From f4e5a9f8e0d4d1a97381fae7f2eaa0615a98b57c Mon Sep 17 00:00:00 2001 From: TIANHE Date: Sat, 24 Jan 2026 03:22:14 +0800 Subject: [PATCH] fix: Multiple bug fixes and improvements - Fix Invalid Date display in Dashboard notifications - Fix timezone offset (8 hours) in Trading Records time display - Fix position closing failures due to commission discrepancies (fetch actual exchange position size for reduce_only orders) - Fix IBKR connection error 'no current event loop in thread' by ensuring asyncio event loop exists - Fix duplicate orders on same candle by extending signal deduplication to close signals - Add responsive design for Profile page (mobile-friendly) - Remove unused strategy_code module and database table - Fix LLM service to support multiple providers (OpenRouter, OpenAI, DeepSeek, Grok, Google) - Add auto-detection of configured LLM provider based on API key availability - Fix AI code generation to use unified LLMService with proper provider selection - Fix crypto symbol format handling (ETH/USDT no longer becomes ETH/USDT/USDT) - Fix Commission display showing '0E-8' in Trading Records - Fix P&L display for signal-only trades (show '--' for unrealized P&L) - Fix OAuth login not updating last_login_at for new users - Add migration script for notification_settings column - Update env.example with new LLM provider configurations - Remove ESLint rule that was not defined in config --- backend_api_python/app/config/api_keys.py | 46 +- backend_api_python/app/routes/__init__.py | 2 - backend_api_python/app/routes/backtest.py | 4 +- backend_api_python/app/routes/indicator.py | 80 ++-- backend_api_python/app/routes/settings.py | 147 +++++- backend_api_python/app/routes/strategy.py | 42 +- .../app/routes/strategy_code.py | 275 ----------- .../app/services/agents/tools.py | 6 +- .../app/services/ibkr_trading/client.py | 25 + backend_api_python/app/services/llm.py | 453 +++++++++++++++--- .../app/services/oauth_service.py | 6 + .../app/services/pending_order_worker.py | 92 ++++ .../app/services/trading_executor.py | 11 +- backend_api_python/app/utils/config_loader.py | 22 + backend_api_python/env.example | 35 +- .../migrations/add_notification_settings.sql | 16 + backend_api_python/migrations/init.sql | 42 +- .../src/components/NoticeIcon/NoticeIcon.vue | 44 +- quantdinger_vue/src/main.js | 2 - quantdinger_vue/src/views/dashboard/index.vue | 30 +- quantdinger_vue/src/views/profile/index.vue | 392 +++++++++++++++ .../components/TradingRecords.vue | 50 +- 22 files changed, 1358 insertions(+), 464 deletions(-) delete mode 100644 backend_api_python/app/routes/strategy_code.py create mode 100644 backend_api_python/migrations/add_notification_settings.sql diff --git a/backend_api_python/app/config/api_keys.py b/backend_api_python/app/config/api_keys.py index c7982b9..165d234 100644 --- a/backend_api_python/app/config/api_keys.py +++ b/backend_api_python/app/config/api_keys.py @@ -21,9 +21,53 @@ class MetaAPIKeys(type): @property def OPENROUTER_API_KEY(cls): + # Always check env var first to avoid stale cache issues + env_val = os.getenv('OPENROUTER_API_KEY', '').strip() + if env_val: + return env_val from app.utils.config_loader import load_addon_config val = load_addon_config().get('openrouter', {}).get('api_key') - return val if val else os.getenv('OPENROUTER_API_KEY', '') + return val if val else '' + + @property + def OPENAI_API_KEY(cls): + """OpenAI direct API key""" + env_val = os.getenv('OPENAI_API_KEY', '').strip() + if env_val: + return env_val + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('openai', {}).get('api_key') + return val if val else '' + + @property + def GOOGLE_API_KEY(cls): + """Google Gemini API key""" + env_val = os.getenv('GOOGLE_API_KEY', '').strip() + if env_val: + return env_val + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('google', {}).get('api_key') + return val if val else '' + + @property + def DEEPSEEK_API_KEY(cls): + """DeepSeek API key""" + env_val = os.getenv('DEEPSEEK_API_KEY', '').strip() + if env_val: + return env_val + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('deepseek', {}).get('api_key') + return val if val else '' + + @property + def GROK_API_KEY(cls): + """xAI Grok API key""" + env_val = os.getenv('GROK_API_KEY', '').strip() + if env_val: + return env_val + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('grok', {}).get('api_key') + return val if val else '' class APIKeys(metaclass=MetaAPIKeys): diff --git a/backend_api_python/app/routes/__init__.py b/backend_api_python/app/routes/__init__.py index 2e2edb8..1c8f15d 100644 --- a/backend_api_python/app/routes/__init__.py +++ b/backend_api_python/app/routes/__init__.py @@ -22,7 +22,6 @@ def register_routes(app: Flask): from app.routes.ibkr import ibkr_bp from app.routes.mt5 import mt5_bp from app.routes.user import user_bp - from app.routes.strategy_code import strategy_code_bp app.register_blueprint(health_bp) app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes @@ -40,4 +39,3 @@ def register_routes(app: Flask): app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio') app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr') app.register_blueprint(mt5_bp, url_prefix='/api/mt5') - app.register_blueprint(strategy_code_bp, url_prefix='/api/strategy-code') diff --git a/backend_api_python/app/routes/backtest.py b/backend_api_python/app/routes/backtest.py index 0dcd15f..58d28d8 100644 --- a/backend_api_python/app/routes/backtest.py +++ b/backend_api_python/app/routes/backtest.py @@ -21,7 +21,9 @@ backtest_service = BacktestService() def _openrouter_base_and_key() -> tuple[str, str]: - key = os.getenv("OPENROUTER_API_KEY", "").strip() + from app.config import APIKeys + # Use APIKeys to get the key (handles env var + config cache properly) + key = APIKeys.OPENROUTER_API_KEY or "" base = os.getenv("OPENROUTER_BASE_URL", "").strip() if not base: api_url = os.getenv("OPENROUTER_API_URL", "").strip() diff --git a/backend_api_python/app/routes/indicator.py b/backend_api_python/app/routes/indicator.py index cd2ab64..bc92c1f 100644 --- a/backend_api_python/app/routes/indicator.py +++ b/backend_api_python/app/routes/indicator.py @@ -493,31 +493,25 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio header = "# Existing code was provided as context.\n" + header return header + body - def _openrouter_base_and_key() -> tuple[str, str]: - """ - Support both: - - OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 - - OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions - """ - key = os.getenv("OPENROUTER_API_KEY", "").strip() - base = os.getenv("OPENROUTER_BASE_URL", "").strip() - if not base: - api_url = os.getenv("OPENROUTER_API_URL", "").strip() - if api_url.endswith("/chat/completions"): - base = api_url[: -len("/chat/completions")] - if not base: - base = "https://openrouter.ai/api/v1" - return base, key - - def _generate_code_via_openrouter() -> str: - base_url, api_key = _openrouter_base_and_key() - if not api_key: + def _generate_code_via_llm() -> str: + """Use unified LLMService to support all configured providers (OpenRouter, OpenAI, Grok, etc.).""" + from app.services.llm import LLMService + + llm = LLMService() + + # Get provider and model from env config (no frontend override) + current_provider = llm.provider + current_model = llm.get_default_model() + current_api_key = llm.get_api_key() + base_url = llm.get_base_url() + + logger.info(f"AI Code Generation - Provider: {current_provider.value}, Model: {current_model}, Base URL: {base_url}, API Key configured: {bool(current_api_key)}") + + # Check if any LLM provider is configured + if not current_api_key: + logger.warning("No LLM API key configured, using template code") return _template_code() - model = os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini").strip() or "openai/gpt-4o-mini" - # Match legacy PHP default more closely - temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7) - # Build user prompt (match PHP behavior) user_prompt = prompt if existing: @@ -529,36 +523,36 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio + "\n\nPlease generate complete new Python code based on the existing code above and my modification requirements. Output the complete Python code directly, without explanations, without segmentation." ) - payload = { - "model": model, - "temperature": temperature, - "stream": False, - "messages": [ + temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7) + + # Call LLM using the unified API (auto-selects provider based on LLM_PROVIDER env) + # use_json_mode=False because we want raw Python code output + content = llm.call_llm_api( + messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], - } - - resp = requests.post( - f"{base_url}/chat/completions", - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - json=payload, - timeout=120, + temperature=temperature, + use_json_mode=False # Code generation doesn't need JSON mode ) - resp.raise_for_status() - j = resp.json() - content = (((j.get("choices") or [{}])[0]).get("message") or {}).get("content") or "" + + # Clean up markdown code blocks if present + content = content.strip() + if content.startswith("```python"): + content = content[9:] + elif content.startswith("```"): + content = content[3:] + if content.endswith("```"): + content = content[:-3] + return content.strip() or _template_code() def stream(): # 不扣任何 QDT:开源本地版直接生成/返回代码 try: - code_text = _generate_code_via_openrouter() + code_text = _generate_code_via_llm() except Exception as e: - logger.warning(f"ai_generate openrouter failed, fallback to template: {e}") + logger.error(f"ai_generate LLM failed, fallback to template. Error: {type(e).__name__}: {e}") code_text = _template_code() # Stream in chunks (front-end appends). diff --git a/backend_api_python/app/routes/settings.py b/backend_api_python/app/routes/settings.py index 35cd5e2..cf90538 100644 --- a/backend_api_python/app/routes/settings.py +++ b/backend_api_python/app/routes/settings.py @@ -92,6 +92,21 @@ CONFIG_SCHEMA = { 'icon': 'robot', 'order': 3, 'items': [ + { + 'key': 'LLM_PROVIDER', + 'label': 'LLM Provider', + 'type': 'select', + 'default': 'openrouter', + 'options': [ + {'value': 'openrouter', 'label': 'OpenRouter (Multi-model gateway)'}, + {'value': 'openai', 'label': 'OpenAI Direct'}, + {'value': 'google', 'label': 'Google Gemini'}, + {'value': 'deepseek', 'label': 'DeepSeek'}, + {'value': 'grok', 'label': 'xAI Grok'}, + ], + 'description': 'Select your preferred LLM provider' + }, + # OpenRouter { 'key': 'OPENROUTER_API_KEY', 'label': 'OpenRouter API Key', @@ -99,37 +114,126 @@ CONFIG_SCHEMA = { 'required': False, 'link': 'https://openrouter.ai/keys', 'link_text': 'settings.link.getApiKey', - 'description': 'OpenRouter API key for AI model access. Supports multiple LLM providers' - }, - { - 'key': 'OPENROUTER_API_URL', - 'label': 'OpenRouter API URL', - 'type': 'text', - 'default': 'https://openrouter.ai/api/v1/chat/completions', - 'description': 'OpenRouter API endpoint URL' + 'description': 'OpenRouter API key. Supports 100+ models via single API', + 'group': 'openrouter' }, { 'key': 'OPENROUTER_MODEL', - 'label': 'Default Model', + 'label': 'OpenRouter Model', 'type': 'text', 'default': 'openai/gpt-4o', 'link': 'https://openrouter.ai/models', 'link_text': 'settings.link.viewModels', - 'description': 'Default LLM model ID, e.g. openai/gpt-4o, anthropic/claude-3.5-sonnet' + 'description': 'Model ID, e.g. openai/gpt-4o, anthropic/claude-3.5-sonnet', + 'group': 'openrouter' }, + # OpenAI Direct + { + 'key': 'OPENAI_API_KEY', + 'label': 'OpenAI API Key', + 'type': 'password', + 'required': False, + 'link': 'https://platform.openai.com/api-keys', + 'link_text': 'settings.link.getApiKey', + 'description': 'OpenAI official API key', + 'group': 'openai' + }, + { + 'key': 'OPENAI_MODEL', + 'label': 'OpenAI Model', + 'type': 'text', + 'default': 'gpt-4o', + 'description': 'Model name: gpt-4o, gpt-4o-mini, gpt-4-turbo, etc.', + 'group': 'openai' + }, + { + 'key': 'OPENAI_BASE_URL', + 'label': 'OpenAI Base URL', + 'type': 'text', + 'default': 'https://api.openai.com/v1', + 'description': 'Custom API endpoint (for proxies or Azure)', + 'group': 'openai' + }, + # Google Gemini + { + 'key': 'GOOGLE_API_KEY', + 'label': 'Google API Key', + 'type': 'password', + 'required': False, + 'link': 'https://aistudio.google.com/apikey', + 'link_text': 'settings.link.getApiKey', + 'description': 'Google AI Studio API key for Gemini', + 'group': 'google' + }, + { + 'key': 'GOOGLE_MODEL', + 'label': 'Gemini Model', + 'type': 'text', + 'default': 'gemini-1.5-flash', + 'description': 'Model: gemini-1.5-flash, gemini-1.5-pro, gemini-2.0-flash-exp', + 'group': 'google' + }, + # DeepSeek + { + 'key': 'DEEPSEEK_API_KEY', + 'label': 'DeepSeek API Key', + 'type': 'password', + 'required': False, + 'link': 'https://platform.deepseek.com/api_keys', + 'link_text': 'settings.link.getApiKey', + 'description': 'DeepSeek API key', + 'group': 'deepseek' + }, + { + 'key': 'DEEPSEEK_MODEL', + 'label': 'DeepSeek Model', + 'type': 'text', + 'default': 'deepseek-chat', + 'description': 'Model: deepseek-chat, deepseek-coder', + 'group': 'deepseek' + }, + { + 'key': 'DEEPSEEK_BASE_URL', + 'label': 'DeepSeek Base URL', + 'type': 'text', + 'default': 'https://api.deepseek.com/v1', + 'description': 'DeepSeek API endpoint', + 'group': 'deepseek' + }, + # xAI Grok + { + 'key': 'GROK_API_KEY', + 'label': 'Grok API Key', + 'type': 'password', + 'required': False, + 'link': 'https://console.x.ai/', + 'link_text': 'settings.link.getApiKey', + 'description': 'xAI Grok API key', + 'group': 'grok' + }, + { + 'key': 'GROK_MODEL', + 'label': 'Grok Model', + 'type': 'text', + 'default': 'grok-beta', + 'description': 'Model: grok-beta, grok-2', + 'group': 'grok' + }, + { + 'key': 'GROK_BASE_URL', + 'label': 'Grok Base URL', + 'type': 'text', + 'default': 'https://api.x.ai/v1', + 'description': 'xAI Grok API endpoint', + 'group': 'grok' + }, + # Common settings { 'key': 'OPENROUTER_TEMPERATURE', 'label': 'Temperature', 'type': 'number', 'default': '0.7', - 'description': 'Model creativity (0-1). Lower = more deterministic, Higher = more creative' - }, - { - 'key': 'OPENROUTER_MAX_TOKENS', - 'label': 'Max Tokens', - 'type': 'number', - 'default': '4000', - 'description': 'Maximum output tokens per request' + 'description': 'Model creativity (0-1). Lower = more deterministic' }, { 'key': 'OPENROUTER_TIMEOUT', @@ -138,13 +242,6 @@ CONFIG_SCHEMA = { 'default': '300', 'description': 'API request timeout in seconds' }, - { - 'key': 'OPENROUTER_CONNECT_TIMEOUT', - 'label': 'Connect Timeout (sec)', - 'type': 'number', - 'default': '30', - 'description': 'Connection establishment timeout in seconds' - }, { 'key': 'AI_MODELS_JSON', 'label': 'Custom Models (JSON)', diff --git a/backend_api_python/app/routes/strategy.py b/backend_api_python/app/routes/strategy.py index 4d85bcd..a9b6834 100644 --- a/backend_api_python/app/routes/strategy.py +++ b/backend_api_python/app/routes/strategy.py @@ -316,8 +316,29 @@ def get_trades(): ) rows = cur.fetchall() or [] cur.close() + + # Convert created_at to UTC timestamp (seconds) for frontend + # This ensures consistent timezone handling + processed_rows = [] + for row in rows: + trade = dict(row) + created_at = trade.get('created_at') + if created_at: + if hasattr(created_at, 'timestamp'): + # datetime object - convert to UTC timestamp + trade['created_at'] = int(created_at.timestamp()) + elif isinstance(created_at, str): + # ISO string - parse and convert + try: + from datetime import datetime + dt = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + trade['created_at'] = int(dt.timestamp()) + except Exception: + pass + processed_rows.append(trade) + # Frontend expects data.trades; keep data.items for compatibility with list-style components. - return jsonify({'code': 1, 'msg': 'success', 'data': {'trades': rows, 'items': rows}}) + return jsonify({'code': 1, 'msg': 'success', 'data': {'trades': processed_rows, 'items': processed_rows}}) except Exception as e: logger.error(f"get_trades failed: {str(e)}") logger.error(traceback.format_exc()) @@ -833,7 +854,24 @@ def get_strategy_notifications(): rows = cur.fetchall() or [] cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': {'items': rows}}) + # Convert created_at to UTC timestamp (seconds) for frontend + processed_rows = [] + for row in rows: + item = dict(row) + created_at = item.get('created_at') + if created_at: + if hasattr(created_at, 'timestamp'): + item['created_at'] = int(created_at.timestamp()) + elif isinstance(created_at, str): + try: + from datetime import datetime + dt = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + item['created_at'] = int(dt.timestamp()) + except Exception: + pass + processed_rows.append(item) + + return jsonify({'code': 1, 'msg': 'success', 'data': {'items': processed_rows}}) except Exception as e: logger.error(f"get_strategy_notifications failed: {str(e)}") logger.error(traceback.format_exc()) diff --git a/backend_api_python/app/routes/strategy_code.py b/backend_api_python/app/routes/strategy_code.py deleted file mode 100644 index a134f55..0000000 --- a/backend_api_python/app/routes/strategy_code.py +++ /dev/null @@ -1,275 +0,0 @@ -""" -Indicator-analysis Strategy APIs (local-first). - -These "strategies" are user-authored Python scripts used on `/indicator-analysis`: -- visualize signals on Kline (via output.plots/output.signals) -- optionally support backtest engine expectations (df signal columns) - -They are different from the live trading executor strategies in `app/routes/strategy.py`. -""" - -from __future__ import annotations - -import json -import os -import re -import time -from typing import Any, Dict - -import requests -from flask import Blueprint, Response, jsonify, request - -from app.utils.db import get_db_connection -from app.utils.logger import get_logger - -logger = get_logger(__name__) - -strategy_code_bp = Blueprint("strategy_code", __name__) - - -def _now_ts() -> int: - return int(time.time()) - - -def _extract_meta_from_code(code: str) -> Dict[str, str]: - if not code or not isinstance(code, str): - return {"name": "", "description": ""} - name_match = re.search(r'^\s*my_indicator_name\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE) - desc_match = re.search(r'^\s*my_indicator_description\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE) - name = (name_match.group(2).strip() if name_match else "")[:100] - description = (desc_match.group(2).strip() if desc_match else "")[:500] - return {"name": name, "description": description} - - -@strategy_code_bp.route("/strategy/getStrategies", methods=["GET"]) -def get_strategies(): - try: - user_id = int(request.args.get("userid") or 1) - with get_db_connection() as db: - cur = db.cursor() - cur.execute( - "SELECT id, user_id, name, code, description, createtime, updatetime FROM qd_strategy_codes WHERE user_id = ? ORDER BY id DESC", - (user_id,), - ) - rows = cur.fetchall() or [] - cur.close() - return jsonify({"code": 1, "msg": "success", "data": rows}) - except Exception as e: - logger.error(f"get_strategies failed: {e}", exc_info=True) - return jsonify({"code": 0, "msg": str(e), "data": []}), 500 - - -@strategy_code_bp.route("/strategy/saveStrategy", methods=["POST"]) -def save_strategy(): - try: - data = request.get_json() or {} - user_id = int(data.get("userid") or 1) - strategy_id = int(data.get("id") or 0) - code = data.get("code") or "" - if not str(code).strip(): - return jsonify({"code": 0, "msg": "code is required", "data": None}), 400 - - name = (data.get("name") or "").strip() - description = (data.get("description") or "").strip() - if not name or not description: - meta = _extract_meta_from_code(code) - if not name: - name = meta.get("name") or "" - if not description: - description = meta.get("description") or "" - if not name: - name = "Custom Strategy" - - now = _now_ts() - with get_db_connection() as db: - cur = db.cursor() - if strategy_id and strategy_id > 0: - cur.execute( - "UPDATE qd_strategy_codes SET name = ?, code = ?, description = ?, updatetime = ? WHERE id = ? AND user_id = ?", - (name, code, description, now, strategy_id, user_id), - ) - else: - cur.execute( - "INSERT INTO qd_strategy_codes (user_id, name, code, description, createtime, updatetime) VALUES (?, ?, ?, ?, ?, ?)", - (user_id, name, code, description, now, now), - ) - strategy_id = int(cur.lastrowid or 0) - db.commit() - cur.close() - - return jsonify({"code": 1, "msg": "success", "data": {"id": strategy_id, "userid": user_id}}) - except Exception as e: - logger.error(f"save_strategy failed: {e}", exc_info=True) - return jsonify({"code": 0, "msg": str(e), "data": None}), 500 - - -@strategy_code_bp.route("/strategy/deleteStrategy", methods=["POST"]) -def delete_strategy(): - try: - data = request.get_json() or {} - user_id = int(data.get("userid") or 1) - strategy_id = int(data.get("id") or 0) - if not strategy_id: - return jsonify({"code": 0, "msg": "id is required", "data": None}), 400 - with get_db_connection() as db: - cur = db.cursor() - cur.execute("DELETE FROM qd_strategy_codes WHERE id = ? AND user_id = ?", (strategy_id, user_id)) - db.commit() - cur.close() - return jsonify({"code": 1, "msg": "success", "data": None}) - except Exception as e: - logger.error(f"delete_strategy failed: {e}", exc_info=True) - return jsonify({"code": 0, "msg": str(e), "data": None}), 500 - - -@strategy_code_bp.route("/strategy/aiGenerate", methods=["POST"]) -def ai_generate_strategy(): - """ - SSE code generation for strategy scripts (local-first, no QDT deduction). - """ - data = request.get_json() or {} - prompt = (data.get("prompt") or "").strip() - existing = (data.get("existingCode") or "").strip() - - if not prompt: - def _err_stream(): - yield "data: " + json.dumps({"error": "提示词不能为空"}, ensure_ascii=False) + "\n\n" - yield "data: [DONE]\n\n" - - return Response(_err_stream(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) - - SYSTEM_PROMPT = """# Role - -You are an expert Python quantitative trading developer. - -# Environment -- Runs in browser (Pyodide): NO network access, no pip, no requests. -- pandas is already imported as pd, numpy as np. DO NOT import them. -- Input: df with columns time/open/high/low/close/volume. - -# Required output (STRICT) -- You MUST define: - - my_indicator_name = "..." - - my_indicator_description = "..." - - output = {"name":..., "plots":[...], "signals":[...]} - -# Chart signal rules (MUST) -- output["signals"] MAY exist, but if present it MUST contain ONLY two types: "buy" and "sell". -- Signals must be aligned with df length: signals[].data length == len(df), use None for "no signal". -- Default signal text MUST be English (recommended "B"/"S" or "Buy"/"Sell"). Do NOT output Chinese text. - -# Execution/backtest compatibility (MUST) -- You MUST set boolean columns: - - df["buy"] and df["sell"] -- Backend will normalize buy/sell into open/close long/short actions based on trade_direction and current position. -- Do NOT emit open_long/close_long/open_short/close_short/add_* in output["signals"]. -- Do NOT implement position sizing, TP/SL, trailing, pyramiding in the script. Those belong to strategy_config / backend. -- Signals are typically confirmed on bar close and executed by backtest on the next bar open (to avoid look-ahead bias). - -# Robustness requirements (IMPORTANT) -- Always handle division-by-zero and NaN/inf when computing indicators (e.g., RSV denominator can be 0). -- Avoid overly restrictive entry conditions that result in zero buys or zero sells. Prefer crossover/event-based signals. -- For multi-indicator strategies, avoid requiring a crossover AND extreme RSI/BB condition on the same bar unless explicitly requested. -- Prefer edge-triggered signals (one-shot) to avoid repeated consecutive buy/sell bars: - buy = raw_buy & ~raw_buy.shift(1).fillna(False) - sell = raw_sell & ~raw_sell.shift(1).fillna(False) - -# Execution rule (IMPORTANT) -- The backtest engine may apply parameterized scaling (scale-in/out) from strategy_config. -- If a candle has a main signal (buy/sell mapped to open/close/reverse), scaling in/out is skipped on the same candle. - -# Output style -- Output Python code only. No markdown code blocks. No extra explanations. -- Keep code comments and default strings in English. -""" - - def _openrouter_base_and_key() -> tuple[str, str]: - key = os.getenv("OPENROUTER_API_KEY", "").strip() - base = os.getenv("OPENROUTER_BASE_URL", "").strip() - if not base: - api_url = os.getenv("OPENROUTER_API_URL", "").strip() - if api_url.endswith("/chat/completions"): - base = api_url[: -len("/chat/completions")] - if not base: - base = "https://openrouter.ai/api/v1" - return base, key - - def _template_code() -> str: - return ( - f'my_indicator_name = "Custom Strategy"\n' - f'my_indicator_description = "{prompt.replace("\\n", " ")[:200]}"\n\n' - "# Buy/Sell only. Execution is normalized in backend.\n" - "df = df.copy()\n" - "sma = df['close'].rolling(14).mean()\n" - "raw_buy = (df['close'] > sma) & (df['close'].shift(1) <= sma.shift(1))\n" - "raw_sell = (df['close'] < sma) & (df['close'].shift(1) >= sma.shift(1))\n" - "# Edge-triggered signals (avoid repeated consecutive signals)\n" - "buy = raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))\n" - "sell = raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))\n" - "df['buy'] = buy.astype(bool)\n" - "df['sell'] = sell.astype(bool)\n" - "\n" - "buy_marks = [df['low'].iloc[i]*0.995 if bool(df['buy'].iloc[i]) else None for i in range(len(df))]\n" - "sell_marks = [df['high'].iloc[i]*1.005 if bool(df['sell'].iloc[i]) else None for i in range(len(df))]\n" - "output = {\n" - " 'name': my_indicator_name,\n" - " 'plots': [ {'name':'SMA 14','data': sma.tolist(),'color':'#1890ff','overlay': True} ],\n" - " 'signals': [\n" - " {'type':'buy','text':'B','data': buy_marks,'color':'#00E676'},\n" - " {'type':'sell','text':'S','data': sell_marks,'color':'#FF5252'}\n" - " ]\n" - "}\n" - ) - - def _generate() -> str: - base_url, api_key = _openrouter_base_and_key() - if not api_key: - return _template_code() - - model = (os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini") or "").strip() or "openai/gpt-4o-mini" - temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7) - - user_prompt = prompt - if existing: - user_prompt = ( - "# Existing Code (modify based on this):\n\n```python\n" - + existing.strip() - + "\n```\n\n# Modification Requirements:\n\n" - + prompt - + "\n\nPlease generate complete new Python code based on the existing code above and my modification requirements. Output the complete Python code directly, without explanations, without segmentation." - ) - - resp = requests.post( - f"{base_url}/chat/completions", - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, - json={ - "model": model, - "temperature": temperature, - "stream": False, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": user_prompt}, - ], - }, - timeout=120, - ) - resp.raise_for_status() - j = resp.json() - content = (((j.get("choices") or [{}])[0]).get("message") or {}).get("content") or "" - return content.strip() or _template_code() - - def stream(): - try: - code_text = _generate() - except Exception as e: - logger.warning(f"strategy aiGenerate failed, fallback template: {e}") - code_text = _template_code() - - chunk_size = 200 - for i in range(0, len(code_text), chunk_size): - yield "data: " + json.dumps({"content": code_text[i : i + chunk_size]}, ensure_ascii=False) + "\n\n" - yield "data: [DONE]\n\n" - - return Response(stream(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) - - diff --git a/backend_api_python/app/services/agents/tools.py b/backend_api_python/app/services/agents/tools.py index 448da78..b4fc227 100644 --- a/backend_api_python/app/services/agents/tools.py +++ b/backend_api_python/app/services/agents/tools.py @@ -137,7 +137,8 @@ class AgentTools: elif market == 'Crypto': exchange = self._ccxt_exchange() - symbol_pair = f'{symbol}/USDT' + # Handle symbol format: ETH/USDT -> ETH/USDT, ETH -> ETH/USDT + symbol_pair = symbol if '/' in symbol else f'{symbol}/USDT' start_time = int((datetime.now() - timedelta(days=days)).timestamp()) # CCXT timeframes: 1d, 1h, 4h ... ccxt_tf = tf if tf in ["1d", "1h", "4h"] else "1d" @@ -233,7 +234,8 @@ class AgentTools: } elif market == 'Crypto': exchange = self._ccxt_exchange() - symbol_pair = f'{symbol}/USDT' + # Handle symbol format: ETH/USDT -> ETH/USDT, ETH -> ETH/USDT + symbol_pair = symbol if '/' in symbol else f'{symbol}/USDT' ticker = exchange.fetch_ticker(symbol_pair) if ticker: return { diff --git a/backend_api_python/app/services/ibkr_trading/client.py b/backend_api_python/app/services/ibkr_trading/client.py index c468138..815193d 100644 --- a/backend_api_python/app/services/ibkr_trading/client.py +++ b/backend_api_python/app/services/ibkr_trading/client.py @@ -6,6 +6,7 @@ Uses ib_insync library to connect to TWS or IB Gateway for trading. import time import threading +import asyncio from dataclasses import dataclass, field from typing import Optional, Dict, Any, List @@ -14,6 +15,25 @@ from app.services.ibkr_trading.symbols import normalize_symbol, format_display_s logger = get_logger(__name__) + +def _ensure_event_loop(): + """ + Ensure there is an event loop in the current thread. + + ib_insync requires an asyncio event loop to function. + When called from Flask request threads, there may not be one. + """ + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + raise RuntimeError("Event loop is closed") + except RuntimeError: + # No event loop exists in this thread, create one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + logger.debug("Created new event loop for IBKR client") + return loop + # Lazy import ib_insync to allow other features to work without it installed ib_insync = None @@ -99,6 +119,9 @@ class IBKRClient: return True try: + # Ensure event loop exists in this thread (required by ib_insync) + _ensure_event_loop() + _ensure_ib_insync() if self._ib is None: @@ -145,6 +168,8 @@ class IBKRClient: def _ensure_connected(self): """Ensure connection is established.""" + # Ensure event loop exists (may be called from different threads) + _ensure_event_loop() if not self.connected: if not self.connect(): raise ConnectionError("Cannot connect to IBKR") diff --git a/backend_api_python/app/services/llm.py b/backend_api_python/app/services/llm.py index 3d08328..3959888 100644 --- a/backend_api_python/app/services/llm.py +++ b/backend_api_python/app/services/llm.py @@ -1,11 +1,13 @@ """ LLM service. -Wraps OpenRouter API calls and robust JSON parsing. +Supports multiple providers: OpenRouter, OpenAI, Google Gemini, DeepSeek, Grok. Kept separate from AnalysisService to avoid circular imports. """ import json +import os import requests from typing import Dict, Any, Optional, List +from enum import Enum from app.utils.logger import get_logger from app.config import APIKeys @@ -14,102 +16,394 @@ from app.utils.config_loader import load_addon_config logger = get_logger(__name__) -class LLMService: - """LLM provider wrapper.""" +class LLMProvider(Enum): + """Supported LLM providers""" + OPENROUTER = "openrouter" + OPENAI = "openai" + GOOGLE = "google" + DEEPSEEK = "deepseek" + GROK = "grok" - def __init__(self): - # Config may not be loaded yet during import time; we resolve lazily via properties. - pass + +# Provider configurations +PROVIDER_CONFIGS = { + LLMProvider.OPENROUTER: { + "base_url": "https://openrouter.ai/api/v1", + "default_model": "openai/gpt-4o", + "fallback_model": "openai/gpt-4o-mini", + }, + LLMProvider.OPENAI: { + "base_url": "https://api.openai.com/v1", + "default_model": "gpt-4o", + "fallback_model": "gpt-4o-mini", + }, + LLMProvider.GOOGLE: { + "base_url": "https://generativelanguage.googleapis.com/v1beta", + "default_model": "gemini-1.5-flash", + "fallback_model": "gemini-1.5-flash", + }, + LLMProvider.DEEPSEEK: { + "base_url": "https://api.deepseek.com/v1", + "default_model": "deepseek-chat", + "fallback_model": "deepseek-chat", + }, + LLMProvider.GROK: { + "base_url": "https://api.x.ai/v1", + "default_model": "grok-beta", + "fallback_model": "grok-beta", + }, +} + + +class LLMService: + """LLM provider wrapper with multi-provider support.""" + + def __init__(self, provider: str = None): + """ + Initialize LLM service. + + Args: + provider: Override the default provider (openrouter, openai, google, deepseek, grok) + """ + self._provider_override = provider + @property + def provider(self) -> LLMProvider: + """Get the active LLM provider.""" + if self._provider_override: + try: + return LLMProvider(self._provider_override.lower()) + except ValueError: + pass + + # Check env/config for provider selection + config = load_addon_config() + provider_name = config.get('llm', {}).get('provider') or os.getenv('LLM_PROVIDER', '') + + if provider_name: + try: + selected = LLMProvider(provider_name.lower()) + # Verify this provider has an API key configured + if self.get_api_key(selected): + return selected + logger.warning(f"LLM_PROVIDER={provider_name} but no API key configured, auto-detecting...") + except ValueError: + pass + + # Auto-detect: find any provider with a configured API key + # Priority: DeepSeek > Grok > OpenAI > Google > OpenRouter + priority_order = [ + LLMProvider.DEEPSEEK, + LLMProvider.GROK, + LLMProvider.OPENAI, + LLMProvider.GOOGLE, + LLMProvider.OPENROUTER, + ] + + for p in priority_order: + if self.get_api_key(p): + logger.info(f"Auto-detected LLM provider: {p.value}") + return p + + # Fallback to OpenRouter (will fail later if no key) + return LLMProvider.OPENROUTER + + def get_api_key(self, provider: LLMProvider = None) -> str: + """Get API key for the specified provider.""" + p = provider or self.provider + + key_map = { + LLMProvider.OPENROUTER: APIKeys.OPENROUTER_API_KEY, + LLMProvider.OPENAI: APIKeys.OPENAI_API_KEY, + LLMProvider.GOOGLE: APIKeys.GOOGLE_API_KEY, + LLMProvider.DEEPSEEK: APIKeys.DEEPSEEK_API_KEY, + LLMProvider.GROK: APIKeys.GROK_API_KEY, + } + return key_map.get(p, "") or "" + + def get_base_url(self, provider: LLMProvider = None) -> str: + """Get base URL for the specified provider.""" + p = provider or self.provider + config = load_addon_config() + + # Check for custom base URL in config + provider_config = config.get(p.value, {}) + custom_url = provider_config.get('base_url') or os.getenv(f'{p.value.upper()}_BASE_URL', '').strip() + + if custom_url: + return custom_url.rstrip('/') + + return PROVIDER_CONFIGS[p]["base_url"] + + def get_default_model(self, provider: LLMProvider = None) -> str: + """Get default model for the specified provider.""" + p = provider or self.provider + config = load_addon_config() + + provider_config = config.get(p.value, {}) + custom_model = provider_config.get('model') or os.getenv(f'{p.value.upper()}_MODEL', '').strip() + + if custom_model: + return custom_model + + return PROVIDER_CONFIGS[p]["default_model"] + + # Legacy properties for backward compatibility @property def api_key(self): - return APIKeys.OPENROUTER_API_KEY + return self.get_api_key() @property def base_url(self): - config = load_addon_config() - # Keep compatible with old/new config keys. - import os - return config.get('openrouter', {}).get('base_url') or os.getenv('OPENROUTER_BASE_URL', "https://openrouter.ai/api/v1") + return self.get_base_url() - def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str: - """Call OpenRouter API, with optional fallback models.""" - config = load_addon_config() - openrouter_config = config.get('openrouter', {}) - - default_model = openrouter_config.get('model', 'openai/gpt-4o') - - if model is None: - model = default_model - - url = f"{self.base_url}/chat/completions" + def _call_openai_compatible(self, messages: list, model: str, temperature: float, + api_key: str, base_url: str, timeout: int, + use_json_mode: bool = True) -> str: + """Call OpenAI-compatible API (OpenAI, DeepSeek, Grok, OpenRouter).""" + url = f"{base_url}/chat/completions" headers = { - "Authorization": f"Bearer {self.api_key}", + "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", - "HTTP-Referer": "https://quantdinger.com", - "X-Title": "QuantDinger Analysis" } + + # OpenRouter specific headers + if "openrouter" in base_url: + headers["HTTP-Referer"] = "https://quantdinger.com" + headers["X-Title"] = "QuantDinger Analysis" - # Build model candidates (primary + optional fallbacks). + data = { + "model": model, + "messages": messages, + "temperature": temperature, + } + + if use_json_mode: + data["response_format"] = {"type": "json_object"} + + response = requests.post(url, headers=headers, json=data, timeout=timeout) + response.raise_for_status() + + result = response.json() + if "choices" in result and len(result["choices"]) > 0: + content = result["choices"][0]["message"]["content"] + if not content: + raise ValueError(f"Model {model} returned empty content") + return content + else: + raise ValueError("API response is missing 'choices'") + + def _call_google_gemini(self, messages: list, model: str, temperature: float, + api_key: str, base_url: str, timeout: int) -> str: + """Call Google Gemini API.""" + url = f"{base_url}/models/{model}:generateContent?key={api_key}" + + # Convert OpenAI message format to Gemini format + contents = [] + system_instruction = None + + for msg in messages: + role = msg["role"] + content = msg["content"] + + if role == "system": + system_instruction = content + elif role == "user": + contents.append({"role": "user", "parts": [{"text": content}]}) + elif role == "assistant": + contents.append({"role": "model", "parts": [{"text": content}]}) + + data = { + "contents": contents, + "generationConfig": { + "temperature": temperature, + "responseMimeType": "application/json", + } + } + + if system_instruction: + data["systemInstruction"] = {"parts": [{"text": system_instruction}]} + + headers = {"Content-Type": "application/json"} + + response = requests.post(url, headers=headers, json=data, timeout=timeout) + response.raise_for_status() + + result = response.json() + if "candidates" in result and len(result["candidates"]) > 0: + candidate = result["candidates"][0] + if "content" in candidate and "parts" in candidate["content"]: + text = candidate["content"]["parts"][0].get("text", "") + if text: + return text + + raise ValueError("Gemini API response is missing content") + + def _normalize_model_for_provider(self, model: str, provider: LLMProvider) -> str: + """ + Normalize model name for the target provider. + + Frontend may send OpenRouter-style model names (e.g., 'openai/gpt-4o'). + This converts them to the correct format for each provider. + """ + if not model: + return self.get_default_model(provider) + + model = model.strip() + + # If using OpenRouter, keep the original format + if provider == LLMProvider.OPENROUTER: + return model + + # For direct providers, extract the model name from OpenRouter format + # e.g., 'openai/gpt-4o' -> 'gpt-4o' + # 'google/gemini-1.5-flash' -> 'gemini-1.5-flash' + # 'deepseek/deepseek-chat' -> 'deepseek-chat' + # 'x-ai/grok-beta' -> 'grok-beta' + + if '/' in model: + prefix, actual_model = model.split('/', 1) + prefix_lower = prefix.lower() + + # Map OpenRouter prefixes to providers + prefix_to_provider = { + 'openai': LLMProvider.OPENAI, + 'google': LLMProvider.GOOGLE, + 'deepseek': LLMProvider.DEEPSEEK, + 'x-ai': LLMProvider.GROK, + 'xai': LLMProvider.GROK, + } + + # If the model prefix matches the current provider, use the extracted model name + matched_provider = prefix_to_provider.get(prefix_lower) + if matched_provider == provider: + return actual_model + + # If model prefix doesn't match current provider, use provider's default model + # This prevents sending 'gpt-4o' to DeepSeek, etc. + logger.warning(f"Model '{model}' doesn't match provider '{provider.value}', using default model") + return self.get_default_model(provider) + + # Model name without prefix - use as is + return model + + def _detect_provider_from_model(self, model: str) -> Optional[LLMProvider]: + """ + Detect which provider a model belongs to based on its name. + Returns None if detection fails. + """ + if not model or '/' not in model: + return None + + prefix = model.split('/')[0].lower() + + prefix_to_provider = { + 'openai': LLMProvider.OPENAI, + 'google': LLMProvider.GOOGLE, + 'deepseek': LLMProvider.DEEPSEEK, + 'x-ai': LLMProvider.GROK, + 'xai': LLMProvider.GROK, + 'anthropic': LLMProvider.OPENROUTER, # Anthropic only via OpenRouter + 'meta': LLMProvider.OPENROUTER, # Meta/Llama only via OpenRouter + 'mistral': LLMProvider.OPENROUTER, # Mistral only via OpenRouter + } + + return prefix_to_provider.get(prefix) + + def call_llm_api(self, messages: list, model: str = None, temperature: float = 0.7, + use_fallback: bool = True, provider: LLMProvider = None, + use_json_mode: bool = True) -> str: + """ + Call LLM API with the specified or default provider. + + Args: + messages: List of message dicts with 'role' and 'content' + model: Model name (uses provider default if not specified). Supports OpenRouter format (e.g., 'openai/gpt-4o') + temperature: Sampling temperature + use_fallback: Whether to try fallback model on failure + provider: Override the service's default provider + use_json_mode: Whether to request JSON output format (default True for analysis, False for code generation) + + Returns: + Generated text content + + Model Resolution Priority: + 1. If model is specified and matches a direct provider (openai/, google/, deepseek/, x-ai/), + use that provider directly if its API key is configured + 2. Otherwise, use the configured LLM_PROVIDER with normalized model name + 3. Fall back to provider's default model if model name is incompatible + """ + # Smart provider detection: if model specifies a provider and we have its API key, use it + if model and not provider: + detected_provider = self._detect_provider_from_model(model) + if detected_provider and detected_provider != LLMProvider.OPENROUTER: + # Check if we have API key for the detected provider + if self.get_api_key(detected_provider): + provider = detected_provider + logger.debug(f"Auto-detected provider '{provider.value}' from model '{model}'") + + p = provider or self.provider + api_key = self.get_api_key(p) + + if not api_key: + raise ValueError(f"API key not configured for provider: {p.value}") + + base_url = self.get_base_url(p) + + # Normalize model name for the provider + model = self._normalize_model_for_provider(model, p) + + config = load_addon_config() + timeout = int(config.get(p.value, {}).get('timeout', 120)) + + # Build model candidates models_to_try = [model] + provider_default_model = PROVIDER_CONFIGS[p]["default_model"] + if use_fallback: + fallback = PROVIDER_CONFIGS[p].get("fallback_model") + if fallback and fallback != model: + models_to_try.append(fallback) - # Fallback models are currently hard-coded for local mode. - fallback_models = ["openai/gpt-4o-mini"] - - if use_fallback and model == default_model: - models_to_try.extend(fallback_models) - last_error = None - timeout = int(openrouter_config.get('timeout', 120)) - for current_model in models_to_try: try: - data = { - "model": current_model, - "messages": messages, - "temperature": temperature, - "response_format": {"type": "json_object"} - } - # logger.debug(f"Trying model: {current_model}") - - response = requests.post(url, headers=headers, json=data, timeout=timeout) - - if response.status_code == 402: - logger.warning(f"OpenRouter returned 402 for model {current_model}; trying fallback model...") - last_error = f"402 Payment Required for model {current_model}" - continue - - response.raise_for_status() - - result = response.json() - if "choices" in result and len(result["choices"]) > 0: - content = result["choices"][0]["message"]["content"] - if not content: - raise ValueError(f"Model {current_model} returned empty content") - - if current_model != model: - logger.info(f"Fallback model succeeded: {current_model}") - return content + if p == LLMProvider.GOOGLE: + return self._call_google_gemini( + messages, current_model, temperature, + api_key, base_url, timeout + ) else: - logger.error(f"OpenRouter API returned unexpected structure ({current_model}): {json.dumps(result)}") - raise ValueError("OpenRouter API response is missing 'choices'") + # OpenAI-compatible providers + return self._call_openai_compatible( + messages, current_model, temperature, + api_key, base_url, timeout, + use_json_mode=use_json_mode + ) except requests.exceptions.HTTPError as e: - logger.error(f"OpenRouter API HTTP error ({current_model}): {e.response.text if e.response else str(e)}") + error_detail = e.response.text if e.response else str(e) + logger.error(f"{p.value} API HTTP error ({current_model}): {error_detail}") last_error = str(e) + + # Check for payment/quota errors + if e.response and e.response.status_code in (402, 429): + logger.warning(f"{p.value} returned {e.response.status_code} for model {current_model}; trying fallback...") + continue + if not use_fallback or current_model == models_to_try[-1]: raise + except requests.exceptions.RequestException as e: - logger.error(f"OpenRouter API request error ({current_model}): {str(e)}") + logger.error(f"{p.value} API request error ({current_model}): {str(e)}") last_error = str(e) if not use_fallback or current_model == models_to_try[-1]: raise + except ValueError as e: logger.warning(f"Model {current_model} returned invalid data: {str(e)}") last_error = str(e) - # If this is not the last candidate model, try the next one if current_model == models_to_try[-1]: raise @@ -117,14 +411,20 @@ class LLMService: logger.error(error_msg) raise Exception(error_msg) - def safe_call_llm(self, system_prompt: str, user_prompt: str, default_structure: Dict[str, Any], model: str = None) -> Dict[str, Any]: + # Legacy method for backward compatibility + def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str: + """Call LLM API (legacy method name for backward compatibility).""" + return self.call_llm_api(messages, model, temperature, use_fallback) + + def safe_call_llm(self, system_prompt: str, user_prompt: str, default_structure: Dict[str, Any], + model: str = None, provider: LLMProvider = None) -> Dict[str, Any]: """Safe LLM call with robust JSON parsing and fallback structure.""" response_text = "" try: - response_text = self.call_openrouter_api([ + response_text = self.call_llm_api([ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} - ], model=model) + ], model=model, provider=provider) # Strip markdown fences if present clean_text = response_text.strip() @@ -159,3 +459,20 @@ class LLMService: logger.error(f"LLM call failed: {str(e)}") default_structure['report'] = f"Analysis failed: {str(e)}" return default_structure + + @classmethod + def get_available_providers(cls) -> List[Dict[str, Any]]: + """Get list of available (configured) providers.""" + providers = [] + + for p in LLMProvider: + service = cls() + api_key = service.get_api_key(p) + providers.append({ + "id": p.value, + "name": p.value.title(), + "configured": bool(api_key), + "default_model": PROVIDER_CONFIGS[p]["default_model"], + }) + + return providers diff --git a/backend_api_python/app/services/oauth_service.py b/backend_api_python/app/services/oauth_service.py index 931c4ee..87ec9fa 100644 --- a/backend_api_python/app/services/oauth_service.py +++ b/backend_api_python/app/services/oauth_service.py @@ -429,6 +429,12 @@ class OAuthService: oauth_info.get('refresh_token')) ) + # Update last_login_at for new OAuth users + cur.execute( + "UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", + (user_id,) + ) + db.commit() cur.close() diff --git a/backend_api_python/app/services/pending_order_worker.py b/backend_api_python/app/services/pending_order_worker.py index 5584d19..1f5d5b6 100644 --- a/backend_api_python/app/services/pending_order_worker.py +++ b/backend_api_python/app/services/pending_order_worker.py @@ -948,6 +948,98 @@ class PendingOrderWorker: def _current_avg() -> float: return float(total_quote / total_base) if total_base > 0 else 0.0 + # For close/reduce signals, query actual exchange position to avoid insufficient balance due to fees + # The exchange position may be smaller than our recorded amount due to trading fees + if reduce_only and market_type == "swap": + try: + actual_pos_size = 0.0 + if isinstance(client, OkxClient): + inst_id = to_okx_swap_inst_id(str(symbol)) + pos_resp = client.get_positions(inst_id=inst_id) + pos_data = (pos_resp.get("data") or []) if isinstance(pos_resp, dict) else [] + for pos in pos_data: + if not isinstance(pos, dict): + continue + pos_inst = str(pos.get("instId") or "").strip() + pos_ps = str(pos.get("posSide") or "").strip().lower() + # Match instrument and position side + if pos_inst == inst_id and pos_ps == pos_side: + # OKX pos field is signed for net mode; use abs for simplicity + pos_qty = abs(float(pos.get("pos") or 0.0)) + # Convert contracts to base amount using ctVal + ct_val = float(pos.get("ctVal") or 0.0) + if ct_val > 0: + actual_pos_size = pos_qty * ct_val + else: + actual_pos_size = pos_qty + break + elif isinstance(client, BinanceFuturesClient): + pos_resp = client.get_positions() or [] + pos_list = pos_resp if isinstance(pos_resp, list) else [] + # Normalize symbol for matching (remove / or -) + norm_sym = str(symbol or "").replace("/", "").replace("-", "").upper() + for pos in pos_list: + if not isinstance(pos, dict): + continue + pos_sym = str(pos.get("symbol") or "").upper() + if pos_sym != norm_sym: + continue + # Match position side + p_side = str(pos.get("positionSide") or "").strip().lower() + if p_side == pos_side or (p_side == "both" and pos_side in ("long", "short")): + pos_amt = abs(float(pos.get("positionAmt") or 0.0)) + if pos_amt > 0: + actual_pos_size = pos_amt + break + elif isinstance(client, BybitClient): + pos_resp = client.get_positions() or {} + pos_list = (pos_resp.get("result") or {}).get("list") or [] if isinstance(pos_resp, dict) else [] + for pos in pos_list: + if not isinstance(pos, dict): + continue + pos_sym = str(pos.get("symbol") or "") + if pos_sym != str(symbol or "").replace("/", ""): + continue + p_side = str(pos.get("side") or "").strip().lower() + if (p_side == "buy" and pos_side == "long") or (p_side == "sell" and pos_side == "short"): + pos_sz = abs(float(pos.get("size") or 0.0)) + if pos_sz > 0: + actual_pos_size = pos_sz + break + elif isinstance(client, BitgetMixClient): + product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") + pos_resp = client.get_positions(product_type=product_type) or {} + pos_list = (pos_resp.get("data") or []) if isinstance(pos_resp, dict) else [] + for pos in pos_list: + if not isinstance(pos, dict): + continue + pos_sym = str(pos.get("symbol") or "") + if pos_sym != str(symbol or ""): + continue + p_side = str(pos.get("holdSide") or "").strip().lower() + if p_side == pos_side: + pos_sz = abs(float(pos.get("total") or pos.get("available") or 0.0)) + if pos_sz > 0: + actual_pos_size = pos_sz + break + + # If we found actual position and it's smaller than requested, use actual size + if actual_pos_size > 0 and actual_pos_size < float(amount or 0.0): + logger.info( + f"Close position adjustment: pending_id={order_id}, strategy_id={strategy_id}, " + f"requested={amount}, actual_pos={actual_pos_size}, using actual" + ) + phases["pos_adjustment"] = { + "requested": float(amount or 0.0), + "actual_position": actual_pos_size, + "using": actual_pos_size, + } + amount = actual_pos_size + except Exception as e: + # Best-effort only; log and continue with original amount + logger.warning(f"Failed to query position for close adjustment: pending_id={order_id}, err={e}") + phases["pos_query_error"] = str(e) + # Decide if we should use limit-first flow. use_limit_first = order_mode in ("maker", "limit", "limit_first", "maker_then_market") diff --git a/backend_api_python/app/services/trading_executor.py b/backend_api_python/app/services/trading_executor.py index 644ffd9..1c49b80 100644 --- a/backend_api_python/app/services/trading_executor.py +++ b/backend_api_python/app/services/trading_executor.py @@ -2370,11 +2370,14 @@ class TradingExecutor: cooldown_sec = 30 # keep small; worker already retries the claimed order via attempts/max_attempts try: stsig = int(signal_ts or 0) - # Strict "same candle" de-dup should ONLY apply to open signals. - # Rationale: on higher timeframes (e.g. 1D), scale-in signals (add_*) may legitimately trigger - # multiple times within the same candle/day as price evolves; we must not block them by candle key. + # Strict "same candle" de-dup applies to open and close signals. + # Rationale: + # - open_* signals should only trigger once per candle (prevents repeated entries) + # - close_* signals should only trigger once per candle (prevents repeated close attempts) + # - add_*/reduce_* signals may legitimately trigger multiple times within same candle + # as price evolves for DCA/scaling strategies sig_norm = str(signal_type or "").strip().lower() - strict_candle_dedup = stsig > 0 and sig_norm in ("open_long", "open_short") + strict_candle_dedup = stsig > 0 and sig_norm in ("open_long", "open_short", "close_long", "close_short") if strict_candle_dedup: cur.execute( diff --git a/backend_api_python/app/utils/config_loader.py b/backend_api_python/app/utils/config_loader.py index 9cb7646..f439683 100644 --- a/backend_api_python/app/utils/config_loader.py +++ b/backend_api_python/app/utils/config_loader.py @@ -70,6 +70,28 @@ def load_addon_config() -> Dict[str, Any]: ('OPENROUTER_TIMEOUT', 'openrouter.timeout', 'int'), ('OPENROUTER_CONNECT_TIMEOUT', 'openrouter.connect_timeout', 'int'), ('AI_MODELS_JSON', 'ai.models', 'json'), + + # OpenAI Direct + ('OPENAI_API_KEY', 'openai.api_key', 'string'), + ('OPENAI_BASE_URL', 'openai.base_url', 'string'), + ('OPENAI_MODEL', 'openai.model', 'string'), + + # Google Gemini + ('GOOGLE_API_KEY', 'google.api_key', 'string'), + ('GOOGLE_MODEL', 'google.model', 'string'), + + # DeepSeek + ('DEEPSEEK_API_KEY', 'deepseek.api_key', 'string'), + ('DEEPSEEK_BASE_URL', 'deepseek.base_url', 'string'), + ('DEEPSEEK_MODEL', 'deepseek.model', 'string'), + + # xAI Grok + ('GROK_API_KEY', 'grok.api_key', 'string'), + ('GROK_BASE_URL', 'grok.base_url', 'string'), + ('GROK_MODEL', 'grok.model', 'string'), + + # LLM Provider Selection + ('LLM_PROVIDER', 'llm.provider', 'string'), # App ('CORS_ORIGINS', 'app.cors_origins', 'string'), diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 9ab19d1..3af3c98 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -145,7 +145,13 @@ ENABLE_REFLECTION_WORKER=false REFLECTION_WORKER_INTERVAL_SEC=86400 # ========================= -# OpenRouter / LLM +# LLM Provider Selection +# ========================= +# Choose your LLM provider: openrouter, openai, google, deepseek, grok +LLM_PROVIDER=openrouter + +# ========================= +# OpenRouter (Multi-model gateway, recommended) # ========================= OPENROUTER_API_KEY= OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions @@ -155,6 +161,33 @@ OPENROUTER_MAX_TOKENS=4000 OPENROUTER_TIMEOUT=300 OPENROUTER_CONNECT_TIMEOUT=30 +# ========================= +# OpenAI Direct +# ========================= +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4o + +# ========================= +# Google Gemini +# ========================= +GOOGLE_API_KEY= +GOOGLE_MODEL=gemini-1.5-flash + +# ========================= +# DeepSeek +# ========================= +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +DEEPSEEK_MODEL=deepseek-chat + +# ========================= +# xAI Grok +# ========================= +GROK_API_KEY= +GROK_BASE_URL=https://api.x.ai/v1 +GROK_MODEL=grok-beta + # Optional: override model list shown in UI (JSON object: {"model_id":"Display Name", ...}) AI_MODELS_JSON={} diff --git a/backend_api_python/migrations/add_notification_settings.sql b/backend_api_python/migrations/add_notification_settings.sql new file mode 100644 index 0000000..a658ef6 --- /dev/null +++ b/backend_api_python/migrations/add_notification_settings.sql @@ -0,0 +1,16 @@ +-- Migration: Add notification_settings column to qd_users table +-- Run this if you see error: column "notification_settings" does not exist + +-- Add notification_settings column (if not exists) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'qd_users' AND column_name = 'notification_settings' + ) THEN + ALTER TABLE qd_users ADD COLUMN notification_settings TEXT DEFAULT ''; + RAISE NOTICE 'Column notification_settings added to qd_users'; + ELSE + RAISE NOTICE 'Column notification_settings already exists'; + END IF; +END $$; diff --git a/backend_api_python/migrations/init.sql b/backend_api_python/migrations/init.sql index 6617528..582a51f 100644 --- a/backend_api_python/migrations/init.sql +++ b/backend_api_python/migrations/init.sql @@ -296,25 +296,7 @@ CREATE TABLE IF NOT EXISTS qd_indicator_codes ( CREATE INDEX IF NOT EXISTS idx_indicator_codes_user_id ON qd_indicator_codes(user_id); -- ============================================================================= --- 8. Strategy Codes --- ============================================================================= - -CREATE TABLE IF NOT EXISTS qd_strategy_codes ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL DEFAULT '', - code TEXT, - description TEXT DEFAULT '', - createtime BIGINT, - updatetime BIGINT, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_strategy_codes_user_id ON qd_strategy_codes(user_id); - --- ============================================================================= --- 9. AI Decisions +-- 8. AI Decisions -- ============================================================================= CREATE TABLE IF NOT EXISTS qd_ai_decisions ( @@ -329,7 +311,7 @@ CREATE TABLE IF NOT EXISTS qd_ai_decisions ( CREATE INDEX IF NOT EXISTS idx_ai_decisions_user_id ON qd_ai_decisions(user_id); -- ============================================================================= --- 10. Addon Config +-- 9. Addon Config -- ============================================================================= CREATE TABLE IF NOT EXISTS qd_addon_config ( @@ -339,7 +321,7 @@ CREATE TABLE IF NOT EXISTS qd_addon_config ( ); -- ============================================================================= --- 11. Watchlist +-- 10. Watchlist -- ============================================================================= CREATE TABLE IF NOT EXISTS qd_watchlist ( @@ -356,7 +338,7 @@ CREATE TABLE IF NOT EXISTS qd_watchlist ( CREATE INDEX IF NOT EXISTS idx_watchlist_user_id ON qd_watchlist(user_id); -- ============================================================================= --- 12. Analysis Tasks +-- 11. Analysis Tasks -- ============================================================================= CREATE TABLE IF NOT EXISTS qd_analysis_tasks ( @@ -376,7 +358,7 @@ CREATE TABLE IF NOT EXISTS qd_analysis_tasks ( CREATE INDEX IF NOT EXISTS idx_analysis_tasks_user_id ON qd_analysis_tasks(user_id); -- ============================================================================= --- 13. Backtest Runs +-- 12. Backtest Runs -- ============================================================================= CREATE TABLE IF NOT EXISTS qd_backtest_runs ( @@ -404,7 +386,7 @@ CREATE INDEX IF NOT EXISTS idx_backtest_runs_user_id ON qd_backtest_runs(user_id CREATE INDEX IF NOT EXISTS idx_backtest_runs_indicator_id ON qd_backtest_runs(indicator_id); -- ============================================================================= --- 14. Exchange Credentials +-- 13. Exchange Credentials -- ============================================================================= CREATE TABLE IF NOT EXISTS qd_exchange_credentials ( @@ -421,7 +403,7 @@ CREATE TABLE IF NOT EXISTS qd_exchange_credentials ( CREATE INDEX IF NOT EXISTS idx_exchange_credentials_user_id ON qd_exchange_credentials(user_id); -- ============================================================================= --- 15. Manual Positions +-- 14. Manual Positions -- ============================================================================= CREATE TABLE IF NOT EXISTS qd_manual_positions ( @@ -445,7 +427,7 @@ CREATE TABLE IF NOT EXISTS qd_manual_positions ( CREATE INDEX IF NOT EXISTS idx_manual_positions_user_id ON qd_manual_positions(user_id); -- ============================================================================= --- 16. Position Alerts +-- 15. Position Alerts -- ============================================================================= CREATE TABLE IF NOT EXISTS qd_position_alerts ( @@ -471,7 +453,7 @@ CREATE INDEX IF NOT EXISTS idx_position_alerts_user_id ON qd_position_alerts(use CREATE INDEX IF NOT EXISTS idx_position_alerts_position_id ON qd_position_alerts(position_id); -- ============================================================================= --- 17. Position Monitors +-- 16. Position Monitors -- ============================================================================= CREATE TABLE IF NOT EXISTS qd_position_monitors ( @@ -494,7 +476,7 @@ CREATE TABLE IF NOT EXISTS qd_position_monitors ( CREATE INDEX IF NOT EXISTS idx_position_monitors_user_id ON qd_position_monitors(user_id); -- ============================================================================= --- 18. Market Symbols (Seed Data) +-- 17. Market Symbols (Seed Data) -- ============================================================================= CREATE TABLE IF NOT EXISTS qd_market_symbols ( @@ -585,7 +567,7 @@ INSERT INTO qd_market_symbols (market, symbol, name, exchange, currency, is_acti ON CONFLICT (market, symbol) DO NOTHING; -- ============================================================================= --- 19. Agent Memories (AI Learning System) +-- 18. Agent Memories (AI Learning System) -- ============================================================================= -- Stores agent decision experiences for RAG-style retrieval during analysis. -- Each agent (trader, risk_analyst, etc.) shares this table but is identified by agent_name. @@ -611,7 +593,7 @@ CREATE INDEX IF NOT EXISTS idx_agent_memories_created ON qd_agent_memories(agent CREATE INDEX IF NOT EXISTS idx_agent_memories_market ON qd_agent_memories(agent_name, market, symbol); -- ============================================================================= --- 20. Reflection Records (AI Auto-Verification System) +-- 19. Reflection Records (AI Auto-Verification System) -- ============================================================================= -- Records analysis predictions for future auto-verification and closed-loop learning. diff --git a/quantdinger_vue/src/components/NoticeIcon/NoticeIcon.vue b/quantdinger_vue/src/components/NoticeIcon/NoticeIcon.vue index fb9b9ab..a849e2f 100644 --- a/quantdinger_vue/src/components/NoticeIcon/NoticeIcon.vue +++ b/quantdinger_vue/src/components/NoticeIcon/NoticeIcon.vue @@ -290,7 +290,30 @@ export default { }, formatTime (timestamp) { if (!timestamp) return '' - const date = new Date(timestamp * 1000) + // 支持多种时间格式:ISO字符串、秒级时间戳、毫秒级时间戳 + let date + if (typeof timestamp === 'number') { + // 数字类型:判断是秒级还是毫秒级时间戳 + date = new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp) + } else if (typeof timestamp === 'string') { + // 字符串类型 + if (/^\d+$/.test(timestamp)) { + // 纯数字字符串(时间戳) + const ts = parseInt(timestamp, 10) + date = new Date(ts < 1e12 ? ts * 1000 : ts) + } else { + // ISO 日期字符串或其他格式 + date = new Date(timestamp) + } + } else { + return '' + } + + // 检查日期是否有效 + if (isNaN(date.getTime())) { + return '' + } + const now = new Date() const diff = now - date const minutes = Math.floor(diff / 60000) @@ -311,7 +334,24 @@ export default { }, formatFullTime (timestamp) { if (!timestamp) return '' - const date = new Date(timestamp * 1000) + // 支持多种时间格式:ISO字符串、秒级时间戳、毫秒级时间戳 + let date + if (typeof timestamp === 'number') { + date = new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp) + } else if (typeof timestamp === 'string') { + if (/^\d+$/.test(timestamp)) { + const ts = parseInt(timestamp, 10) + date = new Date(ts < 1e12 ? ts * 1000 : ts) + } else { + date = new Date(timestamp) + } + } else { + return '' + } + + if (isNaN(date.getTime())) { + return '' + } return date.toLocaleString() }, formatMessageHtml (message) { diff --git a/quantdinger_vue/src/main.js b/quantdinger_vue/src/main.js index 4d5a4b9..608dc04 100644 --- a/quantdinger_vue/src/main.js +++ b/quantdinger_vue/src/main.js @@ -40,11 +40,9 @@ if (typeof window !== 'undefined') { // mount axios to `Vue.$http` and `this.$http` Vue.use(VueAxios) // use pro-layout components -/* eslint-disable vue/component-definition-name-casing */ Vue.component('pro-layout', ProLayout) Vue.component('page-container', PageHeaderWrapper) Vue.component('page-header-wrapper', PageHeaderWrapper) -/* eslint-enable vue/component-definition-name-casing */ window.umi_plugin_ant_themeVar = themePluginConfig.theme diff --git a/quantdinger_vue/src/views/dashboard/index.vue b/quantdinger_vue/src/views/dashboard/index.vue index d30fbfb..fa05ddf 100644 --- a/quantdinger_vue/src/views/dashboard/index.vue +++ b/quantdinger_vue/src/views/dashboard/index.vue @@ -379,9 +379,9 @@ {{ getSignalTypeText(text) }} -