diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6d2bec9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.py] +indent_size = 4 + +[*.{js,jsx,ts,tsx,vue,json,yml,yaml,md,css,scss,less,html}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/workflows/basic-ci.yml b/.github/workflows/basic-ci.yml index fe4cbdd..c72d130 100644 --- a/.github/workflows/basic-ci.yml +++ b/.github/workflows/basic-ci.yml @@ -17,7 +17,7 @@ jobs: python-check: name: Python Syntax Check runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -59,12 +59,12 @@ jobs: python -c " import sys sys.path.insert(0, '.') - + # Import key modules to verify they are loadable from app import create_app from app.config import settings from app.routes import health - + print('✓ Core modules imported successfully') print('✓ No critical import errors detected') " @@ -77,7 +77,7 @@ jobs: frontend-check: name: Frontend Build Check runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d01a267 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,42 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-toml + - id: check-yaml + - id: trailing-whitespace + + - repo: local + hooks: + - id: backend-ruff-format + name: backend ruff format + entry: ruff format backend_api_python + language: python + additional_dependencies: + - ruff + pass_filenames: false + files: ^backend_api_python/.*\.py$ + + - id: backend-ruff-check + name: backend ruff check + entry: ruff check --fix backend_api_python + language: python + additional_dependencies: + - ruff + pass_filenames: false + files: ^backend_api_python/.*\.py$ + + # - id: frontend-prettier-check + # name: frontend prettier write + # entry: yarn --cwd frontend_vue format + # language: system + # pass_filenames: false + # files: ^frontend_vue/(src|tests/unit)/.*\.(js|vue|json|css|less|scss|md)$|^frontend_vue/(package\.json|vue\.config\.js|babel\.config\.js|postcss\.config\.js|jest\.config\.js|jsconfig\.json|\.eslintrc\.js|\.prettierrc)$ + + # - id: frontend-eslint-check + # name: frontend eslint fix + # entry: yarn --cwd frontend_vue lint:fix + # language: system + # pass_filenames: false + # files: ^frontend_vue/.*\.(js|vue)$|^frontend_vue/(package\.json|vue\.config\.js|babel\.config\.js|postcss\.config\.js|jest\.config\.js|\.eslintrc\.js)$ \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 194fc35..843c363 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -66,11 +66,11 @@ This Code of Conduct applies to all official QuantDinger spaces, including: If you experience or witness behavior that violates this Code of Conduct, please report it through one of the following channels: -1. **GitHub** - Open an issue labeled `conduct`. +1. **GitHub** + Open an issue labeled `conduct`. Please avoid including sensitive or private information. -2. **Email** +2. **Email** Contact the project maintainer via the email listed in `README.md`. If there is an immediate safety concern, contact your local emergency services. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e3cb1fa --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +.PHONY: install-backend-dev lint-backend format-backend check-backend check-backend-docker fix-backend fix-backend-docker install-frontend lint-frontend format-frontend check-frontend check-code format-code install-git-hooks run-pre-commit run-pre-commit-all + +install-backend-dev: + cd backend_api_python && python3 -m pip install -r requirements-dev.txt + +lint-backend: + cd backend_api_python && ruff check . + +format-backend: + cd backend_api_python && ruff format . + +check-backend: + cd backend_api_python && ruff format --check . && ruff check . && vulture app scripts run.py gunicorn_config.py + +check-backend-docker: + docker run --rm -v "$(PWD)/backend_api_python:/app" -w /app python:3.12-slim-bookworm sh -lc "pip install -q ruff vulture && ruff format --check . && ruff check . && vulture app scripts run.py gunicorn_config.py" + +fix-backend: + cd backend_api_python && ruff check . --fix && ruff format . + +fix-backend-docker: + docker run --rm -v "$(PWD)/backend_api_python:/app" -w /app python:3.12-slim-bookworm sh -lc "pip install -q ruff && ruff check . --fix && ruff format ." + +install-frontend: + cd frontend_vue && yarn install + +lint-frontend: + cd frontend_vue && yarn lint:check + +format-frontend: + cd frontend_vue && yarn format + +check-frontend: + cd frontend_vue && yarn format:check && yarn lint:check + +check-code: check-backend check-frontend + +format-code: format-backend format-frontend + +install-git-hooks: + pre-commit install + +run-pre-commit: + pre-commit run + +run-pre-commit-all: + pre-commit run -a diff --git a/README.md b/README.md index 982fdb2..d79e519 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@

AI-Native Quantitative Trading Platform

Vibe Coding Meets Algo Trading

-

+

🇨🇳 中文  ·  - 🇺🇸 English + 🇺🇸 English

- + 🌐 Live Demo  ·  📺 Video  ·  💬 Community  ·  @@ -93,7 +93,7 @@ docker-compose up -d --build > - Launch: > `docker-compose up -d --build` -> **Windows PowerShell**: +> **Windows PowerShell**: > - Copy backend config: > `Copy-Item backend_api_python\env.example -Destination backend_api_python\.env` > - Need more knobs? Scroll to the lower "Advanced / rarely changed" section inside: diff --git a/TRADEMARKS.md b/TRADEMARKS.md index cad8e0d..b83e3d5 100644 --- a/TRADEMARKS.md +++ b/TRADEMARKS.md @@ -1,6 +1,6 @@ # QuantDinger Trademarks & Branding Policy -This document governs the use of **QuantDinger** trademarks and brand assets (including the name, logo, and visual identity). +This document governs the use of **QuantDinger** trademarks and brand assets (including the name, logo, and visual identity). It is **separate** from the code license. The code in this repository is licensed under the **Apache License 2.0** (see `LICENSE`). > Note: This policy is provided for clarity and is not legal advice. @@ -20,7 +20,7 @@ The following are considered **QuantDinger brand assets**: ## 2) Apache 2.0 vs. trademarks (important) -Apache License 2.0 governs the **code** and allows you to fork, modify, and redistribute the code under its terms. +Apache License 2.0 governs the **code** and allows you to fork, modify, and redistribute the code under its terms. However, **Apache 2.0 does not grant trademark rights**. Trademarks are governed by this policy. You must still comply with Apache 2.0 requirements (e.g., preserving license/copyright notices, and `NOTICE` if present). diff --git a/backend_api_python/app/__init__.py b/backend_api_python/app/__init__.py index 70d90d7..d5a876d 100644 --- a/backend_api_python/app/__init__.py +++ b/backend_api_python/app/__init__.py @@ -1,12 +1,13 @@ """ -QuantDinger Python API - Flask application factory. +DinQuant Python API - Flask application factory. """ -from flask import Flask -from flask_cors import CORS -import logging + import traceback -from app.utils.logger import setup_logger, get_logger +from flask import Flask +from flask_cors import CORS + +from app.utils.logger import get_logger, setup_logger logger = get_logger(__name__) @@ -20,6 +21,7 @@ def get_trading_executor(): global _trading_executor if _trading_executor is None: from app.services.trading_executor import TradingExecutor + _trading_executor = TradingExecutor() return _trading_executor @@ -29,6 +31,7 @@ def get_pending_order_worker(): global _pending_order_worker if _pending_order_worker is None: from app.services.pending_order_worker import PendingOrderWorker + _pending_order_worker = PendingOrderWorker() return _pending_order_worker @@ -37,6 +40,7 @@ def start_polymarket_worker(): """Start the Polymarket background task.""" try: from app.services.polymarket_worker import get_polymarket_worker + get_polymarket_worker().start() except Exception as e: logger.error(f"Failed to start Polymarket worker: {e}") @@ -44,23 +48,25 @@ def start_polymarket_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}") @@ -72,9 +78,10 @@ def start_pending_order_worker(): To enable it, set ENABLE_PENDING_ORDER_WORKER=true. """ import os + # Local deployment: default to enabled so queued orders can be dispatched automatically. # To disable it, set ENABLE_PENDING_ORDER_WORKER=false explicitly. - if os.getenv('ENABLE_PENDING_ORDER_WORKER', 'true').lower() != 'true': + if os.getenv("ENABLE_PENDING_ORDER_WORKER", "true").lower() != "true": logger.info("Pending order worker is disabled (paper mode). Set ENABLE_PENDING_ORDER_WORKER=true to enable.") return try: @@ -91,6 +98,7 @@ def start_usdt_order_worker(): Only starts if USDT_PAY_ENABLED=true. """ import os + if str(os.getenv("USDT_PAY_ENABLED", "False")).lower() not in ("1", "true", "yes"): logger.info("USDT order worker not started (USDT_PAY_ENABLED is not true).") return @@ -103,6 +111,7 @@ def start_usdt_order_worker(): try: from app.services.usdt_payment_service import get_usdt_order_worker + get_usdt_order_worker().start() except Exception as e: logger.error(f"Failed to start USDT order worker: {e}") @@ -114,37 +123,38 @@ def restore_running_strategies(): Local deployment: only restores IndicatorStrategy. """ import os + # You can disable auto-restore to avoid starting many threads on low-resource hosts. - if os.getenv('DISABLE_RESTORE_RUNNING_STRATEGIES', 'false').lower() == 'true': + if os.getenv("DISABLE_RESTORE_RUNNING_STRATEGIES", "false").lower() == "true": logger.info("Startup strategy restore is disabled via DISABLE_RESTORE_RUNNING_STRATEGIES") return try: from app.services.strategy import StrategyService - + strategy_service = StrategyService() trading_executor = get_trading_executor() - + running_strategies = strategy_service.get_running_strategies_with_type() - + if not running_strategies: logger.info("No running strategies to restore.") return - + logger.info(f"Restoring {len(running_strategies)} running strategies...") - + restored_count = 0 for strategy_info in running_strategies: - strategy_id = strategy_info['id'] - strategy_type = strategy_info.get('strategy_type', '') - + strategy_id = strategy_info["id"] + strategy_type = strategy_info.get("strategy_type", "") + try: - if strategy_type and strategy_type != 'IndicatorStrategy': + if strategy_type and strategy_type != "IndicatorStrategy": logger.info(f"Skip restore unsupported strategy type: id={strategy_id}, type={strategy_type}") continue success = trading_executor.start_strategy(strategy_id) - strategy_type_name = 'IndicatorStrategy' - + strategy_type_name = "IndicatorStrategy" + if success: restored_count += 1 logger.info(f"[OK] {strategy_type_name} {strategy_id} restored") @@ -152,55 +162,58 @@ def restore_running_strategies(): logger.warning(f"[FAIL] {strategy_type_name} {strategy_id} restore failed (state may be stale)") # If recovery fails, update the database status to stopped to prevent the policy from being in a "zombie" state. try: - strategy_service.update_strategy_status(strategy_id, 'stopped') + strategy_service.update_strategy_status(strategy_id, "stopped") logger.info(f"[FIX] Updated strategy {strategy_id} status to 'stopped' after restore failure") except Exception as e: logger.error(f"Failed to update strategy {strategy_id} status after restore failure: {e}") except Exception as e: logger.error(f"Error restoring strategy {strategy_id}: {str(e)}") logger.error(traceback.format_exc()) - + logger.info(f"Strategy restore completed: {restored_count}/{len(running_strategies)} restored") - + except Exception as e: logger.error(f"Failed to restore running strategies: {str(e)}") logger.error(traceback.format_exc()) # Do not raise; avoid breaking app startup. -def create_app(config_name='default'): +def create_app(config_name="default"): """ Flask application factory. - + Args: config_name: config name - + Returns: Flask app """ app = Flask(__name__) - - app.config['JSON_AS_ASCII'] = False - + + app.config["JSON_AS_ASCII"] = False + CORS(app) - + setup_logger() - + # Initialize database and ensure admin user exists try: - from app.utils.db import init_database, get_db_type + from app.utils.db import get_db_type, init_database + logger.info(f"Database type: {get_db_type()}") init_database() - + # Ensure admin user exists (multi-user mode) from app.services.user_service import get_user_service + get_user_service().ensure_admin_exists() except Exception as e: logger.warning(f"Database initialization note: {e}") from app.routes import register_routes + register_routes(app) - + # Startup hooks. with app.app_context(): start_pending_order_worker() @@ -210,15 +223,17 @@ def create_app(config_name='default'): # Offline calibration to make AI thresholds self-tuning. try: from app.services.ai_calibration import start_ai_calibration_worker + start_ai_calibration_worker() except Exception: pass # Reflection worker: validate past decisions, run calibration periodically. try: from app.services.reflection import start_reflection_worker + start_reflection_worker() except Exception: pass restore_running_strategies() - + return app diff --git a/backend_api_python/app/config/__init__.py b/backend_api_python/app/config/__init__.py index bb4029e..30d904c 100644 --- a/backend_api_python/app/config/__init__.py +++ b/backend_api_python/app/config/__init__.py @@ -2,34 +2,24 @@ Configuration module Export all configurations uniformly """ -from app.config.settings import Config + from app.config.api_keys import APIKeys -from app.config.database import RedisConfig, CacheConfig -from app.config.data_sources import ( - DataSourceConfig, - FinnhubConfig, - TiingoConfig, - YFinanceConfig, - CCXTConfig, - AkshareConfig -) +from app.config.data_sources import CCXTConfig, DataSourceConfig, FinnhubConfig, TiingoConfig, YFinanceConfig +from app.config.database import CacheConfig, RedisConfig +from app.config.settings import Config __all__ = [ # main configuration - 'Config', - + "Config", # API key - 'APIKeys', - + "APIKeys", # Database/cache - 'RedisConfig', - 'CacheConfig', - + "RedisConfig", + "CacheConfig", # data source - 'DataSourceConfig', - 'FinnhubConfig', - 'TiingoConfig', - 'YFinanceConfig', - 'CCXTConfig', - 'AkshareConfig', + "DataSourceConfig", + "FinnhubConfig", + "TiingoConfig", + "YFinanceConfig", + "CCXTConfig", ] diff --git a/backend_api_python/app/config/api_keys.py b/backend_api_python/app/config/api_keys.py index c241c43..ffe4261 100644 --- a/backend_api_python/app/config/api_keys.py +++ b/backend_api_python/app/config/api_keys.py @@ -2,103 +2,106 @@ API key configuration. All third-party keys should be provided via environment variables (recommended: backend_api_python/.env). """ + import os + class MetaAPIKeys(type): """API Keys metaclass, used to support dynamic acquisition of class attributes""" + @property def FINNHUB_API_KEY(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('finnhub', {}).get('api_key') - return val if val else os.getenv('FINNHUB_API_KEY', '') - + + val = load_addon_config().get("finnhub", {}).get("api_key") + return val if val else os.getenv("FINNHUB_API_KEY", "") + @property def TIINGO_API_KEY(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('tiingo', {}).get('api_key') - return val if val else os.getenv('TIINGO_API_KEY', '') - @property - def TWELVE_DATA_API_KEY(cls): - env_val = os.getenv('TWELVE_DATA_API_KEY', '').strip() - if env_val: - return env_val - from app.utils.config_loader import load_addon_config - val = load_addon_config().get('twelve_data', {}).get('api_key') - return val if val else '' - + val = load_addon_config().get("tiingo", {}).get("api_key") + return val if val else os.getenv("TIINGO_API_KEY", "") + @property def OPENROUTER_API_KEY(cls): # Always check env var first to avoid stale cache issues - env_val = os.getenv('OPENROUTER_API_KEY', '').strip() + 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 '' - + + val = load_addon_config().get("openrouter", {}).get("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() + 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 '' - + + 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() + 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 '' - + + 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() + 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 '' - + + 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() + 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 '' - + + val = load_addon_config().get("grok", {}).get("api_key") + return val if val else "" + @property def TAVILY_API_KEYS(cls): """Tavily Search API keys (comma-separated for rotation)""" - env_val = os.getenv('TAVILY_API_KEYS', '').strip() + env_val = os.getenv("TAVILY_API_KEYS", "").strip() if env_val: - return [k.strip() for k in env_val.split(',') if k.strip()] + return [k.strip() for k in env_val.split(",") if k.strip()] from app.utils.config_loader import load_addon_config - val = load_addon_config().get('tavily', {}).get('api_keys', '') + + val = load_addon_config().get("tavily", {}).get("api_keys", "") if val: - return [k.strip() for k in val.split(',') if k.strip()] + return [k.strip() for k in val.split(",") if k.strip()] return [] - + @property def SERPAPI_KEYS(cls): """SerpAPI keys (comma-separated for rotation)""" - env_val = os.getenv('SERPAPI_KEYS', '').strip() + env_val = os.getenv("SERPAPI_KEYS", "").strip() if env_val: - return [k.strip() for k in env_val.split(',') if k.strip()] + return [k.strip() for k in env_val.split(",") if k.strip()] from app.utils.config_loader import load_addon_config - val = load_addon_config().get('serpapi', {}).get('api_keys', '') + + val = load_addon_config().get("serpapi", {}).get("api_keys", "") if val: - return [k.strip() for k in val.split(',') if k.strip()] + return [k.strip() for k in val.split(",") if k.strip()] return [] @@ -106,13 +109,13 @@ class APIKeys(metaclass=MetaAPIKeys): """API key configuration class""" @classmethod - def get(cls, key_name: str, default: str = '') -> str: + def get(cls, key_name: str, default: str = "") -> str: """Get API key""" # Try to get from class attribute if hasattr(cls, key_name): return getattr(cls, key_name) return default - + @classmethod def is_configured(cls, key_name: str) -> bool: """Check if the API key is configured""" diff --git a/backend_api_python/app/config/data_sources.py b/backend_api_python/app/config/data_sources.py index 9629c79..57725a4 100644 --- a/backend_api_python/app/config/data_sources.py +++ b/backend_api_python/app/config/data_sources.py @@ -1,30 +1,36 @@ """ Data source configuration """ + import os + class MetaDataSourceConfig(type): @property def DEFAULT_TIMEOUT(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('data_source', {}).get('timeout') - return int(val) if val is not None else int(os.getenv('DATA_SOURCE_TIMEOUT', 30)) + + val = load_addon_config().get("data_source", {}).get("timeout") + return int(val) if val is not None else int(os.getenv("DATA_SOURCE_TIMEOUT", 30)) @property def RETRY_COUNT(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('data_source', {}).get('retry_count') - return int(val) if val is not None else int(os.getenv('DATA_SOURCE_RETRY', 3)) + + val = load_addon_config().get("data_source", {}).get("retry_count") + return int(val) if val is not None else int(os.getenv("DATA_SOURCE_RETRY", 3)) @property def RETRY_BACKOFF(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('data_source', {}).get('retry_backoff') - return float(val) if val is not None else float(os.getenv('DATA_SOURCE_RETRY_BACKOFF', 0.5)) + + val = load_addon_config().get("data_source", {}).get("retry_backoff") + return float(val) if val is not None else float(os.getenv("DATA_SOURCE_RETRY_BACKOFF", 0.5)) class DataSourceConfig(metaclass=MetaDataSourceConfig): """General data source configuration.""" + pass @@ -36,14 +42,16 @@ class MetaFinnhubConfig(type): @property def TIMEOUT(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('finnhub', {}).get('timeout') - return int(val) if val is not None else int(os.getenv('FINNHUB_TIMEOUT', 10)) + + val = load_addon_config().get("finnhub", {}).get("timeout") + return int(val) if val is not None else int(os.getenv("FINNHUB_TIMEOUT", 10)) @property def RATE_LIMIT(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('finnhub', {}).get('rate_limit') - return int(val) if val is not None else int(os.getenv('FINNHUB_RATE_LIMIT', 60)) + + val = load_addon_config().get("finnhub", {}).get("rate_limit") + return int(val) if val is not None else int(os.getenv("FINNHUB_RATE_LIMIT", 60)) @property def RATE_LIMIT_PERIOD(cls): @@ -53,6 +61,7 @@ class MetaFinnhubConfig(type): class FinnhubConfig(metaclass=MetaFinnhubConfig): """Finnhub data source configuration""" + class MetaTiingoConfig(type): @property def BASE_URL(cls): @@ -61,49 +70,46 @@ class MetaTiingoConfig(type): @property def TIMEOUT(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('tiingo', {}).get('timeout') - return int(val) if val is not None else int(os.getenv('TIINGO_TIMEOUT', 10)) + + val = load_addon_config().get("tiingo", {}).get("timeout") + return int(val) if val is not None else int(os.getenv("TIINGO_TIMEOUT", 10)) class TiingoConfig(metaclass=MetaTiingoConfig): """Tiingo data source configuration""" + class MetaYFinanceConfig(type): @property def TIMEOUT(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('yfinance', {}).get('timeout') - return int(val) if val is not None else int(os.getenv('YFINANCE_TIMEOUT', 30)) - + + val = load_addon_config().get("yfinance", {}).get("timeout") + return int(val) if val is not None else int(os.getenv("YFINANCE_TIMEOUT", 30)) + @property def INTERVAL_MAP(cls): - return { - '1m': '1m', - '5m': '5m', - '15m': '15m', - '30m': '30m', - '1H': '1h', - '4H': '4h', - '1D': '1d', - '1W': '1wk' - } + return {"1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m", "1H": "1h", "4H": "4h", "1D": "1d", "1W": "1wk"} class YFinanceConfig(metaclass=MetaYFinanceConfig): """Yahoo Finance data source configuration""" + class MetaCCXTConfig(type): @property def DEFAULT_EXCHANGE(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('ccxt', {}).get('default_exchange') - return val if val else os.getenv('CCXT_DEFAULT_EXCHANGE', 'binance') + + val = load_addon_config().get("ccxt", {}).get("default_exchange") + return val if val else os.getenv("CCXT_DEFAULT_EXCHANGE", "binance") @property def TIMEOUT(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('ccxt', {}).get('timeout') - return int(val) if val is not None else int(os.getenv('CCXT_TIMEOUT', 10000)) + + val = load_addon_config().get("ccxt", {}).get("timeout") + return int(val) if val is not None else int(os.getenv("CCXT_TIMEOUT", 10000)) @property def ENABLE_RATE_LIMIT(cls): @@ -111,53 +117,26 @@ class MetaCCXTConfig(type): @property def TIMEFRAME_MAP(cls): - return { - '1m': '1m', - '5m': '5m', - '15m': '15m', - '30m': '30m', - '1H': '1h', - '4H': '4h', - '1D': '1d', - '1W': '1w' - } + return {"1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m", "1H": "1h", "4H": "4h", "1D": "1d", "1W": "1w"} @property def PROXY(cls): # 1) Local proxy helpers from backend_api_python/.env # PROXY_URL has the highest priority if provided. - proxy_url = (os.getenv('PROXY_URL') or '').strip() + proxy_url = (os.getenv("PROXY_URL") or "").strip() if proxy_url: return proxy_url # 2) Standard proxy envs (fallback) - for key in ['HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY']: - v = (os.getenv(key) or '').strip() + for key in ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"]: + v = (os.getenv(key) or "").strip() if v: return v - return '' + return "" class CCXTConfig(metaclass=MetaCCXTConfig): """CCXT cryptocurrency data source configuration.""" + pass - - -class MetaAkshareConfig(type): - @property - def TIMEOUT(cls): - from app.utils.config_loader import load_addon_config - val = load_addon_config().get('akshare', {}).get('timeout') - return int(val) if val is not None else int(os.getenv('AKSHARE_TIMEOUT', 30)) - - @property - def PERIOD_MAP(cls): - return { - '1D': 'daily', - '1W': 'weekly' - } - - -class AkshareConfig(metaclass=MetaAkshareConfig): - """Akshare data source configuration""" diff --git a/backend_api_python/app/config/database.py b/backend_api_python/app/config/database.py index 88524fc..8405be4 100644 --- a/backend_api_python/app/config/database.py +++ b/backend_api_python/app/config/database.py @@ -1,37 +1,40 @@ """ Database and cache configuration """ + import os + class MetaRedisConfig(type): - """Redis configuration""" + """Redis configuration""" + @property def HOST(cls): - return os.getenv('REDIS_HOST', 'localhost') - + return os.getenv("REDIS_HOST", "localhost") + @property def PORT(cls): - return int(os.getenv('REDIS_PORT', 6379)) - + return int(os.getenv("REDIS_PORT", 6379)) + @property def PASSWORD(cls): - return os.getenv('REDIS_PASSWORD', None) - + return os.getenv("REDIS_PASSWORD", None) + @property def DB(cls): - return int(os.getenv('REDIS_DB', 0)) - + return int(os.getenv("REDIS_DB", 0)) + @property def CONNECT_TIMEOUT(cls): - return int(os.getenv('REDIS_CONNECT_TIMEOUT', 5)) - + return int(os.getenv("REDIS_CONNECT_TIMEOUT", 5)) + @property def SOCKET_TIMEOUT(cls): - return int(os.getenv('REDIS_SOCKET_TIMEOUT', 5)) - + return int(os.getenv("REDIS_SOCKET_TIMEOUT", 5)) + @property def MAX_CONNECTIONS(cls): - return int(os.getenv('REDIS_MAX_CONNECTIONS', 10)) + return int(os.getenv("REDIS_MAX_CONNECTIONS", 10)) class RedisConfig(metaclass=MetaRedisConfig): @@ -50,27 +53,27 @@ class MetaCacheConfig(type): def ENABLED(cls): # Forced to be off by default unless explicitly enabled by an environment variable - return os.getenv('CACHE_ENABLED', 'False').lower() == 'true' + return os.getenv("CACHE_ENABLED", "False").lower() == "true" @property def DEFAULT_EXPIRE(cls): - return int(os.getenv('CACHE_EXPIRE', 300)) + return int(os.getenv("CACHE_EXPIRE", 300)) @property def KLINE_CACHE_TTL(cls): return { - '1m': 5, # K-line cache for 1 minute and 5 seconds - '3m': 30, # 3 minutes K-line cache 30 seconds - '5m': 60, # 5 minutes K-line cache 1 minute - '15m': 300, # 15 minutes K-line cache 5 minutes - '30m': 300, # 30 minutes K-line cache 5 minutes - '1H': 300, # 1 hour K-line cache for 5 minutes - '4H': 300, # 4 hours K-line cache for 5 minutes - '1D': 300, # Daily K-line cache for 5 minutes + "1m": 5, # K-line cache for 1 minute and 5 seconds + "3m": 30, # 3 minutes K-line cache 30 seconds + "5m": 60, # 5 minutes K-line cache 1 minute + "15m": 300, # 15 minutes K-line cache 5 minutes + "30m": 300, # 30 minutes K-line cache 5 minutes + "1H": 300, # 1 hour K-line cache for 5 minutes + "4H": 300, # 4 hours K-line cache for 5 minutes + "1D": 300, # Daily K-line cache for 5 minutes # Compatible with lowercase - '1h': 300, - '4h': 300, - '1d': 300, + "1h": 300, + "4h": 300, + "1d": 300, } @property @@ -84,4 +87,5 @@ class MetaCacheConfig(type): class CacheConfig(metaclass=MetaCacheConfig): """Cache configuration.""" + pass diff --git a/backend_api_python/app/config/settings.py b/backend_api_python/app/config/settings.py index 6e79a6a..018d984 100644 --- a/backend_api_python/app/config/settings.py +++ b/backend_api_python/app/config/settings.py @@ -1,93 +1,99 @@ """ Apply main configuration """ + import os + class MetaConfig(type): # ==================== Service Configuration ==================== # Service startup parameters are usually determined by environment variables or command line parameters. Reading from the database is not recommended. - + @property def HOST(cls): - return os.getenv('PYTHON_API_HOST', '0.0.0.0') + return os.getenv("PYTHON_API_HOST", "0.0.0.0") @property def PORT(cls): - return int(os.getenv('PYTHON_API_PORT', 5000)) + return int(os.getenv("PYTHON_API_PORT", 5000)) @property def DEBUG(cls): - return os.getenv('PYTHON_API_DEBUG', 'False').lower() == 'true' + return os.getenv("PYTHON_API_DEBUG", "False").lower() == "true" @property def APP_NAME(cls): - return 'QuantDinger Python API' + return "QuantDinger Python API" @property def VERSION(cls): - return '2.0.0' + return "2.0.0" # ==================== Authentication configuration ==================== @property def SECRET_KEY(cls): - return os.getenv('SECRET_KEY', 'quantdinger-secret-key-change-me') + return os.getenv("SECRET_KEY", "quantdinger-secret-key-change-me") @property def ADMIN_USER(cls): - return os.getenv('ADMIN_USER', 'quantdinger') + return os.getenv("ADMIN_USER", "quantdinger") @property def ADMIN_PASSWORD(cls): - return os.getenv('ADMIN_PASSWORD', '123456') + return os.getenv("ADMIN_PASSWORD", "123456") # ==================== Log configuration ==================== # Log configuration is usually required at the earliest stage of application startup. It is recommended to maintain environment variables. - + @property def LOG_LEVEL(cls): - return os.getenv('LOG_LEVEL', 'INFO') + return os.getenv("LOG_LEVEL", "INFO") @property def LOG_DIR(cls): - return os.getenv('LOG_DIR', 'logs') + return os.getenv("LOG_DIR", "logs") @property def LOG_FILE(cls): - return os.getenv('LOG_FILE', 'app.log') + return os.getenv("LOG_FILE", "app.log") @property def LOG_MAX_BYTES(cls): - return int(os.getenv('LOG_MAX_BYTES', 10 * 1024 * 1024)) + return int(os.getenv("LOG_MAX_BYTES", 10 * 1024 * 1024)) @property def LOG_BACKUP_COUNT(cls): - return int(os.getenv('LOG_BACKUP_COUNT', 5)) + return int(os.getenv("LOG_BACKUP_COUNT", 5)) # ==================== Security Configuration ==================== @property def RATE_LIMIT(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('app', {}).get('rate_limit') - return int(val) if val is not None else int(os.getenv('RATE_LIMIT', 100)) + + val = load_addon_config().get("app", {}).get("rate_limit") + return int(val) if val is not None else int(os.getenv("RATE_LIMIT", 100)) # ==================== Function switch ==================== @property def ENABLE_CACHE(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('app', {}).get('enable_cache') + + val = load_addon_config().get("app", {}).get("enable_cache") if val is not None: return bool(val) - return os.getenv('ENABLE_CACHE', 'False').lower() == 'true' + return os.getenv("ENABLE_CACHE", "False").lower() == "true" @property def ENABLE_REQUEST_LOG(cls): from app.utils.config_loader import load_addon_config - val = load_addon_config().get('app', {}).get('enable_request_log') + + val = load_addon_config().get("app", {}).get("enable_request_log") if val is not None: return bool(val) - return os.getenv('ENABLE_REQUEST_LOG', 'True').lower() == 'true' + return os.getenv("ENABLE_REQUEST_LOG", "True").lower() == "true" + class Config(metaclass=MetaConfig): """Application configuration class.""" diff --git a/backend_api_python/app/data/market_symbols_seed.py b/backend_api_python/app/data/market_symbols_seed.py index e89817b..ced4439 100644 --- a/backend_api_python/app/data/market_symbols_seed.py +++ b/backend_api_python/app/data/market_symbols_seed.py @@ -18,6 +18,7 @@ def _get_db_connection(): """Get database connection, returns None if not available.""" try: from app.utils.db import get_db_connection + return get_db_connection() except Exception: return None @@ -26,18 +27,18 @@ def _get_db_connection(): def get_hot_symbols(market: str, limit: int = 10) -> List[Dict]: """ Get hot symbols for a market. - + Args: market: Market name (e.g., 'Crypto', 'USStock', 'Forex') limit: Maximum number of results - + Returns: List of {market, symbol, name} dicts """ - market = (market or '').strip() + market = (market or "").strip() if not market: return [] - + try: with _get_db_connection() as db: cur = db.cursor() @@ -48,11 +49,11 @@ def get_hot_symbols(market: str, limit: int = 10) -> List[Dict]: ORDER BY sort_order DESC LIMIT ? """, - (market, max(limit, 0)) + (market, max(limit, 0)), ) rows = cur.fetchall() or [] cur.close() - return [{'market': r['market'], 'symbol': r['symbol'], 'name': r.get('name') or ''} for r in rows] + return [{"market": r["market"], "symbol": r["symbol"], "name": r.get("name") or ""} for r in rows] except Exception as e: logger.debug(f"get_hot_symbols from DB failed: {e}") return [] @@ -61,23 +62,23 @@ def get_hot_symbols(market: str, limit: int = 10) -> List[Dict]: def search_symbols(market: str, keyword: str, limit: int = 20) -> List[Dict]: """ Search symbols by keyword. - + Args: market: Market name keyword: Search keyword (matches symbol or name) limit: Maximum number of results - + Returns: List of {market, symbol, name} dicts """ - market = (market or '').strip() - kw = (keyword or '').strip() + market = (market or "").strip() + kw = (keyword or "").strip() if not market or not kw: return [] - + # Use ILIKE for case-insensitive search in PostgreSQL - pattern = f'%{kw}%' - + pattern = f"%{kw}%" + try: with _get_db_connection() as db: cur = db.cursor() @@ -89,11 +90,11 @@ def search_symbols(market: str, keyword: str, limit: int = 20) -> List[Dict]: ORDER BY sort_order DESC LIMIT ? """, - (market, pattern, pattern, max(limit, 0)) + (market, pattern, pattern, max(limit, 0)), ) rows = cur.fetchall() or [] cur.close() - return [{'market': r['market'], 'symbol': r['symbol'], 'name': r.get('name') or ''} for r in rows] + return [{"market": r["market"], "symbol": r["symbol"], "name": r.get("name") or ""} for r in rows] except Exception as e: logger.debug(f"search_symbols from DB failed: {e}") return [] @@ -101,8 +102,8 @@ def search_symbols(market: str, keyword: str, limit: int = 20) -> List[Dict]: def _normalize_for_match(market: str, symbol: str) -> str: """Normalize symbol for matching.""" - m = (market or '').strip() - s = (symbol or '').strip().upper() + m = (market or "").strip() + s = (symbol or "").strip().upper() if not m or not s: return s @@ -112,15 +113,15 @@ def _normalize_for_match(market: str, symbol: str) -> str: def get_symbol_name(market: str, symbol: str) -> Optional[str]: """ Get display name for a symbol. - + Args: market: Market name symbol: Symbol (e.g., 'AAPL', 'BTC/USDT', '600519') - + Returns: Symbol name or None if not found """ - m = (market or '').strip() + m = (market or "").strip() if not m: return None @@ -130,7 +131,7 @@ def get_symbol_name(market: str, symbol: str) -> Optional[str]: # Crypto: allow user to pass BTC (try BTC/USDT) or full pair candidate_symbols = [s] - if m == 'Crypto' and '/' not in s: + if m == "Crypto" and "/" not in s: candidate_symbols.append(f"{s}/USDT") try: @@ -138,27 +139,26 @@ def get_symbol_name(market: str, symbol: str) -> Optional[str]: cur = db.cursor() for cand in candidate_symbols: cur.execute( - "SELECT name FROM qd_market_symbols WHERE market = ? AND UPPER(symbol) = ?", - (m, cand.upper()) + "SELECT name FROM qd_market_symbols WHERE market = ? AND UPPER(symbol) = ?", (m, cand.upper()) ) row = cur.fetchone() - if row and row.get('name'): + if row and row.get("name"): cur.close() - return str(row['name']) + return str(row["name"]) cur.close() except Exception as e: logger.debug(f"get_symbol_name from DB failed: {e}") - + return None def get_all_symbols(market: str = None) -> List[Dict]: """ Get all active symbols, optionally filtered by market. - + Args: market: Optional market filter - + Returns: List of symbol records """ @@ -173,7 +173,7 @@ def get_all_symbols(market: str = None) -> List[Dict]: WHERE market = ? AND is_active = 1 ORDER BY sort_order DESC """, - (market.strip(),) + (market.strip(),), ) else: cur.execute( diff --git a/backend_api_python/app/data_sources/__init__.py b/backend_api_python/app/data_sources/__init__.py index d1b3c45..923a4d5 100644 --- a/backend_api_python/app/data_sources/__init__.py +++ b/backend_api_python/app/data_sources/__init__.py @@ -7,38 +7,26 @@ Improved version (refer to daily_stock_analysis project): - Data cache (cache_manager) - Anti-ban strategy (rate_limiter) """ + +from app.data_sources.cache_manager import DataCache, get_kline_cache, get_realtime_cache, get_stock_info_cache +from app.data_sources.circuit_breaker import CircuitBreaker, get_realtime_circuit_breaker from app.data_sources.factory import DataSourceFactory -from app.data_sources.circuit_breaker import ( - CircuitBreaker, - get_realtime_circuit_breaker -) -from app.data_sources.cache_manager import ( - DataCache, - get_realtime_cache, - get_kline_cache, - get_stock_info_cache -) -from app.data_sources.rate_limiter import ( - RateLimiter, - get_random_user_agent, - random_sleep, - retry_with_backoff -) +from app.data_sources.rate_limiter import RateLimiter, get_random_user_agent, random_sleep, retry_with_backoff __all__ = [ # factory - 'DataSourceFactory', + "DataSourceFactory", # fuse - 'CircuitBreaker', - 'get_realtime_circuit_breaker', + "CircuitBreaker", + "get_realtime_circuit_breaker", # cache - 'DataCache', - 'get_realtime_cache', - 'get_kline_cache', - 'get_stock_info_cache', + "DataCache", + "get_realtime_cache", + "get_kline_cache", + "get_stock_info_cache", # current limiter - 'RateLimiter', - 'get_random_user_agent', - 'random_sleep', - 'retry_with_backoff', + "RateLimiter", + "get_random_user_agent", + "random_sleep", + "retry_with_backoff", ] diff --git a/backend_api_python/app/data_sources/asia_stock_kline.py b/backend_api_python/app/data_sources/asia_stock_kline.py deleted file mode 100644 index 0506d45..0000000 --- a/backend_api_python/app/data_sources/asia_stock_kline.py +++ /dev/null @@ -1,566 +0,0 @@ -""" -A-share / H-share chart K-lines — multi-tier fallback. - -Priority order (when TWELVE_DATA_API_KEY is configured): - ALL timeframes → Twelve Data (paid, globally stable) → Tencent daily/weekly → yfinance → AkShare - -Without API key: - Daily / Weekly → Tencent fqkline (fast, no key) → yfinance → AkShare - Minute / Hour → yfinance → AkShare (Eastmoney, fragile overseas) - -Tencent ``fqkline`` only reliably supports day/week/month. -yfinance supports CN (.SS/.SZ) and HK (.HK) at all common intervals. -Twelve Data (https://twelvedata.com) supports XSHG/XSHE/XHKG at all intervals. -""" - -from __future__ import annotations - -import os -import time -from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional - -import pandas as pd -import requests - -from app.utils.logger import get_logger - -logger = get_logger(__name__) - -_MAX_ATTEMPTS = 3 -_BACKOFF_BASE_SEC = 1.5 -_BACKOFF_CAP_SEC = 12.0 - -_TRANSIENT_ERR_MARKERS = ( - "remote end closed connection", - "connection aborted", - "connection reset", - "timed out", - "timeout", - "max retries exceeded", - "temporarily unavailable", - "broken pipe", - "eof occurred", - "remote disconnected", - "chunkedencodingerror", - "incompleteread", - "rate", - "too many requests", - "429", -) - - -def _is_transient(exc: BaseException) -> bool: - return any(m in str(exc).lower() for m in _TRANSIENT_ERR_MARKERS) - - -_CHART_TF_ALIASES = { - "1w": "1W", - "1d": "1D", - "1h": "1H", - "4h": "4H", - "d": "1D", - "day": "1D", - "w": "1W", - "week": "1W", - "wk": "1W", - "60m": "1H", - "240m": "4H", - "1day": "1D", - "1week": "1W", -} - - -def normalize_chart_timeframe(timeframe: str) -> str: - t = (timeframe or "1D").strip() - if not t: - return "1D" - key = t.lower() - if key in _CHART_TF_ALIASES: - return _CHART_TF_ALIASES[key] - return t - - -# --------------------------------------------------------------------------- -# AkShare code converters -# --------------------------------------------------------------------------- - -def ak_a_code_from_tencent(tencent_code: str) -> str: - c = (tencent_code or "").strip().lower() - if len(c) >= 8 and c[:2] in ("sh", "sz"): - return c[2:] - return c - - -def ak_hk_code_from_tencent(tencent_code: str) -> str: - c = (tencent_code or "").strip().upper().replace(".HK", "") - if c.startswith("HK"): - num = c[2:] - else: - num = c - if num.isdigit(): - return num.zfill(5) - return num - - -# --------------------------------------------------------------------------- -# Twelve Data (paid, globally reliable — https://twelvedata.com) -# --------------------------------------------------------------------------- - -def _get_twelve_data_api_key() -> str: - try: - from app.utils.config_loader import load_addon_config - key = load_addon_config().get("twelve_data", {}).get("api_key", "") - if key: - return key - except Exception: - pass - return (os.getenv("TWELVE_DATA_API_KEY") or "").strip() - - -_TD_INTERVAL_MAP = { - "1m": "1min", - "5m": "5min", - "15m": "15min", - "30m": "30min", - "1H": "1h", - "4H": "4h", - "1D": "1day", - "1W": "1week", -} - - -def _td_symbol_and_exchange(tencent_code: str, is_hk: bool) -> tuple[str, str]: - """Convert Tencent code to Twelve Data (symbol, exchange).""" - c = (tencent_code or "").strip().upper() - if is_hk: - num = c.replace("HK", "") - if num.isdigit(): - num = str(int(num)).zfill(4) - return num, "XHKG" - digits = c.lstrip("SHSZ") - if c.startswith("SH") or digits.startswith("6"): - return digits, "XSHG" - return digits, "XSHE" - - -def fetch_twelvedata_klines( - *, - is_hk: bool, - tencent_code: str, - timeframe: str, - limit: int, - before_time: Optional[int], -) -> List[Dict[str, Any]]: - """Fetch K-lines from Twelve Data REST API. Requires TWELVE_DATA_API_KEY.""" - api_key = _get_twelve_data_api_key() - if not api_key: - return [] - - interval = _TD_INTERVAL_MAP.get(timeframe) - if not interval: - return [] - - symbol, exchange = _td_symbol_and_exchange(tencent_code, is_hk) - params: Dict[str, Any] = { - "symbol": symbol, - "exchange": exchange, - "interval": interval, - "outputsize": min(int(limit), 5000), - "apikey": api_key, - "format": "JSON", - "dp": "4", - } - if before_time: - end_dt = datetime.fromtimestamp(int(before_time)) - params["end_date"] = end_dt.strftime("%Y-%m-%d %H:%M:%S") - - url = "https://api.twelvedata.com/time_series" - - for attempt in range(_MAX_ATTEMPTS): - try: - resp = requests.get(url, params=params, timeout=20) - data = resp.json() - break - except Exception as e: - if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e): - delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt)) - logger.debug( - "TwelveData transient error %s/%s tf=%s (attempt %s/%s): %s", - symbol, exchange, timeframe, attempt + 1, _MAX_ATTEMPTS, e, - ) - time.sleep(delay) - continue - logger.warning("TwelveData request failed %s/%s tf=%s: %s", symbol, exchange, timeframe, e) - return [] - else: - return [] - - if data.get("status") != "ok" or "values" not in data: - code = data.get("code", "") - msg = data.get("message", str(data)) - if code == 429 or "API credits" in msg or "minute limit" in msg: - logger.warning("TwelveData rate limit for %s/%s: %s", symbol, exchange, msg) - else: - logger.warning("TwelveData error %s/%s tf=%s: %s", symbol, exchange, timeframe, msg) - return [] - - out: List[Dict[str, Any]] = [] - for v in data["values"]: - try: - dt_str = v.get("datetime", "") - for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"): - try: - ts = int(datetime.strptime(dt_str, fmt).timestamp()) - break - except ValueError: - continue - else: - continue - o = float(v["open"]) - h = float(v["high"]) - low = float(v["low"]) - c = float(v["close"]) - vol = float(v.get("volume") or 0) - if o == 0 and c == 0: - continue - out.append({ - "time": ts, - "open": round(o, 4), - "high": round(h, 4), - "low": round(low, 4), - "close": round(c, 4), - "volume": round(vol, 2), - }) - except Exception: - continue - - out.sort(key=lambda x: x["time"]) - logger.debug("TwelveData returned %d bars for %s/%s tf=%s", len(out), symbol, exchange, timeframe) - return out - - -# --------------------------------------------------------------------------- -# yfinance helpers (globally accessible — Yahoo CDN) -# --------------------------------------------------------------------------- - -def yf_symbol_from_tencent(tencent_code: str, is_hk: bool) -> str: - """Convert Tencent-style code (SH600519 / SZ000001 / HK00700) to yfinance ticker.""" - c = (tencent_code or "").strip().upper() - if is_hk: - num = c.replace("HK", "") - if num.isdigit(): - return str(int(num)).zfill(4) + ".HK" - return num + ".HK" - if c.startswith("SH"): - return c[2:] + ".SS" - if c.startswith("SZ"): - return c[2:] + ".SZ" - digits = c.lstrip("SHSZ") - if digits.startswith("6"): - return digits + ".SS" - return digits + ".SZ" - - -_YF_INTERVAL_MAP = { - "1m": "1m", - "5m": "5m", - "15m": "15m", - "30m": "30m", - "1H": "1h", - "4H": "1h", - "1D": "1d", - "1W": "1wk", -} - -_YF_DAYS_MAP = { - "1m": lambda lim: min(7, max(2, (lim // 240) + 2)), - "5m": lambda lim: min(60, max(3, (lim // 48) + 3)), - "15m": lambda lim: min(60, max(3, (lim // 16) + 3)), - "30m": lambda lim: min(60, max(5, (lim // 8) + 5)), - "1H": lambda lim: min(730, max(8, (lim // 4) + 8)), - "4H": lambda lim: min(730, max(20, lim + 10)), - "1D": lambda lim: min(3650, lim + 10), - "1W": lambda lim: min(3650, lim * 7 + 30), -} - - -def _bars_from_yfinance_df(df: Any) -> List[Dict[str, Any]]: - """Convert a yfinance DataFrame (with DatetimeIndex or Date/Datetime column) to bar dicts.""" - if df is None or getattr(df, "empty", True): - return [] - df = df.reset_index() - time_col = None - for candidate in ("Datetime", "Date", "index"): - if candidate in df.columns: - time_col = candidate - break - if time_col is None: - return [] - out: List[Dict[str, Any]] = [] - for _, row in df.iterrows(): - try: - tv = row[time_col] - if hasattr(tv, "timestamp"): - ts = int(tv.timestamp()) - else: - continue - o, h, low, c, v = ( - float(row["Open"]), - float(row["High"]), - float(row["Low"]), - float(row["Close"]), - float(row["Volume"]), - ) - if o == 0 and c == 0: - continue - out.append({ - "time": ts, - "open": round(o, 4), - "high": round(h, 4), - "low": round(low, 4), - "close": round(c, 4), - "volume": round(v, 2), - }) - except Exception: - continue - out.sort(key=lambda x: x["time"]) - return out - - -def fetch_yfinance_klines( - *, - is_hk: bool, - tencent_code: str, - timeframe: str, - limit: int, - before_time: Optional[int], -) -> List[Dict[str, Any]]: - """Fetch K-lines via yfinance for CN/HK stocks. Globally accessible, no API key needed.""" - try: - import yfinance as yf - except ImportError: - logger.debug("yfinance not installed; skipping yfinance K-lines") - return [] - - interval = _YF_INTERVAL_MAP.get(timeframe) - if not interval: - return [] - - yf_sym = yf_symbol_from_tencent(tencent_code, is_hk) - effective_limit = limit * 4 if timeframe == "4H" else limit - days_func = _YF_DAYS_MAP.get(timeframe, lambda x: x + 10) - days = days_func(effective_limit) - - end = datetime.fromtimestamp(int(before_time)) if before_time else datetime.now() - start = end - timedelta(days=days) - - df: Any = None - for attempt in range(_MAX_ATTEMPTS): - try: - ticker = yf.Ticker(yf_sym) - df = ticker.history( - start=start.strftime("%Y-%m-%d"), - end=(end + timedelta(days=1)).strftime("%Y-%m-%d"), - interval=interval, - ) - break - except Exception as e: - if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e): - delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt)) - logger.debug( - "yfinance transient error %s tf=%s (attempt %s/%s), retry in %.1fs: %s", - yf_sym, timeframe, attempt + 1, _MAX_ATTEMPTS, delay, e, - ) - time.sleep(delay) - continue - logger.warning("yfinance K-line failed %s tf=%s: %s", yf_sym, timeframe, e) - return [] - - bars = _bars_from_yfinance_df(df) - if timeframe == "4H" and bars: - bars = _merge_every_n_sorted_bars(bars, 4) - logger.debug("yfinance returned %d bars for %s tf=%s", len(bars), yf_sym, timeframe) - return bars - - -# --------------------------------------------------------------------------- -# AkShare helpers (Eastmoney — unreliable from overseas, used as last resort) -# --------------------------------------------------------------------------- - -def _minute_period_str(timeframe: str) -> Optional[str]: - return {"1m": "1", "5m": "5", "15m": "15", "30m": "30", "1H": "60", "4H": "60"}.get(timeframe) - - -def _min_bar_window(timeframe: str, limit: int, before_time: Optional[int]) -> tuple[str, str]: - _ = (timeframe, limit) - end = datetime.fromtimestamp(int(before_time)) if before_time else datetime.now() - start = end - timedelta(days=16) - fmt = "%Y-%m-%d %H:%M:%S" - return start.strftime(fmt), end.strftime(fmt) - - -def _bars_from_ak_min_df(df: Any) -> List[Dict[str, Any]]: - if df is None or getattr(df, "empty", True): - return [] - cols = [str(x) for x in df.columns] - time_c = "时间" if "时间" in cols else (cols[0] if len(cols) > 5 else None) - if not time_c: - return [] - - def _pick(name_zh: str, idx: int) -> str: - return name_zh if name_zh in cols else (cols[idx] if len(cols) > idx else "") - - c_open = _pick("开盘", 1) - c_close = _pick("收盘", 2) - c_high = _pick("最高", 3) - c_low = _pick("最低", 4) - c_vol = _pick("成交量", 5) - if not all((c_open, c_close, c_high, c_low, c_vol)): - return [] - - out: List[Dict[str, Any]] = [] - for _, row in df.iterrows(): - try: - t = pd.Timestamp(row[time_c]) - ts = int(t.timestamp()) - o, c, h, low, v = float(row[c_open]), float(row[c_close]), float(row[c_high]), float(row[c_low]), float(row[c_vol]) - out.append({ - "time": ts, - "open": round(o, 4), - "high": round(h, 4), - "low": round(low, 4), - "close": round(c, 4), - "volume": round(v, 2), - }) - except Exception: - continue - out.sort(key=lambda x: x["time"]) - return out - - -def _merge_every_n_sorted_bars(bars: List[Dict[str, Any]], n: int) -> List[Dict[str, Any]]: - if n <= 1 or len(bars) < n: - return bars - out: List[Dict[str, Any]] = [] - i = 0 - while i + n <= len(bars): - chunk = bars[i : i + n] - out.append({ - "time": chunk[0]["time"], - "open": chunk[0]["open"], - "high": max(b["high"] for b in chunk), - "low": min(b["low"] for b in chunk), - "close": chunk[-1]["close"], - "volume": round(sum(b["volume"] for b in chunk), 2), - }) - i += n - return out - - -def fetch_akshare_minute_klines( - *, - is_hk: bool, - tencent_code: str, - timeframe: str, - limit: int, - before_time: Optional[int], -) -> List[Dict[str, Any]]: - p = _minute_period_str(timeframe) - if p is None: - return [] - try: - import akshare as ak # type: ignore - except ImportError: - logger.debug("akshare not installed; skipping AkShare minute K-lines") - return [] - - sym = ak_hk_code_from_tencent(tencent_code) if is_hk else ak_a_code_from_tencent(tencent_code) - sd, ed = _min_bar_window(timeframe, limit, before_time) - adj = "" if p == "1" else "qfq" - - df: Any = None - for attempt in range(_MAX_ATTEMPTS): - try: - if is_hk: - df = ak.stock_hk_hist_min_em(symbol=sym, period=p, adjust=adj, start_date=sd, end_date=ed) - else: - df = ak.stock_zh_a_hist_min_em(symbol=sym, start_date=sd, end_date=ed, period=p, adjust=adj) - break - except Exception as e: - if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e): - delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt)) - logger.debug( - "AkShare minute transient error %s tf=%s sym=%s (attempt %s/%s): %s", - tencent_code, timeframe, sym, attempt + 1, _MAX_ATTEMPTS, e, - ) - time.sleep(delay) - continue - logger.warning("AkShare minute K-line failed %s tf=%s sym=%s: %s", tencent_code, timeframe, sym, e) - return [] - - bars = _bars_from_ak_min_df(df) - if timeframe == "4H" and bars: - bars = _merge_every_n_sorted_bars(bars, 4) - return bars - - -def fetch_akshare_weekly_klines( - *, - is_hk: bool, - tencent_code: str, - limit: int, - before_time: Optional[int], -) -> List[Dict[str, Any]]: - try: - import akshare as ak # type: ignore - except ImportError: - return [] - - sym = ak_hk_code_from_tencent(tencent_code) if is_hk else ak_a_code_from_tencent(tencent_code) - end = datetime.fromtimestamp(int(before_time)) if before_time else datetime.now() - start = end - timedelta(days=max(int(limit or 300), 1) * 14 + 400) - start_s = start.strftime("%Y%m%d") - end_s = end.strftime("%Y%m%d") - - df: Any = None - for attempt in range(_MAX_ATTEMPTS): - try: - if is_hk: - df = ak.stock_hk_hist(symbol=sym, period="weekly", start_date=start_s, end_date=end_s, adjust="qfq") - else: - df = ak.stock_zh_a_hist(symbol=sym, period="weekly", start_date=start_s, end_date=end_s, adjust="qfq") - break - except Exception as e: - if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e): - delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt)) - logger.debug( - "AkShare weekly transient error sym=%s (attempt %s/%s): %s", - sym, attempt + 1, _MAX_ATTEMPTS, e, - ) - time.sleep(delay) - continue - logger.warning("AkShare weekly K-line failed sym=%s: %s", sym, e) - return [] - - if df is None or getattr(df, "empty", True) or "日期" not in df.columns: - return [] - out: List[Dict[str, Any]] = [] - for _, row in df.iterrows(): - try: - t = pd.Timestamp(row["日期"]) - ts = int(t.timestamp()) - o, c, h, low = float(row["开盘"]), float(row["收盘"]), float(row["最高"]), float(row["最低"]) - v = float(row["成交量"]) - out.append({ - "time": ts, - "open": round(o, 4), - "high": round(h, 4), - "low": round(low, 4), - "close": round(c, 4), - "volume": round(v, 2), - }) - except Exception: - continue - out.sort(key=lambda x: x["time"]) - return out diff --git a/backend_api_python/app/data_sources/base.py b/backend_api_python/app/data_sources/base.py index 053cc74..32b6ab9 100644 --- a/backend_api_python/app/data_sources/base.py +++ b/backend_api_python/app/data_sources/base.py @@ -2,9 +2,10 @@ Data source base class Define a unified data source interface """ + from abc import ABC, abstractmethod -from typing import Dict, List, Any, Optional -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional from app.utils.logger import get_logger @@ -12,40 +13,27 @@ logger = get_logger(__name__) # K-line cycle mapping (seconds) -TIMEFRAME_SECONDS = { - '1m': 60, - '5m': 300, - '15m': 900, - '30m': 1800, - '1H': 3600, - '4H': 14400, - '1D': 86400, - '1W': 604800 -} +TIMEFRAME_SECONDS = {"1m": 60, "5m": 300, "15m": 900, "30m": 1800, "1H": 3600, "4H": 14400, "1D": 86400, "1W": 604800} class BaseDataSource(ABC): """Data source base class.""" name: str = "base" - + @abstractmethod def get_kline( - self, - symbol: str, - timeframe: str, - limit: int, - before_time: Optional[int] = None + self, symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None ) -> List[Dict[str, Any]]: """ Get K-line data - + Args: symbol: trading pair/stock code timeframe: time period (1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W) limit: number of data items before_time: Get data before this time (Unix timestamp, seconds) - + Returns: K-line data list, format: [{"time": int, "open": float, "high": float, "low": float, "close": float, "volume": float}, ...] @@ -60,82 +48,63 @@ class BaseDataSource(ABC): Implementations may return a dict compatible with CCXT `fetch_ticker` shape (e.g. {'last': ...}). """ raise NotImplementedError("get_ticker is not implemented for this data source") - + def format_kline( - self, - timestamp: int, - open_price: float, - high: float, - low: float, - close: float, - volume: float + self, timestamp: int, open_price: float, high: float, low: float, close: float, volume: float ) -> Dict[str, Any]: """Format a single K-line record.""" return { - 'time': timestamp, - 'open': round(float(open_price), 4), - 'high': round(float(high), 4), - 'low': round(float(low), 4), - 'close': round(float(close), 4), - 'volume': round(float(volume), 2) + "time": timestamp, + "open": round(float(open_price), 4), + "high": round(float(high), 4), + "low": round(float(low), 4), + "close": round(float(close), 4), + "volume": round(float(volume), 2), } - - def calculate_time_range( - self, - timeframe: str, - limit: int, - buffer_ratio: float = 1.2 - ) -> int: + + def calculate_time_range(self, timeframe: str, limit: int, buffer_ratio: float = 1.2) -> int: """ Calculate the time range (seconds) required to obtain the specified number of K-lines - + Args: timeframe: time period limit: number of K-lines buffer_ratio: buffer coefficient - + Returns: Time range (seconds) """ seconds_per_candle = TIMEFRAME_SECONDS.get(timeframe, 86400) return int(seconds_per_candle * limit * buffer_ratio) - + def filter_and_limit( - self, - klines: List[Dict[str, Any]], - limit: int, - before_time: Optional[int] = None + self, klines: List[Dict[str, Any]], limit: int, before_time: Optional[int] = None ) -> List[Dict[str, Any]]: """ Filter and limit K-line data - + Args: klines: K-line data list limit: maximum quantity before_time: Filter data after this time - + Returns: Processed K-line data """ # Sort by time - klines.sort(key=lambda x: x['time']) - + klines.sort(key=lambda x: x["time"]) + # filter time if before_time: - klines = [k for k in klines if k['time'] < before_time] - + klines = [k for k in klines if k["time"] < before_time] + # Limit quantity (take the latest) if len(klines) > limit: klines = klines[-limit:] - + return klines - - def log_result( - self, - symbol: str, - klines: List[Dict[str, Any]], - timeframe: str - ): + + def log_result(self, symbol: str, klines: List[Dict[str, Any]], timeframe: str): """Record the result log. Delayed judgment: diff --git a/backend_api_python/app/data_sources/cache_manager.py b/backend_api_python/app/data_sources/cache_manager.py index a04d650..17bf420 100644 --- a/backend_api_python/app/data_sources/cache_manager.py +++ b/backend_api_python/app/data_sources/cache_manager.py @@ -13,13 +13,12 @@ characteristic: 3. Partition management by data type """ -import time import logging -from typing import Dict, Any, Optional, List -from collections import OrderedDict -from dataclasses import dataclass, field -from datetime import datetime import threading +import time +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any, Dict, Optional logger = logging.getLogger(__name__) @@ -27,15 +26,16 @@ logger = logging.getLogger(__name__) @dataclass class CacheEntry: """cache entry""" + data: Any timestamp: float ttl: float hit_count: int = 0 - + def is_expired(self) -> bool: """Check if expired""" return time.time() - self.timestamp > self.ttl - + def age(self) -> float: """Return cache age (seconds)""" return time.time() - self.timestamp @@ -44,34 +44,34 @@ class CacheEntry: class DataCache: """ Data Cache Manager - + characteristic: - TTL expiration mechanism - Maximum capacity limit - LRU elimination strategy - Thread safety """ - + def __init__( self, name: str = "default", default_ttl: float = 600.0, # Default 10 minutes - max_size: int = 1000 # Maximum number of cache entries + max_size: int = 1000, # Maximum number of cache entries ): self.name = name self.default_ttl = default_ttl self.max_size = max_size self._cache: OrderedDict[str, CacheEntry] = OrderedDict() self._lock = threading.RLock() - + # Statistics self._hits = 0 self._misses = 0 - + def get(self, key: str) -> Optional[Any]: """ Get cached data - + Returns: Cached data, returns None if it does not exist or has expired. """ @@ -79,33 +79,28 @@ class DataCache: if key not in self._cache: self._misses += 1 return None - + entry = self._cache[key] - + # Check if expired if entry.is_expired(): del self._cache[key] self._misses += 1 logger.debug(f"[cache] {self.name}:{key} expired and was removed") return None - + # Update access order (LRU) self._cache.move_to_end(key) entry.hit_count += 1 self._hits += 1 - + logger.debug(f"[cache hit] {self.name}:{key} (age: {entry.age():.0f}s/{entry.ttl:.0f}s)") return entry.data - - def set( - self, - key: str, - data: Any, - ttl: Optional[float] = None - ) -> None: + + def set(self, key: str, data: Any, ttl: Optional[float] = None) -> None: """ Set cache data - + Args: key: cache key data: cache data @@ -116,16 +111,12 @@ class DataCache: while len(self._cache) >= self.max_size: oldest_key, _ = self._cache.popitem(last=False) logger.debug(f"[cache] {self.name} reached capacity, evicted: {oldest_key}") - + actual_ttl = ttl if ttl is not None else self.default_ttl - self._cache[key] = CacheEntry( - data=data, - timestamp=time.time(), - ttl=actual_ttl - ) - + self._cache[key] = CacheEntry(data=data, timestamp=time.time(), ttl=actual_ttl) + logger.debug(f"[cache update] {self.name}:{key} TTL={actual_ttl}s") - + def delete(self, key: str) -> bool: """Delete cache entry""" with self._lock: @@ -134,7 +125,7 @@ class DataCache: logger.debug(f"[cache] {self.name}:{key} deleted") return True return False - + def clear(self) -> int: """Clear cache""" with self._lock: @@ -142,35 +133,32 @@ class DataCache: self._cache.clear() logger.info(f"[cache] {self.name} cleared {count} records") return count - + def cleanup_expired(self) -> int: """Clean up expired entries""" with self._lock: - expired_keys = [ - key for key, entry in self._cache.items() - if entry.is_expired() - ] + expired_keys = [key for key, entry in self._cache.items() if entry.is_expired()] for key in expired_keys: del self._cache[key] - + if expired_keys: logger.debug(f"[cache] {self.name} cleaned {len(expired_keys)} expired records") return len(expired_keys) - + def stats(self) -> Dict[str, Any]: """Get cache statistics""" with self._lock: total_requests = self._hits + self._misses hit_rate = self._hits / total_requests if total_requests > 0 else 0 - + return { - 'name': self.name, - 'size': len(self._cache), - 'max_size': self.max_size, - 'hits': self._hits, - 'misses': self._misses, - 'hit_rate': f"{hit_rate:.1%}", - 'default_ttl': self.default_ttl + "name": self.name, + "size": len(self._cache), + "max_size": self.max_size, + "hits": self._hits, + "misses": self._misses, + "hit_rate": f"{hit_rate:.1%}", + "default_ttl": self.default_ttl, } @@ -182,21 +170,21 @@ class DataCache: _realtime_cache = DataCache( name="realtime", default_ttl=1200.0, # 20 minutes - max_size=6000 + max_size=6000, ) # K-line data caching (5 minutes TTL, caching on demand) _kline_cache = DataCache( name="kline", - default_ttl=300.0, # 5 minutes - max_size=500 # Up to 500 trading pairs + default_ttl=300.0, # 5 minutes + max_size=500, # Up to 500 trading pairs ) # Stock basic information cache (1 day TTL) _stock_info_cache = DataCache( name="stock_info", default_ttl=86400.0, # 24 hours - max_size=6000 + max_size=6000, ) @@ -215,15 +203,10 @@ def get_stock_info_cache() -> DataCache: return _stock_info_cache -def generate_kline_cache_key( - symbol: str, - timeframe: str, - limit: int, - before_time: Optional[int] = None -) -> str: +def generate_kline_cache_key(symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None) -> str: """ Generate K-line cache key - + Format: symbol:timeframe:limit[:before_time] """ key = f"{symbol}:{timeframe}:{limit}" diff --git a/backend_api_python/app/data_sources/circuit_breaker.py b/backend_api_python/app/data_sources/circuit_breaker.py index c5db6ae..6538851 100644 --- a/backend_api_python/app/data_sources/circuit_breaker.py +++ b/backend_api_python/app/data_sources/circuit_breaker.py @@ -13,139 +13,140 @@ HALF_OPEN --Success--> CLOSED HALF_OPEN --Failure--> OPEN """ -import time import logging -from typing import Dict, Any, Optional +import time from enum import Enum +from typing import Any, Dict, Optional logger = logging.getLogger(__name__) class CircuitState(Enum): """fuse status""" - CLOSED = "closed" # normal state - OPEN = "open" # Fuse status (not available) + + CLOSED = "closed" # normal state + OPEN = "open" # Fuse status (not available) HALF_OPEN = "half_open" # Half-open state (exploratory request) class CircuitBreaker: """ Circuit Breakers - Manage the blown/cooling status of data sources - + Strategy: - Enter the fuse state after N consecutive failures - Skip this data source during the circuit breaker period - Automatically returns to half-open state after cooling time - In the half-open state, if a single success is successful, it will be fully restored, if it fails, the fuse will continue to be broken. """ - + def __init__( self, - failure_threshold: int = 3, # Continuous failure threshold + failure_threshold: int = 3, # Continuous failure threshold cooldown_seconds: float = 300.0, # Cooling time (seconds), default 5 minutes - half_open_max_calls: int = 1 # Maximum number of attempts in half-open state + half_open_max_calls: int = 1, # Maximum number of attempts in half-open state ): self.failure_threshold = failure_threshold self.cooldown_seconds = cooldown_seconds self.half_open_max_calls = half_open_max_calls - + # Status of each data source {source_name: {state, failures, last_failure_time, half_open_calls}} self._states: Dict[str, Dict[str, Any]] = {} - + def _get_state(self, source: str) -> Dict[str, Any]: """Get or initialize data source status""" if source not in self._states: self._states[source] = { - 'state': CircuitState.CLOSED, - 'failures': 0, - 'last_failure_time': 0.0, - 'half_open_calls': 0, - 'last_error': None + "state": CircuitState.CLOSED, + "failures": 0, + "last_failure_time": 0.0, + "half_open_calls": 0, + "last_error": None, } return self._states[source] - + def is_available(self, source: str) -> bool: """ Check if the data source is available - + Return True to indicate that the request can be attempted Return False to indicate that the data source should be skipped """ state = self._get_state(source) current_time = time.time() - - if state['state'] == CircuitState.CLOSED: + + if state["state"] == CircuitState.CLOSED: return True - - if state['state'] == CircuitState.OPEN: + + if state["state"] == CircuitState.OPEN: # Check cool down time - time_since_failure = current_time - state['last_failure_time'] + time_since_failure = current_time - state["last_failure_time"] if time_since_failure >= self.cooldown_seconds: # Cooling is completed and enters the half-open state - state['state'] = CircuitState.HALF_OPEN - state['half_open_calls'] = 0 + state["state"] = CircuitState.HALF_OPEN + state["half_open_calls"] = 0 logger.info(f"[circuit breaker] {source} cooldown finished, entering half-open state") return True else: remaining = self.cooldown_seconds - time_since_failure logger.debug(f"[circuit breaker] {source} is open, remaining cooldown: {remaining:.0f}s") return False - - if state['state'] == CircuitState.HALF_OPEN: + + if state["state"] == CircuitState.HALF_OPEN: # Limit the number of requests in the half-open state - if state['half_open_calls'] < self.half_open_max_calls: + if state["half_open_calls"] < self.half_open_max_calls: return True return False - + return True - + def record_success(self, source: str) -> None: """Log successful request""" state = self._get_state(source) - - if state['state'] == CircuitState.HALF_OPEN: + + if state["state"] == CircuitState.HALF_OPEN: # Successful in half-open state, full recovery logger.info(f"[circuit breaker] {source} request succeeded in half-open state, fully recovered") - + # reset state - state['state'] = CircuitState.CLOSED - state['failures'] = 0 - state['half_open_calls'] = 0 - state['last_error'] = None - + state["state"] = CircuitState.CLOSED + state["failures"] = 0 + state["half_open_calls"] = 0 + state["last_error"] = None + def record_failure(self, source: str, error: Optional[str] = None) -> None: """Logging failed requests""" state = self._get_state(source) current_time = time.time() - - state['failures'] += 1 - state['last_failure_time'] = current_time - state['last_error'] = error - - if state['state'] == CircuitState.HALF_OPEN: + + state["failures"] += 1 + state["last_failure_time"] = current_time + state["last_error"] = error + + if state["state"] == CircuitState.HALF_OPEN: # Fails in half-open state and continues to fuse - state['state'] = CircuitState.OPEN - state['half_open_calls'] = 0 - logger.warning(f"[circuit breaker] {source} request failed in half-open state, staying open for {self.cooldown_seconds}s") - elif state['failures'] >= self.failure_threshold: + state["state"] = CircuitState.OPEN + state["half_open_calls"] = 0 + logger.warning( + f"[circuit breaker] {source} request failed in half-open state, staying open for {self.cooldown_seconds}s" + ) + elif state["failures"] >= self.failure_threshold: # reaches the threshold and enters the circuit breaker - state['state'] = CircuitState.OPEN - logger.warning(f"[circuit breaker] {source} failed {state['failures']} times consecutively and is now open " - f"(cooldown {self.cooldown_seconds}s)") + state["state"] = CircuitState.OPEN + logger.warning( + f"[circuit breaker] {source} failed {state['failures']} times consecutively and is now open " + f"(cooldown {self.cooldown_seconds}s)" + ) if error: logger.warning(f"[circuit breaker] last error: {error}") - + def get_status(self) -> Dict[str, Dict[str, Any]]: """Get all data source status""" return { - source: { - 'state': info['state'].value, - 'failures': info['failures'], - 'last_error': info['last_error'] - } + source: {"state": info["state"].value, "failures": info["failures"], "last_error": info["last_error"]} for source, info in self._states.items() } - + def reset(self, source: Optional[str] = None) -> None: """Reset fuse status""" if source: @@ -163,9 +164,9 @@ class CircuitBreaker: # Real-time market circuit breaker (more stringent strategy) _realtime_circuit_breaker = CircuitBreaker( - failure_threshold=2, # Failed 2 times in a row - cooldown_seconds=180.0, # Cool for 3 minutes - half_open_max_calls=1 + failure_threshold=2, # Failed 2 times in a row + cooldown_seconds=180.0, # Cool for 3 minutes + half_open_max_calls=1, ) diff --git a/backend_api_python/app/data_sources/cn_hk_fundamentals.py b/backend_api_python/app/data_sources/cn_hk_fundamentals.py deleted file mode 100644 index d3c4173..0000000 --- a/backend_api_python/app/data_sources/cn_hk_fundamentals.py +++ /dev/null @@ -1,292 +0,0 @@ -""" -A-share / HK share fundamentals — multi-tier fallback. - -Priority (when TWELVE_DATA_API_KEY configured): - Twelve Data /statistics + /profile → AkShare (Eastmoney, fragile overseas) - -Without API key: - AkShare only (may fail from overseas servers) - -Keys are aligned with MarketDataCollector expectations (pe_ratio, pb_ratio, etc.). -""" - -from __future__ import annotations - -import math -import time -from typing import Any, Dict, Optional - -import requests - -from app.data_sources.asia_stock_kline import ( - _get_twelve_data_api_key, - _td_symbol_and_exchange, - ak_a_code_from_tencent, - ak_hk_code_from_tencent, -) -from app.utils.logger import get_logger - -logger = get_logger(__name__) - -_TD_TIMEOUT = 15 -_TD_MAX_ATTEMPTS = 2 -_TD_BACKOFF_SEC = 2.0 - - -def _float_clean(x: Any) -> Optional[float]: - if x is None or x == "": - return None - try: - v = float(x) - if math.isnan(v) or math.isinf(v): - return None - return v - except (TypeError, ValueError): - return None - - -# --------------------------------------------------------------------------- -# Twelve Data fundamentals (globally stable, paid) -# --------------------------------------------------------------------------- - -def _td_request(endpoint: str, symbol: str, exchange: str) -> Optional[Dict[str, Any]]: - """Generic Twelve Data GET with retry.""" - api_key = _get_twelve_data_api_key() - if not api_key: - return None - url = f"https://api.twelvedata.com{endpoint}" - params = {"symbol": symbol, "exchange": exchange, "apikey": api_key} - for attempt in range(_TD_MAX_ATTEMPTS): - try: - resp = requests.get(url, params=params, timeout=_TD_TIMEOUT) - data = resp.json() - if data.get("status") == "error": - code = data.get("code", "") - msg = (data.get("message") or "")[:120] - if code == 429 or "API credits" in msg or "minute limit" in msg: - logger.warning("TwelveData rate limit on %s %s/%s: %s", endpoint, symbol, exchange, msg) - else: - logger.debug("TwelveData %s error %s/%s: %s", endpoint, symbol, exchange, msg) - return None - return data - except Exception as e: - if attempt + 1 < _TD_MAX_ATTEMPTS: - time.sleep(_TD_BACKOFF_SEC) - continue - logger.warning("TwelveData %s request failed %s/%s: %s", endpoint, symbol, exchange, e) - return None - - -def fetch_twelvedata_fundamental(tencent_code: str, is_hk: bool) -> Dict[str, Any]: - """Fetch PE/PB/PS/PEG/ROE/margin/market_cap/52w from Twelve Data /statistics.""" - symbol, exchange = _td_symbol_and_exchange(tencent_code, is_hk) - data = _td_request("/statistics", symbol, exchange) - if not data or "statistics" not in data: - return {} - - stats = data["statistics"] - result: Dict[str, Any] = {"source": "twelvedata"} - - vm = stats.get("valuations_metrics") or {} - result["market_cap"] = _float_clean(vm.get("market_capitalization")) - result["pe_ratio"] = _float_clean(vm.get("trailing_pe")) - result["forward_pe"] = _float_clean(vm.get("forward_pe")) - result["pb_ratio"] = _float_clean(vm.get("price_to_book_mrq")) - result["ps_ratio"] = _float_clean(vm.get("price_to_sales_ttm")) - result["peg"] = _float_clean(vm.get("peg_ratio")) - result["enterprise_value"] = _float_clean(vm.get("enterprise_value")) - - fin = stats.get("financials") or {} - result["profit_margin"] = _float_clean(fin.get("profit_margin")) - result["gross_margin"] = _float_clean(fin.get("gross_margin")) - result["operating_margin"] = _float_clean(fin.get("operating_margin")) - result["roe"] = _float_clean(fin.get("return_on_equity_ttm")) - result["roa"] = _float_clean(fin.get("return_on_assets_ttm")) - - ss = stats.get("stock_statistics") or {} - result["total_shares"] = _float_clean(ss.get("shares_outstanding")) - result["float_shares"] = _float_clean(ss.get("float_shares")) - - sp = stats.get("stock_price_summary") or {} - result["52w_high"] = _float_clean(sp.get("fifty_two_week_high")) - result["52w_low"] = _float_clean(sp.get("fifty_two_week_low")) - result["beta"] = _float_clean(sp.get("beta")) - - div = stats.get("dividends_and_splits") or {} - result["dividend_yield"] = _float_clean(div.get("trailing_annual_dividend_yield")) - result["dividend_rate"] = _float_clean(div.get("trailing_annual_dividend_rate")) - - non_null = sum(1 for v in result.values() if v is not None and v != "twelvedata") - logger.debug("TwelveData /statistics %s/%s: %d non-null fields", symbol, exchange, non_null) - return result - - -def fetch_twelvedata_profile(tencent_code: str, is_hk: bool) -> Dict[str, Any]: - """Fetch company info from Twelve Data /profile.""" - symbol, exchange = _td_symbol_and_exchange(tencent_code, is_hk) - data = _td_request("/profile", symbol, exchange) - if not data or not data.get("name"): - return {} - - out: Dict[str, Any] = {"source": "twelvedata"} - for src, dst in ( - ("name", "name"), - ("industry", "industry"), - ("sector", "sector"), - ("website", "website"), - ("description", "description"), - ("employees", "employees"), - ("name", "full_name"), - ): - v = data.get(src) - if v is not None and str(v).strip(): - out[dst] = str(v).strip() if isinstance(v, str) else v - - country = data.get("country") - if country: - out["country"] = country - - logger.debug("TwelveData /profile %s/%s: name=%s industry=%s", symbol, exchange, out.get("name"), out.get("industry")) - return out - - -# --------------------------------------------------------------------------- -# AkShare fundamentals (Eastmoney — fragile overseas, used as fallback) -# --------------------------------------------------------------------------- - -def _eastmoney_a_em_symbol(tencent_code: str) -> str: - c = ak_a_code_from_tencent(tencent_code) - c = (c or "").zfill(6) - if c.startswith("6"): - return "SH" + c - return "SZ" + c - - -def _individual_info_map(symbol_6: str) -> Dict[str, Any]: - out: Dict[str, Any] = {} - try: - import akshare as ak # type: ignore - df = ak.stock_individual_info_em(symbol=symbol_6) - except Exception as e: - logger.debug("stock_individual_info_em failed %s: %s", symbol_6, e) - return out - if df is None or getattr(df, "empty", True) or len(df.columns) < 2: - return out - kcol, vcol = df.columns[0], df.columns[1] - for _, row in df.iterrows(): - try: - k = str(row[kcol]).strip() - if k: - out[k] = row[vcol] - except Exception: - continue - return out - - -def fetch_cn_fundamental_akshare(tencent_code: str) -> Dict[str, Any]: - """PE/PB/PS, market cap, ROE proxy, EPS for A-share (best-effort).""" - sym6 = ak_a_code_from_tencent(tencent_code) - if not sym6: - return {} - result: Dict[str, Any] = {"source": "akshare_em"} - info = _individual_info_map(sym6) - if info: - result["market_cap"] = _float_clean(info.get("总市值")) - result["float_market_cap"] = _float_clean(info.get("流通市值")) - ind = info.get("行业") - if ind is not None and str(ind).strip(): - result["industry"] = str(ind).strip() - result["total_shares"] = _float_clean(info.get("总股本")) - result["float_shares"] = _float_clean(info.get("流通股")) - - em_sym = _eastmoney_a_em_symbol(tencent_code) - try: - import akshare as ak # type: ignore - vdf = ak.stock_zh_valuation_comparison_em(symbol=em_sym) - except Exception as e: - logger.debug("stock_zh_valuation_comparison_em failed %s: %s", em_sym, e) - vdf = None - - if vdf is not None and not vdf.empty and "代码" in vdf.columns: - hit = vdf[vdf["代码"].astype(str).str.replace(".0", "", regex=False).str.zfill(6) == sym6.zfill(6)] - if not hit.empty: - r = hit.iloc[0] - pe = _float_clean(r.get("市盈率-TTM")) - if pe is not None: - result["pe_ratio"] = pe - pb = _float_clean(r.get("市净率-MRQ")) - if pb is not None: - result["pb_ratio"] = pb - ps = _float_clean(r.get("市销率-TTM")) - if ps is not None: - result["ps_ratio"] = ps - peg = _float_clean(r.get("PEG")) - if peg is not None: - result["peg"] = peg - - return result - - -def fetch_hk_fundamental_akshare(tencent_code: str) -> Dict[str, Any]: - hk5 = ak_hk_code_from_tencent(tencent_code) - if not hk5: - return {} - result: Dict[str, Any] = {"source": "akshare_em"} - try: - import akshare as ak # type: ignore - df = ak.stock_hk_financial_indicator_em(symbol=hk5) - except Exception as e: - logger.debug("stock_hk_financial_indicator_em failed %s: %s", hk5, e) - return result - if df is None or df.empty: - return result - r = df.iloc[0] - result["pe_ratio"] = _float_clean(r.get("市盈率")) - result["pb_ratio"] = _float_clean(r.get("市净率")) - result["eps"] = _float_clean(r.get("基本每股收益(元)")) - result["roe"] = _float_clean(r.get("股东权益回报率(%)")) - result["profit_margin"] = _float_clean(r.get("销售净利率(%)")) - mcap = _float_clean(r.get("总市值(港元)")) or _float_clean(r.get("港股市值(港元)")) - if mcap is not None: - result["market_cap"] = mcap - result["dividend_yield"] = _float_clean(r.get("股息率TTM(%)")) - return result - - -def fetch_cn_company_extras(tencent_code: str) -> Dict[str, Any]: - sym6 = ak_a_code_from_tencent(tencent_code) - if not sym6: - return {} - info = _individual_info_map(sym6) - out: Dict[str, Any] = {} - if info.get("行业"): - out["industry"] = str(info["行业"]).strip() - if info.get("上市时间"): - out["ipo_date"] = str(info["上市时间"]).strip() - return out - - -def fetch_hk_company_extras(tencent_code: str) -> Dict[str, Any]: - hk5 = ak_hk_code_from_tencent(tencent_code) - if not hk5: - return {} - out: Dict[str, Any] = {} - try: - import akshare as ak # type: ignore - df = ak.stock_hk_company_profile_em(symbol=hk5) - except Exception as e: - logger.debug("stock_hk_company_profile_em failed %s: %s", hk5, e) - return out - if df is None or df.empty: - return out - r = df.iloc[0] - for key, col in ( - ("industry", "所属行业"), - ("ipo_date", "公司成立日期"), - ("website", "公司网址"), - ("full_name", "公司名称"), - ): - v = r.get(col) - if v is not None and str(v).strip(): - out[key] = str(v).strip() - return out diff --git a/backend_api_python/app/data_sources/cn_stock.py b/backend_api_python/app/data_sources/cn_stock.py deleted file mode 100644 index af87ce0..0000000 --- a/backend_api_python/app/data_sources/cn_stock.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -中国A股数据源 — 多层 fallback - -有 TWELVE_DATA_API_KEY: - 所有周期 → Twelve Data(主) → 腾讯日/周线 → yfinance → AkShare - -无 API Key: - 分钟/小时 → yfinance → AkShare - 日/周线 → 腾讯 fqkline → yfinance → AkShare -""" - -from __future__ import annotations - -from typing import Dict, List, Any, Optional - -from app.data_sources.base import BaseDataSource -from app.data_sources.tencent import normalize_cn_code, fetch_quote, parse_quote_to_ticker, fetch_kline, tencent_kline_rows_to_dicts -from app.data_sources.asia_stock_kline import ( - normalize_chart_timeframe, - fetch_twelvedata_klines, - fetch_yfinance_klines, - fetch_akshare_minute_klines, - fetch_akshare_weekly_klines, -) -from app.utils.logger import get_logger - -logger = get_logger(__name__) - - -class CNStockDataSource(BaseDataSource): - """A股数据源(TwelveData + Tencent + yfinance + AkShare)""" - - name = "CNStock/multi-source" - - def get_ticker(self, symbol: str) -> Dict[str, Any]: - code = normalize_cn_code(symbol) - parts = fetch_quote(code) - if not parts: - return {"last": 0, "symbol": code} - t = parse_quote_to_ticker(parts) - return { - "last": t.get("last", 0), - "change": t.get("change", 0), - "changePercent": t.get("changePercent", 0), - "high": t.get("high", 0), - "low": t.get("low", 0), - "open": t.get("open", 0), - "previousClose": t.get("previousClose", 0), - "name": t.get("name", ""), - "symbol": code, - } - - def get_kline( - self, - symbol: str, - timeframe: str, - limit: int, - before_time: Optional[int] = None, - ) -> List[Dict[str, Any]]: - code = normalize_cn_code(symbol) - tf = normalize_chart_timeframe(timeframe) - lim = max(int(limit or 300), 1) - - # Tier 1: Twelve Data (paid, most reliable) - rows = fetch_twelvedata_klines( - is_hk=False, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time - ) - if rows: - return self.filter_and_limit(rows, limit=lim, before_time=before_time) - - # Tier 2: Tencent for daily/weekly (fast, free) - if tf in ("1D", "1W"): - tf_map = {"1D": "day", "1W": "week"} - period = tf_map.get(tf, "day") - raw_rows = fetch_kline(code, period=period, count=lim, adj="qfq") - out = tencent_kline_rows_to_dicts(raw_rows) - if out: - return self.filter_and_limit(out, limit=lim, before_time=before_time) - - # Tier 3: yfinance (works when Yahoo not rate-limited) - rows = fetch_yfinance_klines( - is_hk=False, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time - ) - if rows: - return self.filter_and_limit(rows, limit=lim, before_time=before_time) - - # Tier 4: AkShare (fragile overseas, last resort) - if tf in ("1m", "5m", "15m", "30m", "1H", "4H"): - rows = fetch_akshare_minute_klines( - is_hk=False, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time - ) - elif tf == "1W": - rows = fetch_akshare_weekly_klines( - is_hk=False, tencent_code=code, limit=lim, before_time=before_time - ) - else: - rows = [] - - return self.filter_and_limit(rows, limit=lim, before_time=before_time) diff --git a/backend_api_python/app/data_sources/crypto.py b/backend_api_python/app/data_sources/crypto.py index dcd16fa..370f550 100644 --- a/backend_api_python/app/data_sources/crypto.py +++ b/backend_api_python/app/data_sources/crypto.py @@ -2,75 +2,71 @@ Cryptocurrency data source Get data using CCXT (Coinbase) """ -from typing import Dict, List, Any, Optional, Tuple + from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + import ccxt -from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS +from app.config import CCXTConfig +from app.data_sources.base import TIMEFRAME_SECONDS, BaseDataSource from app.utils.logger import get_logger -from app.config import CCXTConfig, APIKeys logger = get_logger(__name__) class CryptoDataSource(BaseDataSource): """Cryptocurrency data source""" - + name = "Crypto/CCXT" - + # time period mapping TIMEFRAME_MAP = CCXTConfig.TIMEFRAME_MAP - + # List of common quote currencies (sorted by priority) - COMMON_QUOTES = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC', 'BNB', 'EUR', 'GBP'] - + COMMON_QUOTES = ["USDT", "USD", "BTC", "ETH", "BUSD", "USDC", "BNB", "EUR", "GBP"] + def __init__(self): - config = { - 'timeout': CCXTConfig.TIMEOUT, - 'enableRateLimit': CCXTConfig.ENABLE_RATE_LIMIT - } - + config = {"timeout": CCXTConfig.TIMEOUT, "enableRateLimit": CCXTConfig.ENABLE_RATE_LIMIT} + # If a proxy is configured if CCXTConfig.PROXY: - config['proxies'] = { - 'http': CCXTConfig.PROXY, - 'https': CCXTConfig.PROXY - } - + config["proxies"] = {"http": CCXTConfig.PROXY, "https": CCXTConfig.PROXY} + exchange_id = CCXTConfig.DEFAULT_EXCHANGE - + # Dynamically loading exchange classes if not hasattr(ccxt, exchange_id): logger.warning(f"CCXT exchange '{exchange_id}' not found, falling back to 'coinbase'") - exchange_id = 'coinbase' - + exchange_id = "coinbase" + exchange_class = getattr(ccxt, exchange_id) self.exchange = exchange_class(config) - + # Lazy loading of markets (loaded on first use) self._markets_loaded = False self._markets_cache = None - + def _ensure_markets_loaded(self) -> bool: """Make sure markets are loaded (for symbol verification)""" if self._markets_loaded and self._markets_cache is not None: return True - + try: # Some exchanges require explicit loading of markets - if hasattr(self.exchange, 'load_markets'): + if hasattr(self.exchange, "load_markets"): self.exchange.load_markets(reload=False) - self._markets_cache = getattr(self.exchange, 'markets', {}) + self._markets_cache = getattr(self.exchange, "markets", {}) self._markets_loaded = True return True except Exception as e: logger.debug(f"Failed to load markets for {self.exchange.id}: {e}") return False - + def _normalize_symbol(self, symbol: str) -> Tuple[str, str]: """ Normalized symbol format, returns (normalized_symbol, base_currency) - + Handles various input formats: - BTC/USDT -> BTC/USDT - BTCUSDT -> BTC/USDT @@ -79,69 +75,69 @@ class CryptoDataSource(BaseDataSource): - PI, TRX -> PI/USDT, TRX/USDT """ if not symbol: - return '', '' - + return "", "" + sym = symbol.strip() - + # Remove swap/futures suffix - if ':' in sym: - sym = sym.split(':', 1)[0] - + if ":" in sym: + sym = sym.split(":", 1)[0] + sym = sym.upper() - + # If there is already a separator, parse it directly - if '/' in sym: - parts = sym.split('/', 1) + if "/" in sym: + parts = sym.split("/", 1) base = parts[0].strip() - quote = parts[1].strip() if len(parts) > 1 else '' + quote = parts[1].strip() if len(parts) > 1 else "" if base and quote: return f"{base}/{quote}", base - + # Try to identify from common quote currencies for quote in self.COMMON_QUOTES: if sym.endswith(quote) and len(sym) > len(quote): - base = sym[:-len(quote)] + base = sym[: -len(quote)] if base: return f"{base}/{quote}", base - + # If not recognized, USDT will be used by default. return f"{sym}/USDT", sym - - def _find_valid_symbol(self, base: str, preferred_quote: str = 'USDT') -> Optional[str]: + + def _find_valid_symbol(self, base: str, preferred_quote: str = "USDT") -> Optional[str]: """ Find valid symbols in the exchange's markets - + Args: base: base currency (e.g. 'PI', 'TRX') preferred_quote: preferred quote currency - + Returns: A valid symbol found, or None if not found """ if not self._ensure_markets_loaded(): return None - + markets = self._markets_cache or {} if not markets: return None - + # Try different quote currencies by priority quotes_to_try = [preferred_quote] + [q for q in self.COMMON_QUOTES if q != preferred_quote] - + for quote in quotes_to_try: candidate = f"{base}/{quote}" if candidate in markets: market = markets[candidate] # Check if the market is active - if market.get('active', True): + if market.get("active", True): return candidate - + return None - + def _normalize_symbol_for_exchange(self, symbol: str) -> str: """ Standardize symbols based on exchange characteristics - + Symbol format requirements for different exchanges: - Binance: BTC/USDT (standard format) - OKX: BTC/USDT (standard format, but some currencies may not be supported) @@ -150,28 +146,28 @@ class CryptoDataSource(BaseDataSource): - Bitfinex: tBTCUST (special format) """ normalized, base = self._normalize_symbol(symbol) - + if not normalized or not base: return symbol - - exchange_id = getattr(self.exchange, 'id', '').lower() - + + exchange_id = getattr(self.exchange, "id", "").lower() + # Special handling: symbol mapping for certain exchanges - if exchange_id == 'coinbase': + if exchange_id == "coinbase": # Coinbase usually uses USD instead of USDT - if normalized.endswith('/USDT'): - usd_version = normalized.replace('/USDT', '/USD') + if normalized.endswith("/USDT"): + usd_version = normalized.replace("/USDT", "/USD") if self._ensure_markets_loaded(): markets = self._markets_cache or {} if usd_version in markets: return usd_version - + # Try to find a valid symbol on the exchange if self._ensure_markets_loaded(): - valid_symbol = self._find_valid_symbol(base, normalized.split('/')[1] if '/' in normalized else 'USDT') + valid_symbol = self._find_valid_symbol(base, normalized.split("/")[1] if "/" in normalized else "USDT") if valid_symbol: return valid_symbol - + return normalized def get_ticker(self, symbol: str) -> Dict[str, Any]: @@ -184,15 +180,15 @@ class CryptoDataSource(BaseDataSource): - Automatically adapt to the symbol format requirements of different exchanges """ if not symbol or not symbol.strip(): - return {'last': 0, 'symbol': symbol} - + return {"last": 0, "symbol": symbol} + # normalized notation normalized = self._normalize_symbol_for_exchange(symbol) - + if not normalized: logger.warning(f"Failed to normalize symbol: {symbol}") - return {'last': 0, 'symbol': symbol} - + return {"last": 0, "symbol": symbol} + # Try to get ticker try: ticker = self.exchange.fetch_ticker(normalized) @@ -200,97 +196,95 @@ class CryptoDataSource(BaseDataSource): return ticker except Exception as e: error_msg = str(e).lower() - is_symbol_error = any(keyword in error_msg for keyword in [ - 'does not have market symbol', - 'symbol not found', - 'invalid symbol', - 'market does not exist', - 'trading pair not found' - ]) - + is_symbol_error = any( + keyword in error_msg + for keyword in [ + "does not have market symbol", + "symbol not found", + "invalid symbol", + "market does not exist", + "trading pair not found", + ] + ) + if is_symbol_error: # Try to find alternative symbols - base = normalized.split('/')[0] if '/' in normalized else normalized + base = normalized.split("/")[0] if "/" in normalized else normalized if self._ensure_markets_loaded(): valid_symbol = self._find_valid_symbol(base) if valid_symbol and valid_symbol != normalized: try: - logger.debug(f"Trying alternative symbol: {valid_symbol} (original: {symbol}, first attempt: {normalized})") + logger.debug( + f"Trying alternative symbol: {valid_symbol} (original: {symbol}, first attempt: {normalized})" + ) ticker = self.exchange.fetch_ticker(valid_symbol) if ticker and isinstance(ticker, dict): return ticker except Exception as e2: logger.debug(f"Alternative symbol {valid_symbol} also failed: {e2}") - + # If all attempts fail, log a warning and return a default value logger.warning( - f"Symbol '{symbol}' (normalized: {normalized}) not found on {self.exchange.id}. " - f"Error: {str(e)[:100]}" + f"Symbol '{symbol}' (normalized: {normalized}) not found on {self.exchange.id}. Error: {str(e)[:100]}" ) - - return {'last': 0, 'symbol': symbol} - + + return {"last": 0, "symbol": symbol} + def get_kline( - self, - symbol: str, - timeframe: str, - limit: int, - before_time: Optional[int] = None + self, symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None ) -> List[Dict[str, Any]]: """Get cryptocurrency K-line data""" klines = [] - + try: - ccxt_timeframe = self.TIMEFRAME_MAP.get(timeframe, '1d') - + ccxt_timeframe = self.TIMEFRAME_MAP.get(timeframe, "1d") + # Use a unified symbol normalization method symbol_pair = self._normalize_symbol_for_exchange(symbol) - + if not symbol_pair: logger.warning(f"Failed to normalize symbol for K-line: {symbol}") return [] - + # logger.info(f"Get cryptocurrency K-line: {symbol_pair}, period: {ccxt_timeframe}, number of bars: {limit}") - + ohlcv = self._fetch_ohlcv(symbol_pair, ccxt_timeframe, limit, before_time, timeframe) - + if not ohlcv: logger.warning(f"CCXT returned no K-lines: {symbol_pair}") return [] - + # Convert data format for candle in ohlcv: if len(candle) < 6: continue - klines.append(self.format_kline( - timestamp=int(candle[0] / 1000), # Milliseconds to seconds - open_price=candle[1], - high=candle[2], - low=candle[3], - close=candle[4], - volume=candle[5] - )) - + klines.append( + self.format_kline( + timestamp=int(candle[0] / 1000), # Milliseconds to seconds + open_price=candle[1], + high=candle[2], + low=candle[3], + close=candle[4], + volume=candle[5], + ) + ) + # Filter and restrict klines = self.filter_and_limit(klines, limit, before_time) - + # Record results self.log_result(symbol, klines, timeframe) - + except Exception as e: logger.error(f"Failed to fetch crypto K-lines {symbol}: {str(e)}") import traceback + logger.error(traceback.format_exc()) - + return klines - + def _fetch_ohlcv( - self, - symbol_pair: str, - ccxt_timeframe: str, - limit: int, - before_time: Optional[int], - timeframe: str + self, symbol_pair: str, ccxt_timeframe: str, limit: int, before_time: Optional[int], timeframe: str ) -> List: """Obtain OHLCV data (supports paging to obtain complete data)""" try: @@ -301,75 +295,66 @@ class CryptoDataSource(BaseDataSource): start_time = end_time - timedelta(seconds=total_seconds) since = int(start_time.timestamp() * 1000) end_ms = before_time * 1000 - + # logger.info(f"Historical data request: since={since//1000}, end={before_time}, time span={total_seconds/86400:.1f} days") - + # Fetch data in pages until the complete time range is covered all_ohlcv = [] batch_limit = 300 # Coinbase limit is often 300, safer than 1000 current_since = since - + while current_since < end_ms: batch = self.exchange.fetch_ohlcv( - symbol_pair, - ccxt_timeframe, - since=current_since, - limit=batch_limit + symbol_pair, ccxt_timeframe, since=current_since, limit=batch_limit ) - + if not batch: break - + all_ohlcv.extend(batch) - + # The time when the last piece of data is obtained is used as the starting time of the next request last_timestamp = batch[-1][0] - + # If the time of the last data exceeds the end time, or the returned data is less than the requested amount, it means that the acquisition has been completed. # if last_timestamp >= end_ms or len(batch) < batch_limit: if last_timestamp >= end_ms: break - + # Next time, start from the next time point of the last item timeframe_ms = TIMEFRAME_SECONDS.get(timeframe, 86400) * 1000 current_since = last_timestamp + timeframe_ms - + # logger.info(f"Getting in paging: {len(all_ohlcv)} items have been obtained, continue from {datetime.fromtimestamp(current_since/1000)}") - + ohlcv = all_ohlcv else: ohlcv = self.exchange.fetch_ohlcv(symbol_pair, ccxt_timeframe, limit=limit) - + # logger.info(f"CCXT returns {len(ohlcv) if ohlcv else 0} pieces of data") return ohlcv - + except Exception as e: logger.warning(f"CCXT fetch_ohlcv failed: {str(e)}; trying fallback") return self._fetch_ohlcv_fallback(symbol_pair, ccxt_timeframe, limit, before_time, timeframe) - + def _fetch_ohlcv_fallback( - self, - symbol_pair: str, - ccxt_timeframe: str, - limit: int, - before_time: Optional[int], - timeframe: str + self, symbol_pair: str, ccxt_timeframe: str, limit: int, before_time: Optional[int], timeframe: str ) -> List: """Alternate acquisition method""" try: total_seconds = self.calculate_time_range(timeframe, limit) - + if before_time: end_time = datetime.fromtimestamp(before_time) start_time = end_time - timedelta(seconds=total_seconds) since = int(start_time.timestamp() * 1000) else: since = int((datetime.now() - timedelta(seconds=total_seconds)).timestamp() * 1000) - + ohlcv = self.exchange.fetch_ohlcv(symbol_pair, ccxt_timeframe, since=since, limit=limit) # logger.info(f"CCXT alternative method returns {len(ohlcv) if ohlcv else 0} pieces of data") return ohlcv except Exception as e: logger.error(f"CCXT fallback method also failed: {str(e)}") return [] - diff --git a/backend_api_python/app/data_sources/factory.py b/backend_api_python/app/data_sources/factory.py index d4c5176..68719c8 100644 --- a/backend_api_python/app/data_sources/factory.py +++ b/backend_api_python/app/data_sources/factory.py @@ -2,7 +2,8 @@ data source factory Return the corresponding data source according to the market type """ -from typing import Dict, List, Any, Optional + +from typing import Any, Dict, List, Optional from app.data_sources.base import BaseDataSource from app.utils.logger import get_logger @@ -12,17 +13,17 @@ logger = get_logger(__name__) class DataSourceFactory: """data source factory""" - + _sources: Dict[str, BaseDataSource] = {} - + @classmethod def get_source(cls, market: str) -> BaseDataSource: """ Get the data source for the specified market - + Args: market: market type (Crypto, USStock, Forex, Futures) - + Returns: Data source instance """ @@ -45,74 +46,67 @@ class DataSourceFactory: return cls.get_source("Futures") # Default to Crypto for safety (most callers want a ticker for crypto pairs). return cls.get_source("Crypto") - + @classmethod def _create_source(cls, market: str) -> BaseDataSource: """Create data source instance""" - if market == 'Crypto': + if market == "Crypto": from app.data_sources.crypto import CryptoDataSource + return CryptoDataSource() - elif market == 'CNStock': - from app.data_sources.cn_stock import CNStockDataSource - return CNStockDataSource() - elif market == 'HKStock': - from app.data_sources.hk_stock import HKStockDataSource - return HKStockDataSource() - elif market == 'USStock': + elif market == "USStock": from app.data_sources.us_stock import USStockDataSource + return USStockDataSource() - elif market == 'Forex': + elif market == "Forex": from app.data_sources.forex import ForexDataSource + return ForexDataSource() - elif market == 'Futures': + elif market == "Futures": from app.data_sources.futures import FuturesDataSource + return FuturesDataSource() else: raise ValueError(f"Unsupported market type: {market}") - + @classmethod def get_kline( - cls, - market: str, - symbol: str, - timeframe: str, - limit: int, - before_time: Optional[int] = None + cls, market: str, symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None ) -> List[Dict[str, Any]]: """ A convenient way to obtain K-line data - + Args: market: market type symbol: trading pair/stock code timeframe: time period limit: number of data items before_time: Get data before this time - + Returns: K-line data list """ try: source = cls.get_source(market) klines = source.get_kline(symbol, timeframe, limit, before_time) - + # Make sure the data is sorted by time - klines.sort(key=lambda x: x['time']) - + klines.sort(key=lambda x: x["time"]) + return klines except Exception as e: logger.error(f"Failed to fetch K-lines {market}:{symbol} - {str(e)}") return [] - + @classmethod def get_ticker(cls, market: str, symbol: str) -> Dict[str, Any]: """ The convenient way to get realtime quotes - + Args: market: market type symbol: trading pair/stock code - + Returns: Real-time quotation data: { 'last': latest price, @@ -126,8 +120,7 @@ class DataSourceFactory: return source.get_ticker(symbol) except NotImplementedError: logger.warning(f"get_ticker not implemented for market: {market}") - return {'last': 0, 'symbol': symbol} + return {"last": 0, "symbol": symbol} except Exception as e: logger.error(f"Failed to fetch ticker {market}:{symbol} - {str(e)}") - return {'last': 0, 'symbol': symbol} - + return {"last": 0, "symbol": symbol} diff --git a/backend_api_python/app/data_sources/forex.py b/backend_api_python/app/data_sources/forex.py index 01b6827..780b510 100644 --- a/backend_api_python/app/data_sources/forex.py +++ b/backend_api_python/app/data_sources/forex.py @@ -2,15 +2,17 @@ Forex data source Get Forex Data with Tiingo """ -from typing import Dict, List, Any, Optional -from datetime import datetime, timedelta -import time -import requests -import threading -from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS +import threading +import time +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +import requests + +from app.config import APIKeys, TiingoConfig +from app.data_sources.base import TIMEFRAME_SECONDS, BaseDataSource from app.utils.logger import get_logger -from app.config import TiingoConfig, APIKeys logger = get_logger(__name__) @@ -22,52 +24,52 @@ _FOREX_CACHE_TTL = 60 # Forex price caching for 60 seconds (Tiingo free API has class ForexDataSource(BaseDataSource): """Forex data source (Tiingo)""" - + name = "Forex/Tiingo" - + # Tiingo resampleFreq mapping # Tiingo free account support: 5min, 15min, 30min, 1hour, 4hour, 1day # Note: 1min requires paid subscription, 1week/1month is not supported by Tiingo FX API TIMEFRAME_MAP = { - '1m': '1min', # Paid subscription required - '5m': '5min', - '15m': '15min', - '30m': '30min', - '1H': '1hour', - '4H': '4hour', - '1D': '1day', - '1W': None, # Tiingo does not support it and needs to be aggregated. - '1M': None # Tiingo does not support it and needs to be aggregated. + "1m": "1min", # Paid subscription required + "5m": "5min", + "15m": "15min", + "30m": "30min", + "1H": "1hour", + "4H": "4hour", + "1D": "1day", + "1W": None, # Tiingo does not support it and needs to be aggregated. + "1M": None, # Tiingo does not support it and needs to be aggregated. } - + # Forex pair mapping (Tiingo uses standard tickers such as eurusd, audusd) # Uppercase letters are also acceptable. Tiingo is usually not case-sensitive, but uniformity is recommended. SYMBOL_MAP = { # Precious metals (Tiingo does not necessarily support all precious metals in OANDA format, usually XAUUSD) - 'XAUUSD': 'xauusd', - 'XAGUSD': 'xagusd', + "XAUUSD": "xauusd", + "XAGUSD": "xagusd", # major currency pairs - 'EURUSD': 'eurusd', - 'GBPUSD': 'gbpusd', - 'USDJPY': 'usdjpy', - 'AUDUSD': 'audusd', - 'USDCAD': 'usdcad', - 'USDCHF': 'usdchf', - 'NZDUSD': 'nzdusd', + "EURUSD": "eurusd", + "GBPUSD": "gbpusd", + "USDJPY": "usdjpy", + "AUDUSD": "audusd", + "USDCAD": "usdcad", + "USDCHF": "usdchf", + "NZDUSD": "nzdusd", } - + def __init__(self): self.base_url = TiingoConfig.BASE_URL if not APIKeys.TIINGO_API_KEY: - logger.warning("Tiingo API key is not configured; FX data will be unavailable") - + logger.warning("Tiingo API key is not configured; FX data will be unavailable") + def get_ticker(self, symbol: str) -> Dict[str, Any]: """ Get realtime quotes for foreign exchange - + Get realtime quotes using the Tiingo FX Top-of-Book API Comes with 60 second cache to avoid triggering Tiingo rate limit frequently - + Returns: dict: { 'last': current price (mid price), @@ -80,42 +82,39 @@ class ForexDataSource(BaseDataSource): api_key = APIKeys.TIINGO_API_KEY if not api_key: logger.warning("Tiingo API key not configured") - return {'last': 0, 'symbol': symbol} - + return {"last": 0, "symbol": symbol} + # Check cache cache_key = f"ticker_{symbol}" with _forex_cache_lock: cached = _forex_cache.get(cache_key) if cached: - cache_time = cached.get('_cache_time', 0) + cache_time = cached.get("_cache_time", 0) if time.time() - cache_time < _FOREX_CACHE_TTL: logger.debug(f"Using cached forex ticker for {symbol}") return cached - + try: # parse symbol tiingo_symbol = self.SYMBOL_MAP.get(symbol) if not tiingo_symbol: tiingo_symbol = symbol.lower() - + # Tiingo FX Top-of-Book API # https://api.tiingo.com/tiingo/fx/top?tickers=eurusd&token=... url = f"{self.base_url}/fx/top" - params = { - 'tickers': tiingo_symbol, - 'token': api_key - } - + params = {"tickers": tiingo_symbol, "token": api_key} + # Retry logic: Handling 429 rate limiting for attempt in range(3): response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT) if response.status_code == 429: wait_time = 2 * (attempt + 1) - logger.warning(f"Tiingo rate limit (429), waiting {wait_time}s before retry ({attempt+1}/3)") + logger.warning(f"Tiingo rate limit (429), waiting {wait_time}s before retry ({attempt + 1}/3)") time.sleep(wait_time) continue break - + if response.status_code == 429: logger.warning("Tiingo rate limit exceeded for ticker request") logger.info("Note: Tiingo 1-minute forex data requires a paid subscription") @@ -124,86 +123,77 @@ class ForexDataSource(BaseDataSource): if cache_key in _forex_cache: logger.info(f"Returning stale cache for {symbol} due to rate limit") return _forex_cache[cache_key] - return {'last': 0, 'symbol': symbol} - + return {"last": 0, "symbol": symbol} + response.raise_for_status() data = response.json() - + if data and isinstance(data, list) and len(data) > 0: item = data[0] # Tiingo FX top returns: ticker, quoteTimestamp, bidPrice, bidSize, askPrice, askSize, midPrice - bid = float(item.get('bidPrice', 0) or 0) - ask = float(item.get('askPrice', 0) or 0) - mid = float(item.get('midPrice', 0) or 0) - + bid = float(item.get("bidPrice", 0) or 0) + ask = float(item.get("askPrice", 0) or 0) + mid = float(item.get("midPrice", 0) or 0) + # If there is no midPrice, calculate the mid price if not mid and bid and ask: mid = (bid + ask) / 2 - + last_price = mid or bid or ask - + # Get the closing price of the previous day to calculate the rise and fall (additional request for daily data is required) prev_close = 0 change = 0 change_pct = 0 - + try: # Get yesterday's closing price - yesterday = (datetime.now() - timedelta(days=2)).strftime('%Y-%m-%d') - today = datetime.now().strftime('%Y-%m-%d') + yesterday = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d") + today = datetime.now().strftime("%Y-%m-%d") price_url = f"{self.base_url}/fx/{tiingo_symbol}/prices" - price_params = { - 'startDate': yesterday, - 'endDate': today, - 'resampleFreq': '1day', - 'token': api_key - } + price_params = {"startDate": yesterday, "endDate": today, "resampleFreq": "1day", "token": api_key} price_resp = requests.get(price_url, params=price_params, timeout=TiingoConfig.TIMEOUT) if price_resp.status_code == 200: price_data = price_resp.json() if price_data and len(price_data) > 0: - prev_close = float(price_data[-1].get('close', 0) or 0) + prev_close = float(price_data[-1].get("close", 0) or 0) if prev_close and last_price: change = last_price - prev_close change_pct = (change / prev_close) * 100 except Exception: pass # Failure to calculate the rise or fall does not affect the main functions - + result = { - 'last': round(last_price, 5), - 'bid': round(bid, 5), - 'ask': round(ask, 5), - 'change': round(change, 5), - 'changePercent': round(change_pct, 2), - 'previousClose': round(prev_close, 5) if prev_close else 0, - '_cache_time': time.time() + "last": round(last_price, 5), + "bid": round(bid, 5), + "ask": round(ask, 5), + "change": round(change, 5), + "changePercent": round(change_pct, 2), + "previousClose": round(prev_close, 5) if prev_close else 0, + "_cache_time": time.time(), } - + # cache results with _forex_cache_lock: _forex_cache[cache_key] = result - + return result - + except Exception as e: logger.error(f"Failed to get forex ticker for {symbol}: {e}") - - return {'last': 0, 'symbol': symbol} - + + return {"last": 0, "symbol": symbol} + def _get_timeframe_seconds(self, timeframe: str) -> int: """Get the number of seconds corresponding to the time period""" return TIMEFRAME_SECONDS.get(timeframe, 86400) - + def get_kline( - self, - symbol: str, - timeframe: str, - limit: int, - before_time: Optional[int] = None + self, symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None ) -> List[Dict[str, Any]]: """ Get foreign exchange K-line data - + Args: symbol: Forex pair symbol (such as XAUUSD, EURUSD) timeframe: time period @@ -215,7 +205,7 @@ class ForexDataSource(BaseDataSource): if not api_key: logger.error("Tiingo API key is not configured") return [] - + try: # 1. Parse Symbol tiingo_symbol = self.SYMBOL_MAP.get(symbol) @@ -225,15 +215,15 @@ class ForexDataSource(BaseDataSource): # 2. Analysis Resolution (resampleFreq) resample_freq = self.TIMEFRAME_MAP.get(timeframe) - + # Special treatment: 1W/1M requires daily aggregation - aggregate_to_weekly = (timeframe == '1W') - aggregate_to_monthly = (timeframe == '1M') + aggregate_to_weekly = timeframe == "1W" + aggregate_to_monthly = timeframe == "1M" original_limit = limit # Save original request quantity - + if aggregate_to_weekly or aggregate_to_monthly: # Aggregate using daily data - resample_freq = '1day' + resample_freq = "1day" # Limit the maximum number of weekly/monthly requests (Tiingo free API has data volume limit) # The maximum weekly request is 100 weeks = 700 days ≈ 2 years # The maximum monthly request is 36 months = 1080 days ≈ 3 years @@ -241,21 +231,21 @@ class ForexDataSource(BaseDataSource): original_limit = min(original_limit, max_limit) # More daily data is needed to aggregate (weekly lines require 7 days, monthly lines require 30 days) limit = original_limit * (7 if aggregate_to_weekly else 30) - + if not resample_freq: logger.warning(f"Tiingo does not support timeframe: {timeframe}") return [] - + # 1 minute data requires paid subscription reminder - if timeframe == '1m': - logger.info(f"Note: Tiingo 1-minute forex data requires a paid subscription") - + if timeframe == "1m": + logger.info("Note: Tiingo 1-minute forex data requires a paid subscription") + # 3. Calculation time range if before_time: end_dt = datetime.fromtimestamp(before_time) else: end_dt = datetime.now() - + # Calculate start time based on period and quantity # Note: Use daily seconds calculation in aggregation mode if aggregate_to_weekly or aggregate_to_monthly: @@ -264,71 +254,75 @@ class ForexDataSource(BaseDataSource): tf_seconds = self._get_timeframe_seconds(timeframe) # Get more buffer time (1.5 times, foreign exchange does not trade on weekends) start_dt = end_dt - timedelta(seconds=limit * tf_seconds * 1.5) - + # Tiingo free API supports up to about 5 years of data, limiting the maximum time range max_days = 365 * 3 # up to 3 years if (end_dt - start_dt).days > max_days: start_dt = end_dt - timedelta(days=max_days) logger.info(f"Tiingo: Limited date range to {max_days} days") - + # Format the date as YYYY-MM-DD (Tiingo supports this format) - start_date_str = start_dt.strftime('%Y-%m-%d') - end_date_str = end_dt.strftime('%Y-%m-%d') - + start_date_str = start_dt.strftime("%Y-%m-%d") + end_date_str = end_dt.strftime("%Y-%m-%d") + # 4. API request (with retry logic) # URL: https://api.tiingo.com/tiingo/fx/{ticker}/prices url = f"{self.base_url}/fx/{tiingo_symbol}/prices" - + params = { - 'startDate': start_date_str, - 'endDate': end_date_str, - 'resampleFreq': resample_freq, - 'token': api_key, - 'format': 'json' + "startDate": start_date_str, + "endDate": end_date_str, + "resampleFreq": resample_freq, + "token": api_key, + "format": "json", } - + # logger.info(f"Tiingo Request: {url} params={params}") - + # Retry logic: Handling 429 rate limiting max_retries = 3 retry_delay = 2 # Second response = None - + for attempt in range(max_retries): try: response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT) - + if response.status_code == 429: # Rate limit, wait and try again wait_time = retry_delay * (attempt + 1) - logger.warning(f"Tiingo rate limit (429), waiting {wait_time}s before retry ({attempt + 1}/{max_retries})") + logger.warning( + f"Tiingo rate limit (429), waiting {wait_time}s before retry ({attempt + 1}/{max_retries})" + ) time.sleep(wait_time) continue - + break # Success or other errors, exit the retry loop - + except requests.exceptions.Timeout: if attempt < max_retries - 1: logger.warning(f"Tiingo request timeout, retrying ({attempt + 1}/{max_retries})") time.sleep(retry_delay) continue raise - + if response is None: logger.error("Tiingo API request failed after all retries") return [] - + if response.status_code == 429: logger.error("Tiingo API rate limit exceeded. Please wait a moment before retrying.") return [] - + if response.status_code == 403: - logger.error("Tiingo API permission error (403): check whether your API key is valid and has access to this dataset.") + logger.error( + "Tiingo API permission error (403): check whether your API key is valid and has access to this dataset." + ) return [] - + response.raise_for_status() data = response.json() - + # 5. Process the response # Tiingo returns a list of dicts: # [ @@ -343,35 +337,37 @@ class ForexDataSource(BaseDataSource): # }, ... # ] # Note: Tiingo FX prices objects keys: date, open, high, low, close. - + if not isinstance(data, list): logger.warning(f"Tiingo response is not a list: {data}") return [] - + klines = [] for item in data: # Parsing time: "2023-01-01T00:00:00.000Z" - dt_str = item.get('date') + dt_str = item.get("date") # Tiingo returns UTC time in ISO format and needs to handle the time zone correctly. # Convert UTC time to local timestamp - if dt_str.endswith('Z'): - dt_str = dt_str[:-1] + '+00:00' # Replace Z with +00:00 for UTC - + if dt_str.endswith("Z"): + dt_str = dt_str[:-1] + "+00:00" # Replace Z with +00:00 for UTC + dt = datetime.fromisoformat(dt_str) ts = int(dt.timestamp()) # UTC time zone is now handled correctly - - klines.append({ - 'time': ts, - 'open': float(item.get('open')), - 'high': float(item.get('high')), - 'low': float(item.get('low')), - 'close': float(item.get('close')), - 'volume': 0.0 # Tiingo FX usually does not have volume - }) - + + klines.append( + { + "time": ts, + "open": float(item.get("open")), + "high": float(item.get("high")), + "low": float(item.get("low")), + "close": float(item.get("close")), + "volume": 0.0, # Tiingo FX usually does not have volume + } + ) + # Sort by time - klines.sort(key=lambda x: x['time']) - + klines.sort(key=lambda x: x["time"]) + # If you need to aggregate to weekly or monthly lines if aggregate_to_weekly: klines = self._aggregate_to_weekly(klines) @@ -379,36 +375,36 @@ class ForexDataSource(BaseDataSource): elif aggregate_to_monthly: klines = self._aggregate_to_monthly(klines) logger.debug(f"Aggregated {len(klines)} monthly candles from daily data") - + # Filter to original request count if len(klines) > original_limit: klines = klines[-original_limit:] - + # logger.info(f"obtained {len(klines)} pieces of Tiingo foreign exchange data") return klines - + except requests.exceptions.RequestException as e: logger.error(f"Tiingo API request failed: {e}") return [] except Exception as e: logger.error(f"Failed to process Tiingo data: {e}") return [] - + def _aggregate_to_weekly(self, daily_klines: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Aggregate daily data into weekly data""" if not daily_klines: return [] - + weekly_klines = [] current_week = None week_data = None - + for kline in daily_klines: - dt = datetime.fromtimestamp(kline['time']) + dt = datetime.fromtimestamp(kline["time"]) # Get the Monday of the week in which the date is located week_start = dt - timedelta(days=dt.weekday()) - week_key = week_start.strftime('%Y-%W') - + week_key = week_start.strftime("%Y-%W") + if week_key != current_week: # Save data from last week if week_data: @@ -416,39 +412,39 @@ class ForexDataSource(BaseDataSource): # start a new week current_week = week_key week_data = { - 'time': int(week_start.timestamp()), - 'open': kline['open'], - 'high': kline['high'], - 'low': kline['low'], - 'close': kline['close'], - 'volume': kline['volume'] + "time": int(week_start.timestamp()), + "open": kline["open"], + "high": kline["high"], + "low": kline["low"], + "close": kline["close"], + "volume": kline["volume"], } else: # Update this week's data - week_data['high'] = max(week_data['high'], kline['high']) - week_data['low'] = min(week_data['low'], kline['low']) - week_data['close'] = kline['close'] - week_data['volume'] += kline['volume'] - + week_data["high"] = max(week_data["high"], kline["high"]) + week_data["low"] = min(week_data["low"], kline["low"]) + week_data["close"] = kline["close"] + week_data["volume"] += kline["volume"] + # Add last week if week_data: weekly_klines.append(week_data) - + return weekly_klines - + def _aggregate_to_monthly(self, daily_klines: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Aggregate daily data into monthly data""" if not daily_klines: return [] - + monthly_klines = [] current_month = None month_data = None - + for kline in daily_klines: - dt = datetime.fromtimestamp(kline['time']) - month_key = dt.strftime('%Y-%m') - + dt = datetime.fromtimestamp(kline["time"]) + month_key = dt.strftime("%Y-%m") + if month_key != current_month: # Save last month’s data if month_data: @@ -457,22 +453,22 @@ class ForexDataSource(BaseDataSource): current_month = month_key month_start = dt.replace(day=1, hour=0, minute=0, second=0) month_data = { - 'time': int(month_start.timestamp()), - 'open': kline['open'], - 'high': kline['high'], - 'low': kline['low'], - 'close': kline['close'], - 'volume': kline['volume'] + "time": int(month_start.timestamp()), + "open": kline["open"], + "high": kline["high"], + "low": kline["low"], + "close": kline["close"], + "volume": kline["volume"], } else: # Update this month's data - month_data['high'] = max(month_data['high'], kline['high']) - month_data['low'] = min(month_data['low'], kline['low']) - month_data['close'] = kline['close'] - month_data['volume'] += kline['volume'] - + month_data["high"] = max(month_data["high"], kline["high"]) + month_data["low"] = min(month_data["low"], kline["low"]) + month_data["close"] = kline["close"] + month_data["volume"] += kline["volume"] + # Add last month if month_data: monthly_klines.append(month_data) - + return monthly_klines diff --git a/backend_api_python/app/data_sources/futures.py b/backend_api_python/app/data_sources/futures.py index dc0aa16..4a82479 100644 --- a/backend_api_python/app/data_sources/futures.py +++ b/backend_api_python/app/data_sources/futures.py @@ -4,64 +4,61 @@ support: 1. Cryptocurrency Futures (Binance Futures via CCXT) 2. Traditional futures (Yahoo Finance) """ -from typing import Dict, List, Any, Optional + from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + import ccxt import yfinance as yf -from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS +from app.config import CCXTConfig +from app.data_sources.base import TIMEFRAME_SECONDS, BaseDataSource from app.utils.logger import get_logger -from app.config import CCXTConfig, APIKeys logger = get_logger(__name__) class FuturesDataSource(BaseDataSource): """Futures data source""" - + name = "Futures" - + # Yahoo Finance time period mapping YF_TIMEFRAME_MAP = { - '1m': '1m', - '5m': '5m', - '15m': '15m', - '30m': '30m', - '1H': '1h', - '4H': '4h', - '1D': '1d', - '1W': '1wk' + "1m": "1m", + "5m": "5m", + "15m": "15m", + "30m": "30m", + "1H": "1h", + "4H": "4h", + "1D": "1d", + "1W": "1wk", } - + # CCXT time period mapping CCXT_TIMEFRAME_MAP = CCXTConfig.TIMEFRAME_MAP - + # Traditional futures contract code (Yahoo Finance) YF_SYMBOLS = { - 'GC': 'GC=F', # gold futures - 'SI': 'SI=F', # Silver futures - 'CL': 'CL=F', # Crude oil futures - 'NG': 'NG=F', # Natural gas futures - 'ZC': 'ZC=F', # Corn futures - 'ZW': 'ZW=F', # Wheat futures + "GC": "GC=F", # gold futures + "SI": "SI=F", # Silver futures + "CL": "CL=F", # Crude oil futures + "NG": "NG=F", # Natural gas futures + "ZC": "ZC=F", # Corn futures + "ZW": "ZW=F", # Wheat futures } - + def __init__(self): # Initialize CCXT (for cryptocurrency futures) config = { - 'timeout': CCXTConfig.TIMEOUT, - 'enableRateLimit': CCXTConfig.ENABLE_RATE_LIMIT, - 'options': { - 'defaultType': 'future' - } + "timeout": CCXTConfig.TIMEOUT, + "enableRateLimit": CCXTConfig.ENABLE_RATE_LIMIT, + "options": {"defaultType": "future"}, } - + if CCXTConfig.PROXY: - config['proxies'] = { - 'http': CCXTConfig.PROXY, - 'https': CCXTConfig.PROXY - } - + config["proxies"] = {"http": CCXTConfig.PROXY, "https": CCXTConfig.PROXY} + self.exchange = ccxt.binance(config) def get_ticker(self, symbol: str) -> Dict[str, Any]: @@ -101,21 +98,17 @@ class FuturesDataSource(BaseDataSource): elif sym.endswith("USD") and len(sym) > 3: sym = f"{sym[:-3]}/USD" return self.exchange.fetch_ticker(sym) - + def _get_timeframe_seconds(self, timeframe: str) -> int: """Get the number of seconds corresponding to the time period""" return TIMEFRAME_SECONDS.get(timeframe, 86400) - + def get_kline( - self, - symbol: str, - timeframe: str, - limit: int, - before_time: Optional[int] = None + self, symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None ) -> List[Dict[str, Any]]: """ Get futures K-line data - + Args: symbol: futures contract code timeframe: time period @@ -123,124 +116,106 @@ class FuturesDataSource(BaseDataSource): before_time: end timestamp """ # Determine whether it is traditional futures or cryptocurrency futures - if symbol in self.YF_SYMBOLS or symbol.endswith('=F'): + if symbol in self.YF_SYMBOLS or symbol.endswith("=F"): return self._get_traditional_futures(symbol, timeframe, limit, before_time) else: return self._get_crypto_futures(symbol, timeframe, limit, before_time) - + def _get_traditional_futures( - self, - symbol: str, - timeframe: str, - limit: int, - before_time: Optional[int] = None + self, symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None ) -> List[Dict[str, Any]]: """Use yfinance to obtain traditional futures data""" try: # Convert symbol format yf_symbol = self.YF_SYMBOLS.get(symbol, symbol) - if not yf_symbol.endswith('=F'): - yf_symbol = symbol + '=F' - + if not yf_symbol.endswith("=F"): + yf_symbol = symbol + "=F" + # conversion time period - yf_interval = self.YF_TIMEFRAME_MAP.get(timeframe, '1d') - + yf_interval = self.YF_TIMEFRAME_MAP.get(timeframe, "1d") + # logger.info(f"Get traditional futures K-line: {yf_symbol}, period: {yf_interval}, number of bars: {limit}") - + # Calculation time range if before_time: end_time = datetime.fromtimestamp(before_time) else: end_time = datetime.now() - + tf_seconds = self._get_timeframe_seconds(timeframe) start_time = end_time - timedelta(seconds=tf_seconds * limit * 1.5) - + # The end parameter of yfinance is not included (exclusive), and one day needs to be added. end_time_inclusive = end_time + timedelta(days=1) - + # Get data ticker = yf.Ticker(yf_symbol) - df = ticker.history( - start=start_time, - end=end_time_inclusive, - interval=yf_interval - ) - + df = ticker.history(start=start_time, end=end_time_inclusive, interval=yf_interval) + if df.empty: logger.warning(f"No data: {yf_symbol}") return [] - + # Convert format klines = [] for index, row in df.iterrows(): - klines.append({ - 'time': int(index.timestamp()), - 'open': float(row['Open']), - 'high': float(row['High']), - 'low': float(row['Low']), - 'close': float(row['Close']), - 'volume': float(row['Volume']) - }) - - klines.sort(key=lambda x: x['time']) + klines.append( + { + "time": int(index.timestamp()), + "open": float(row["Open"]), + "high": float(row["High"]), + "low": float(row["Low"]), + "close": float(row["Close"]), + "volume": float(row["Volume"]), + } + ) + + klines.sort(key=lambda x: x["time"]) if len(klines) > limit: klines = klines[-limit:] - + # logger.info(f"obtained {len(klines)} pieces of traditional futures data") return klines - + except Exception as e: logger.error(f"Failed to fetch traditional futures data: {e}") return [] - + def _get_crypto_futures( - self, - symbol: str, - timeframe: str, - limit: int, - before_time: Optional[int] = None + self, symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None ) -> List[Dict[str, Any]]: """Obtain cryptocurrency futures data using CCXT""" try: # Make sure the symbol format is correct - ccxt_symbol = symbol if '/' in symbol else f"{symbol}/USDT" - ccxt_timeframe = self.CCXT_TIMEFRAME_MAP.get(timeframe, '1d') - + ccxt_symbol = symbol if "/" in symbol else f"{symbol}/USDT" + ccxt_timeframe = self.CCXT_TIMEFRAME_MAP.get(timeframe, "1d") + # logger.info(f"Get cryptocurrency futures K-line: {ccxt_symbol}, period: {ccxt_timeframe}, number of bars: {limit}") - + # Get data if before_time: since_time = before_time - limit * self._get_timeframe_seconds(timeframe) - ohlcv = self.exchange.fetch_ohlcv( - ccxt_symbol, - ccxt_timeframe, - since=since_time * 1000, - limit=limit - ) + ohlcv = self.exchange.fetch_ohlcv(ccxt_symbol, ccxt_timeframe, since=since_time * 1000, limit=limit) else: - ohlcv = self.exchange.fetch_ohlcv( - ccxt_symbol, - ccxt_timeframe, - limit=limit - ) - + ohlcv = self.exchange.fetch_ohlcv(ccxt_symbol, ccxt_timeframe, limit=limit) + # Convert format klines = [] for candle in ohlcv: - klines.append({ - 'time': int(candle[0] / 1000), - 'open': float(candle[1]), - 'high': float(candle[2]), - 'low': float(candle[3]), - 'close': float(candle[4]), - 'volume': float(candle[5]) - }) - + klines.append( + { + "time": int(candle[0] / 1000), + "open": float(candle[1]), + "high": float(candle[2]), + "low": float(candle[3]), + "close": float(candle[4]), + "volume": float(candle[5]), + } + ) + # logger.info(f"obtained {len(klines)} pieces of cryptocurrency futures data") return klines - + except Exception as e: logger.error(f"Failed to fetch crypto futures data: {e}") return [] - diff --git a/backend_api_python/app/data_sources/hk_stock.py b/backend_api_python/app/data_sources/hk_stock.py deleted file mode 100644 index c0e5112..0000000 --- a/backend_api_python/app/data_sources/hk_stock.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -港股/H股数据源 — 多层 fallback - -有 TWELVE_DATA_API_KEY: - 所有周期 → Twelve Data(主) → 腾讯日/周线 → yfinance → AkShare - -无 API Key: - 分钟/小时 → yfinance → AkShare - 日/周线 → 腾讯 fqkline → yfinance → AkShare -""" - -from __future__ import annotations - -from typing import Dict, List, Any, Optional - -from app.data_sources.base import BaseDataSource -from app.data_sources.tencent import normalize_hk_code, fetch_quote, parse_quote_to_ticker, fetch_kline, tencent_kline_rows_to_dicts -from app.data_sources.asia_stock_kline import ( - normalize_chart_timeframe, - fetch_twelvedata_klines, - fetch_yfinance_klines, - fetch_akshare_minute_klines, - fetch_akshare_weekly_klines, -) -from app.utils.logger import get_logger - -logger = get_logger(__name__) - - -class HKStockDataSource(BaseDataSource): - """港股/H股数据源(TwelveData + Tencent + yfinance + AkShare)""" - - name = "HKStock/multi-source" - - def get_ticker(self, symbol: str) -> Dict[str, Any]: - code = normalize_hk_code(symbol) - parts = fetch_quote(code) - if not parts: - return {"last": 0, "symbol": code} - t = parse_quote_to_ticker(parts) - return { - "last": t.get("last", 0), - "change": t.get("change", 0), - "changePercent": t.get("changePercent", 0), - "high": t.get("high", 0), - "low": t.get("low", 0), - "open": t.get("open", 0), - "previousClose": t.get("previousClose", 0), - "name": t.get("name", ""), - "symbol": code, - } - - def get_kline( - self, - symbol: str, - timeframe: str, - limit: int, - before_time: Optional[int] = None, - ) -> List[Dict[str, Any]]: - code = normalize_hk_code(symbol) - tf = normalize_chart_timeframe(timeframe) - lim = max(int(limit or 300), 1) - - # Tier 1: Twelve Data (paid, most reliable) - rows = fetch_twelvedata_klines( - is_hk=True, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time - ) - if rows: - return self.filter_and_limit(rows, limit=lim, before_time=before_time) - - # Tier 2: Tencent for daily/weekly (fast, free) - if tf in ("1D", "1W"): - tf_map = {"1D": "day", "1W": "week"} - period = tf_map.get(tf, "day") - raw_rows = fetch_kline(code, period=period, count=lim, adj="qfq") - out = tencent_kline_rows_to_dicts(raw_rows) - if out: - return self.filter_and_limit(out, limit=lim, before_time=before_time) - - # Tier 3: yfinance (works when Yahoo not rate-limited) - rows = fetch_yfinance_klines( - is_hk=True, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time - ) - if rows: - return self.filter_and_limit(rows, limit=lim, before_time=before_time) - - # Tier 4: AkShare (fragile overseas, last resort) - if tf in ("1m", "5m", "15m", "30m", "1H", "4H"): - rows = fetch_akshare_minute_klines( - is_hk=True, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time - ) - elif tf == "1W": - rows = fetch_akshare_weekly_klines( - is_hk=True, tencent_code=code, limit=lim, before_time=before_time - ) - else: - rows = [] - - return self.filter_and_limit(rows, limit=lim, before_time=before_time) diff --git a/backend_api_python/app/data_sources/polymarket.py b/backend_api_python/app/data_sources/polymarket.py index e9138e6..0925b0d 100644 --- a/backend_api_python/app/data_sources/polymarket.py +++ b/backend_api_python/app/data_sources/polymarket.py @@ -2,21 +2,23 @@ Polymarket prediction market data source Get prediction market data from Polymarket """ -import time -import requests -import json -from typing import Dict, List, Any, Optional -from datetime import datetime, timedelta -from app.utils.logger import get_logger +import json +import time +from datetime import datetime, timedelta +from typing import Dict, List, Optional + +import requests + from app.utils.db import get_db_connection +from app.utils.logger import get_logger logger = get_logger(__name__) class PolymarketDataSource: """Polymarket prediction market data source""" - + def __init__(self): # Polymarket official API endpoint (according to official documentation) # Gamma API: markets, events, tags, searches, etc. (fully public, no authentication required) @@ -27,19 +29,18 @@ class PolymarketDataSource: self.clob_api = "https://clob.polymarket.com" self.cache_ttl = 300 # 5 minutes cache self.session = requests.Session() - self.session.headers.update({ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Accept': 'application/json' - }) - + self.session.headers.update( + {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json"} + ) + def get_trending_markets(self, category: str = None, limit: int = 50) -> List[Dict]: """ Get popular prediction markets - + Args: category: category filter (crypto, politics, economics, sports, all) limit: return quantity limit - + Returns: Prediction market list """ @@ -48,10 +49,10 @@ class PolymarketDataSource: cached = self._get_cached_markets(category, limit) if cached: return cached - + # Fetch from real API - Fetch data from multiple categories to ensure diversity all_markets = [] - + if category and category != "all": # If a category is specified, only data for that category will be obtained markets = self._fetch_markets_from_api(category, limit * 2) @@ -60,7 +61,7 @@ class PolymarketDataSource: # Retrieve all events (without specifying a category to avoid duplicate requests) markets = self._fetch_from_gamma_api(category=None, limit=100) all_markets.extend(markets) - + # 去重(按market_id) seen = set() unique_markets = [] @@ -69,23 +70,23 @@ class PolymarketDataSource: if market_id and market_id not in seen: seen.add(market_id) unique_markets.append(market) - + # Sort by transaction volume - unique_markets.sort(key=lambda x: x.get('volume_24h', 0), reverse=True) - + unique_markets.sort(key=lambda x: x.get("volume_24h", 0), reverse=True) + # Save to database cache if unique_markets: self._save_markets_to_db(unique_markets) return unique_markets[:limit] - + # If the API fails, an empty list is returned (sample data is no longer used) logger.warning("Polymarket API unavailable, returning empty list") return [] - + except Exception as e: logger.error(f"Failed to get trending markets: {e}", exc_info=True) return [] - + def get_market_details(self, market_id: str) -> Optional[Dict]: """Get individual market details""" try: @@ -94,52 +95,58 @@ class PolymarketDataSource: if not market_id: logger.warning("Empty market_id provided") return None - + # Read from database first try: with get_db_connection() as db: cur = db.cursor() - cur.execute(""" - SELECT market_id, question, category, current_probability, + cur.execute( + """ + SELECT market_id, question, category, current_probability, volume_24h, liquidity, end_date_iso, status, outcome_tokens FROM qd_polymarket_markets WHERE market_id = %s - """, (market_id,)) + """, + (market_id,), + ) row = cur.fetchone() cur.close() - + if row: - #RealDictCursor returns the dictionary, accessed using keys - db_market_id = str(row.get('market_id') or market_id) + # RealDictCursor returns the dictionary, accessed using keys + db_market_id = str(row.get("market_id") or market_id) # Parse outcome_tokens (may be a JSON string) outcome_tokens = {} - outcome_tokens_raw = row.get('outcome_tokens') + outcome_tokens_raw = row.get("outcome_tokens") if outcome_tokens_raw: try: if isinstance(outcome_tokens_raw, str): outcome_tokens = json.loads(outcome_tokens_raw) else: outcome_tokens = outcome_tokens_raw if isinstance(outcome_tokens_raw, dict) else {} - except: + except Exception as e: + logger.debug(f"Failed to parse outcome tokens for market {market_id}: {e}") outcome_tokens = {} - + return { "market_id": db_market_id, - "question": row.get('question') or '', - "category": row.get('category') or 'other', - "current_probability": float(row.get('current_probability') or 0), - "volume_24h": float(row.get('volume_24h') or 0), - "liquidity": float(row.get('liquidity') or 0), - "end_date_iso": row.get('end_date_iso'), - "status": row.get('status') or 'active', + "question": row.get("question") or "", + "category": row.get("category") or "other", + "current_probability": float(row.get("current_probability") or 0), + "volume_24h": float(row.get("volume_24h") or 0), + "liquidity": float(row.get("liquidity") or 0), + "end_date_iso": row.get("end_date_iso"), + "status": row.get("status") or "active", "outcome_tokens": outcome_tokens, - "polymarket_url": self._build_polymarket_url(row.get('slug'), db_market_id), - "slug": row.get('slug') if row.get('slug') and not str(row.get('slug', '')).isdigit() else None + "polymarket_url": self._build_polymarket_url(row.get("slug"), db_market_id), + "slug": row.get("slug") + if row.get("slug") and not str(row.get("slug", "")).isdigit() + else None, } except Exception as db_error: logger.warning(f"Database query failed for market {market_id}: {db_error}") # Continue trying to get it from the API - + # If the database does not exist, get it from the API logger.info(f"Market {market_id} not in database, fetching from API") market = self._fetch_market_from_api(market_id) @@ -149,25 +156,25 @@ class PolymarketDataSource: except Exception as save_error: logger.warning(f"Failed to save market to DB: {save_error}") return market - + logger.warning(f"Market {market_id} not found in API") return None - + except Exception as e: logger.error(f"Failed to get market details for {market_id}: {e}", exc_info=True) return None - + def get_market_history(self, market_id: str, days: int = 30) -> List[Dict]: """Get historical market price data.""" # Here you need to implement historical data acquisition logic # Temporarily returns an empty list return [] - + def search_markets(self, keyword: str, limit: int = 20, use_cache: bool = True) -> List[Dict]: """ Search related prediction markets Priority is given to obtaining real-time data from the API, and the database is only used as an optional cache. - + Args: keyword: search keyword limit: limit on the number of returned results @@ -175,7 +182,7 @@ class PolymarketDataSource: """ try: logger.info(f"Searching Polymarket markets for keyword: '{keyword}' (limit={limit}, use_cache={use_cache})") - + # If caching is allowed, try searching from the database first if use_cache: with get_db_connection() as db: @@ -183,120 +190,166 @@ class PolymarketDataSource: # Improved search: search question and slug fields at the same time, also support market_id exact matching keyword_lower = keyword.lower() is_numeric = keyword_lower.isdigit() - has_hyphens = '-' in keyword_lower - + has_hyphens = "-" in keyword_lower + if is_numeric: # If it is a pure number, it may be market_id, an exact match - cur.execute(""" - SELECT market_id, question, category, current_probability, + cur.execute( + """ + SELECT market_id, question, category, current_probability, volume_24h, liquidity, end_date_iso, status, slug FROM qd_polymarket_markets WHERE market_id = %s AND status = 'active' ORDER BY volume_24h DESC LIMIT %s - """, (keyword, limit)) + """, + (keyword, limit), + ) elif has_hyphens: # If it contains a hyphen, it may be a slug, and the slug will be matched first. - cur.execute(""" - SELECT market_id, question, category, current_probability, + cur.execute( + """ + SELECT market_id, question, category, current_probability, volume_24h, liquidity, end_date_iso, status, slug FROM qd_polymarket_markets WHERE (slug ILIKE %s OR question ILIKE %s) AND status = 'active' - ORDER BY + ORDER BY CASE WHEN slug ILIKE %s THEN 1 ELSE 2 END, volume_24h DESC LIMIT %s - """, (f"%{keyword}%", f"%{keyword}%", f"%{keyword}%", limit)) + """, + (f"%{keyword}%", f"%{keyword}%", f"%{keyword}%", limit), + ) else: # Normal text search - cur.execute(""" - SELECT market_id, question, category, current_probability, + cur.execute( + """ + SELECT market_id, question, category, current_probability, volume_24h, liquidity, end_date_iso, status, slug FROM qd_polymarket_markets WHERE (question ILIKE %s OR slug ILIKE %s) AND status = 'active' ORDER BY volume_24h DESC LIMIT %s - """, (f"%{keyword}%", f"%{keyword}%", limit)) - + """, + (f"%{keyword}%", f"%{keyword}%", limit), + ) + rows = cur.fetchall() cur.close() - + if rows: logger.info(f"Found {len(rows)} markets in database for keyword '{keyword}'") - return [{ - "market_id": str(row.get('market_id') or ''), - "question": row.get('question') or '', - "category": row.get('category') or 'other', - "current_probability": float(row.get('current_probability') or 0), - "volume_24h": float(row.get('volume_24h') or 0), - "liquidity": float(row.get('liquidity') or 0), - "end_date_iso": row.get('end_date_iso'), - "status": row.get('status') or 'active', - "polymarket_url": self._build_polymarket_url(row.get('slug'), row.get('market_id') or ''), - "slug": row.get('slug') if row.get('slug') and not str(row.get('slug', '')).isdigit() else None - } for row in rows] - + return [ + { + "market_id": str(row.get("market_id") or ""), + "question": row.get("question") or "", + "category": row.get("category") or "other", + "current_probability": float(row.get("current_probability") or 0), + "volume_24h": float(row.get("volume_24h") or 0), + "liquidity": float(row.get("liquidity") or 0), + "end_date_iso": row.get("end_date_iso"), + "status": row.get("status") or "active", + "polymarket_url": self._build_polymarket_url( + row.get("slug"), row.get("market_id") or "" + ), + "slug": row.get("slug") + if row.get("slug") and not str(row.get("slug", "")).isdigit() + else None, + } + for row in rows + ] + # Obtain and filter directly from Gamma API (used during AI analysis) logger.info(f"Fetching from API for keyword '{keyword}' (use_cache={use_cache})...") - + # Optimization: If the keyword looks like a slug, try direct query first (avoid fetching the full amount) import re + keyword_lower = keyword.lower().strip() - is_slug_like = '-' in keyword_lower and not keyword_lower.isdigit() - + is_slug_like = "-" in keyword_lower and not keyword_lower.isdigit() + if is_slug_like: # Try to query directly through slug (most efficient, according to Polymarket API documentation) direct_market = self._fetch_market_by_slug(keyword_lower) if direct_market: logger.info(f"Found market directly by slug (no need to fetch all markets): {keyword_lower}") return [direct_market] - + # If direct query fails, get more data so that there is enough room for selection # Make multiple requests to get more markets (up to 100 events each time, but each event may contain multiple markets) all_markets = [] - max_requests = 3 # Request up to 3 times to get 300 events (about 4500 markets) + max_requests = 3 # Request up to 3 times to get 300 events (about 4500 markets) for page in range(max_requests): page_markets = self._fetch_from_gamma_api(category=None, limit=100) if not page_markets: break all_markets.extend(page_markets) # If enough markets have been acquired, you can stop early - if len(all_markets) >= 3000: # Get up to 3000 markets + if len(all_markets) >= 3000: # Get up to 3000 markets break logger.info(f"Fetched page {page + 1}/{max_requests}, total markets: {len(all_markets)}") # Short delay to avoid API current limit if page < max_requests - 1: time.sleep(0.5) logger.info(f"Fetched {len(all_markets)} markets from API, filtering for keyword '{keyword}'...") - + # Filter by keyword (supports multiple keyword matching) # If the keyword looks like a slug (contains a hyphen), also try to match the slug - keyword_is_slug = '-' in keyword_lower + keyword_is_slug = "-" in keyword_lower # Extract keywords (remove common stop words and punctuation) # Extract keywords: remove punctuation, retain alphanumeric characters and hyphens - keyword_words = re.findall(r'\b\w+\b', keyword_lower) + keyword_words = re.findall(r"\b\w+\b", keyword_lower) # Filter out words that are too short (less than 3 characters) and common stop words - stop_words = {'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'will', 'would', 'should', 'could', 'may', 'might', 'can', 'must'} + stop_words = { + "the", + "a", + "an", + "and", + "or", + "but", + "in", + "on", + "at", + "to", + "for", + "of", + "with", + "by", + "is", + "are", + "was", + "were", + "be", + "been", + "will", + "would", + "should", + "could", + "may", + "might", + "can", + "must", + } keyword_words = [w for w in keyword_words if len(w) >= 3 and w not in stop_words] - + # If no keywords are extracted, use the original keywords if not keyword_words: keyword_words = [keyword_lower] - + logger.info(f"Extracted keywords: {keyword_words} from '{keyword}'") - + filtered = [] - scored_markets = [] # Used to store scored results - top_candidates = [] # Used to store close matching candidates (for debugging) - + scored_markets = [] # Used to store scored results + top_candidates = [] # Used to store close matching candidates (for debugging) + for market in all_markets: question = market.get("question", "").lower() slug = (market.get("slug") or "").lower() market_id = str(market.get("market_id") or "") - + score = 0 match_reason = "" - + # 1. Exact match (highest priority, score 100) if keyword_lower in question: score = 100 @@ -304,7 +357,7 @@ class PolymarketDataSource: elif keyword_lower == slug: score = 100 match_reason = "exact_match_slug" - + # 2. If the keyword looks like a slug, check the slug field if score < 100 and keyword_is_slug: if keyword_lower == slug: @@ -313,13 +366,13 @@ class PolymarketDataSource: elif keyword_lower in slug or slug in keyword_lower: score = 90 match_reason = "partial_slug_match" - + # 3. If the keyword is a pure number, check market_id if score < 90 and keyword_lower.isdigit(): if keyword_lower == market_id: score = 100 match_reason = "market_id_match" - + # 4. Keyword matching: Check whether all keywords are in the question if score < 90 and keyword_words: # Calculate the number of matching keywords @@ -329,13 +382,15 @@ class PolymarketDataSource: match_ratio = matched_words / len(keyword_words) # Lower the threshold: from 60% to 40% to improve the matching rate if match_ratio >= 0.4: - score = int(60 + match_ratio * 30) # 60-90 minutes + score = int(60 + match_ratio * 30) # 60-90 minutes match_reason = f"keyword_match_{matched_words}/{len(keyword_words)}" else: # Log close matching candidates (for debugging) if matched_words >= 1 and len(top_candidates) < 5: - top_candidates.append((match_ratio, market.get('question', '')[:80], matched_words, len(keyword_words))) - + top_candidates.append( + (match_ratio, market.get("question", "")[:80], matched_words, len(keyword_words)) + ) + # 5. Partial matching: Check whether the main part of the keyword is in the question if score < 60 and keyword_words: # If the keyword contains multiple words, try to match the main part @@ -347,85 +402,89 @@ class PolymarketDataSource: if matched_important >= 1: score = 50 match_reason = f"important_words_match_{matched_important}/{len(important_words)}" - - if score >= 50: # Lower the minimum score requirement from 60 to 50 + + if score >= 50: # Lower the minimum score requirement from 60 to 50 scored_markets.append((score, market, match_reason)) logger.debug(f"Matched (score={score}, reason={match_reason}): {market.get('question', '')[:60]}") - + # Sort by score, take the first limit scored_markets.sort(key=lambda x: x[0], reverse=True) filtered = [market for score, market, reason in scored_markets[:limit]] - + # Output debugging information if len(scored_markets) == 0 and top_candidates: - logger.warning(f"No exact matches found. Top candidates (partial matches):") + logger.warning("No exact matches found. Top candidates (partial matches):") for ratio, question, matched, total in top_candidates: logger.warning(f" - {question} (matched {matched}/{total} keywords, ratio={ratio:.2f})") - - logger.info(f"Filtered {len(filtered)} markets matching keyword '{keyword}' from API (from {len(all_markets)} total markets, {len(scored_markets)} scored matches)") + + logger.info( + f"Filtered {len(filtered)} markets matching keyword '{keyword}' from API (from {len(all_markets)} total markets, {len(scored_markets)} scored matches)" + ) if len(scored_markets) > 0: logger.info(f"Top match: {filtered[0].get('question', '')[:80]} (score={scored_markets[0][0]})") return filtered - + except Exception as e: logger.error(f"Failed to search markets: {e}", exc_info=True) return [] - + def _get_cached_markets(self, category: str = None, limit: int = 50) -> Optional[List[Dict]]: """Read market data from the database cache.""" try: with get_db_connection() as db: cur = db.cursor() - + # Check cache is fresh (within 5 minutes) cutoff_time = datetime.now() - timedelta(seconds=self.cache_ttl) - + query = """ - SELECT market_id, question, category, current_probability, + SELECT market_id, question, category, current_probability, volume_24h, liquidity, end_date_iso, status, outcome_tokens FROM qd_polymarket_markets WHERE status = 'active' AND updated_at > %s """ params = [cutoff_time] - + if category: query += " AND category = %s" params.append(category) - + query += " ORDER BY volume_24h DESC LIMIT %s" params.append(limit) - + cur.execute(query, params) rows = cur.fetchall() cur.close() - + if rows: result = [] for row in rows: - market_id = str(row.get('market_id') or '') - slug = row.get('slug') + market_id = str(row.get("market_id") or "") + slug = row.get("slug") # Make sure to use the correct URL building method polymarket_url = self._build_polymarket_url(slug, market_id) - result.append({ - "market_id": market_id, - "question": row.get('question') or '', - "category": row.get('category') or 'other', - "current_probability": float(row.get('current_probability') or 0), - "volume_24h": float(row.get('volume_24h') or 0), - "liquidity": float(row.get('liquidity') or 0), - "end_date_iso": row.get('end_date_iso'), - "status": row.get('status') or 'active', - "outcome_tokens": row.get('outcome_tokens') if row.get('outcome_tokens') else {}, - "polymarket_url": polymarket_url, - "slug": slug if slug and not str(slug).isdigit() else None - }) + result.append( + { + "market_id": market_id, + "question": row.get("question") or "", + "category": row.get("category") or "other", + "current_probability": float(row.get("current_probability") or 0), + "volume_24h": float(row.get("volume_24h") or 0), + "liquidity": float(row.get("liquidity") or 0), + "end_date_iso": row.get("end_date_iso"), + "status": row.get("status") or "active", + "outcome_tokens": row.get("outcome_tokens") if row.get("outcome_tokens") else {}, + "polymarket_url": polymarket_url, + "slug": slug if slug and not str(slug).isdigit() else None, + } + ) return result - + return None except Exception as e: logger.debug(f"Failed to get cached markets: {e}") return None - + def _fetch_markets_from_api(self, category: str = None, limit: int = 50) -> List[Dict]: """ Retrieve market data from the Polymarket Gamma API @@ -436,17 +495,19 @@ class PolymarketDataSource: markets = self._fetch_from_gamma_api(category, limit) if markets: # Sort by volume_24h in descending order (because the API does not support the order parameter, local sorting is required) - markets.sort(key=lambda x: x.get('volume_24h', 0), reverse=True) - return markets[:limit] # Return the first limit after sorting - + markets.sort(key=lambda x: x.get("volume_24h", 0), reverse=True) + return markets[:limit] # Return the first limit after sorting + # If the API returns an empty list, record a warning (it may be that the API is temporarily unavailable, network problems or throttling) - logger.warning(f"Gamma API failed to fetch markets for category '{category}' (possible reasons: API is temporarily unavailable, network problems, current limiting or returning empty data)") + logger.warning( + f"Gamma API failed to fetch markets for category '{category}' (possible reasons: API is temporarily unavailable, network problems, current limiting or returning empty data)" + ) return [] - + except Exception as e: logger.error(f"Failed to fetch markets from API: {e}", exc_info=True) return [] - + def _fetch_from_gamma_api(self, category: str = None, limit: int = 50) -> List[Dict]: """ Retrieve market data from the Polymarket Gamma API @@ -461,31 +522,33 @@ class PolymarketDataSource: params = { "active": "true", "closed": "false", - "limit": min(limit * 2, 100) # Get more data for sorting and filtering + "limit": min(limit * 2, 100), # Get more data for sorting and filtering } - + # Try adding sorting parameters (if supported by API) # According to the documentation, possible sorting fields: volume_24hr, volume, liquidity, etc. # If the API is not supported, it will be removed after a 422 error. - + # If a category is specified, it needs to be filtered by tag_id # Note: You need to get the tag_id first, and use keywords to infer it here. if category: # You can try filtering by search or tags # Get them all temporarily, then filter when parsing pass - + logger.info(f"Fetching from Gamma API: {url} with params: {params}") response = self.session.get(url, params=params, timeout=15) - + logger.info(f"Gamma API response status: {response.status_code}") - + if response.status_code == 200: try: data = response.json() - logger.debug(f"Gamma API returned data type: {type(data)}, keys: {list(data.keys()) if isinstance(data, dict) else 'list'}") - - #The Gamma API may return a list or an object containing a data field + logger.debug( + f"Gamma API returned data type: {type(data)}, keys: {list(data.keys()) if isinstance(data, dict) else 'list'}" + ) + + # The Gamma API may return a list or an object containing a data field if isinstance(data, list): logger.info(f"Gamma API returned list with {len(data)} items") markets = self._parse_gamma_events(data, category) @@ -495,7 +558,9 @@ class PolymarketDataSource: # Possibly {"data": [...]} format if "data" in data: events_list = data["data"] - logger.info(f"Gamma API returned dict with 'data' field containing {len(events_list) if isinstance(events_list, list) else 'non-list'} items") + logger.info( + f"Gamma API returned dict with 'data' field containing {len(events_list) if isinstance(events_list, list) else 'non-list'} items" + ) markets = self._parse_gamma_events(events_list, category) logger.info(f"Parsed {len(markets)} markets from Gamma API") return markets @@ -508,43 +573,53 @@ class PolymarketDataSource: else: logger.warning(f"Gamma API returned dict with unexpected keys: {list(data.keys())}") logger.debug(f"Full response: {str(data)[:500]}") - + logger.warning(f"Gamma API returned unexpected format: {type(data)}") return [] except json.JSONDecodeError as je: logger.error(f"Gamma API returned invalid JSON: {je}") logger.error(f"Response text (first 500 chars): {response.text[:500]}") return [] - + # Non-200 status code status_code = response.status_code if status_code == 429: - logger.warning(f"Gamma API rate limited (429). Suggestion: Try again later or reduce the request frequency") + logger.warning( + "Gamma API rate limited (429). Suggestion: Try again later or reduce the request frequency" + ) elif status_code == 503: - logger.warning(f"Gamma API service unavailable (503). Polymarket API may be under maintenance") + logger.warning("Gamma API service unavailable (503). Polymarket API may be under maintenance") elif status_code >= 500: - logger.warning(f"Gamma API server error ({status_code}). The Polymarket server may be temporarily unavailable") + logger.warning( + f"Gamma API server error ({status_code}). The Polymarket server may be temporarily unavailable" + ) else: logger.warning(f"Gamma API returned status {status_code}") logger.debug(f"Response headers: {dict(response.headers)}") logger.debug(f"Response text (first 500 chars): {response.text[:500]}") return [] - + except requests.exceptions.Timeout: - logger.warning("Gamma API request timeout after 15 seconds (possible reason: network delay or slow API response)") + logger.warning( + "Gamma API request timeout after 15 seconds (possible reason: network delay or slow API response)" + ) return [] except requests.exceptions.ConnectionError as ce: - logger.warning(f"Gamma API connection error: {ce} (Possible reason: network connection problem or Polymarket API is unreachable)") + logger.warning( + f"Gamma API connection error: {ce} (Possible reason: network connection problem or Polymarket API is unreachable)" + ) return [] except Exception as e: - logger.warning(f"Gamma API failed: {e} (Possible reasons: API format change, network problem or service abnormality)") + logger.warning( + f"Gamma API failed: {e} (Possible reasons: API format change, network problem or service abnormality)" + ) return [] - + def _parse_gamma_events(self, events_data: List[Dict], category_filter: str = None) -> List[Dict]: """ Parse event data returned by the Gamma API The /events endpoint of the Gamma API returns event objects, each containing associated market data - + According to the official documentation, the event object structure is: - The event object contains a markets array - Each market contains fields such as clobTokenIds and outcomePrices @@ -553,58 +628,66 @@ class PolymarketDataSource: if not events_data: logger.warning("_parse_gamma_events received empty events_data") return parsed - + logger.info(f"Parsing {len(events_data)} events from Gamma API") - + # Record the key of the first event for debugging if events_data: first_event_keys = list(events_data[0].keys())[:10] logger.info(f"First event keys: {first_event_keys}") logger.debug(f"First event sample: {str(events_data[0])[:500]}") - + for idx, event in enumerate(events_data): try: - #Gamma API event object structure + # Gamma API event object structure # The event may have multiple markets (markets field), or directly contain market information markets = event.get("markets", []) - + # If the event does not have a markets field, the event itself may be market data. if not markets: # Check whether it is a market object directly (with question or title field) if "question" in event or "title" in event or "slug" in event: markets = [event] else: - if idx < 3: # Only record the details of the first 3 - logger.debug(f"Event {idx} has no markets and doesn't look like a market. Keys: {list(event.keys())[:10]}") + if idx < 3: # Only record the details of the first 3 + logger.debug( + f"Event {idx} has no markets and doesn't look like a market. Keys: {list(event.keys())[:10]}" + ) continue - - if idx < 3: # Only record the details of the first 3 + + if idx < 3: # Only record the details of the first 3 logger.debug(f"Processing event {idx} with {len(markets)} markets") - + for market_idx, market in enumerate(markets): # Extract basic market information market_id = market.get("id") or market.get("slug") or event.get("id") or event.get("slug", "") - question = market.get("question") or event.get("question") or market.get("title") or event.get("title", "") - - if idx < 3 and market_idx < 2: # Record detailed information of the first few markets - logger.info(f"Event {idx}, Market {market_idx}: id={market_id}, question={question[:50] if question else 'None'}, event_slug={event.get('slug')}, market_slug={market.get('slug')}, keys={list(market.keys())[:10]}") - + question = ( + market.get("question") or event.get("question") or market.get("title") or event.get("title", "") + ) + + if idx < 3 and market_idx < 2: # Record detailed information of the first few markets + logger.info( + f"Event {idx}, Market {market_idx}: id={market_id}, question={question[:50] if question else 'None'}, event_slug={event.get('slug')}, market_slug={market.get('slug')}, keys={list(market.keys())[:10]}" + ) + if not question: if idx < 3: - logger.warning(f"Event {idx}, Market {market_idx}: No question found, skipping. Market keys: {list(market.keys())[:10]}") + logger.warning( + f"Event {idx}, Market {market_idx}: No question found, skipping. Market keys: {list(market.keys())[:10]}" + ) continue - + # Infer category inferred_category = self._infer_category(question) - + # If category filtering is specified, filter if category_filter and inferred_category != category_filter: continue - + # Get probability and outcome data current_probability = 50.0 outcome_tokens = {} - + # Method 1: Get real-time prices from CLOB API (most accurate) try: condition_id = market.get("conditionId") or event.get("conditionId") @@ -615,12 +698,18 @@ class PolymarketDataSource: no_price = prices.get("NO", 0) if yes_price > 0: current_probability = yes_price * 100 if yes_price <= 1 else yes_price - outcome_tokens["YES"] = {"price": yes_price if yes_price <= 1 else yes_price / 100, "volume": 0} + outcome_tokens["YES"] = { + "price": yes_price if yes_price <= 1 else yes_price / 100, + "volume": 0, + } if no_price > 0: - outcome_tokens["NO"] = {"price": no_price if no_price <= 1 else no_price / 100, "volume": 0} + outcome_tokens["NO"] = { + "price": no_price if no_price <= 1 else no_price / 100, + "volume": 0, + } except Exception as e: logger.debug(f"Failed to get prices from CLOB API: {e}") - + # Method 2: Process the outcomePrices field (may be a JSON string) if current_probability == 50.0: outcome_prices_str = market.get("outcomePrices") or event.get("outcomePrices") @@ -630,21 +719,27 @@ class PolymarketDataSource: outcome_prices = json.loads(outcome_prices_str) else: outcome_prices = outcome_prices_str - + # outcomePrices is usually in the format ["0.65", "0.35"], corresponding to YES and NO if isinstance(outcome_prices, list) and len(outcome_prices) >= 2: yes_price = float(outcome_prices[0]) if outcome_prices[0] else 0 no_price = float(outcome_prices[1]) if outcome_prices[1] else 0 current_probability = yes_price * 100 if yes_price <= 1 else yes_price - outcome_tokens["YES"] = {"price": yes_price if yes_price <= 1 else yes_price / 100, "volume": 0} - outcome_tokens["NO"] = {"price": no_price if no_price <= 1 else no_price / 100, "volume": 0} + outcome_tokens["YES"] = { + "price": yes_price if yes_price <= 1 else yes_price / 100, + "volume": 0, + } + outcome_tokens["NO"] = { + "price": no_price if no_price <= 1 else no_price / 100, + "volume": 0, + } except Exception as e: logger.debug(f"Failed to parse outcomePrices: {e}") - + # Get outcomes from market or event - #outcomes may be an object array, a string array, or need to be parsed from other fields + # outcomes may be an object array, a string array, or need to be parsed from other fields outcomes = market.get("outcomes") or market.get("tokens") or event.get("outcomes") or [] - + # Process the outcomes array (may be an object or a string) for outcome in outcomes: try: @@ -659,57 +754,58 @@ class PolymarketDataSource: if "NO" not in outcome_tokens: outcome_tokens["NO"] = {"price": 0.5, "volume": 0} continue - + # outcome is an object if not isinstance(outcome, dict): continue - + title = str(outcome.get("title") or outcome.get("name", "")).upper() # Get the price (may be price, probability or currentPrice) - price = float(outcome.get("price") or outcome.get("probability") or outcome.get("currentPrice") or 0) - + price = float( + outcome.get("price") or outcome.get("probability") or outcome.get("currentPrice") or 0 + ) + if "YES" in title or title == "YES" or outcome.get("outcome") == "Yes": current_probability = price * 100 if price <= 1 else price outcome_tokens["YES"] = { "price": price if price <= 1 else price / 100, - "volume": float(outcome.get("volume", outcome.get("volume24hr", 0)) or 0) + "volume": float(outcome.get("volume", outcome.get("volume24hr", 0)) or 0), } elif "NO" in title or title == "NO" or outcome.get("outcome") == "No": outcome_tokens["NO"] = { "price": price if price <= 1 else price / 100, - "volume": float(outcome.get("volume", outcome.get("volume24hr", 0)) or 0) + "volume": float(outcome.get("volume", outcome.get("volume24hr", 0)) or 0), } except Exception as e: logger.debug(f"Failed to parse outcome: {e}") continue - + # If outcomes are not found, try to get probabilities from other fields if current_probability == 50.0: # Try to get it from the probability field of the market prob = market.get("probability") or market.get("yesProbability") or event.get("probability") if prob: current_probability = float(prob) * 100 if float(prob) <= 1 else float(prob) - + # Get trading volume and liquidity volume_24h = float( - market.get("volume_24hr") or - market.get("volume24hr") or - market.get("volume_24h") or - event.get("volume_24hr") or - event.get("volume24hr") or - 0 + market.get("volume_24hr") + or market.get("volume24hr") + or market.get("volume_24h") + or event.get("volume_24hr") + or event.get("volume24hr") + or 0 ) - + liquidity = float( - market.get("liquidity") or - market.get("totalLiquidity") or - event.get("liquidity") or - 0 + market.get("liquidity") or market.get("totalLiquidity") or event.get("liquidity") or 0 ) - + # Parse end date end_date_iso = None - end_date = market.get("endDate") or market.get("end_date") or event.get("endDate") or event.get("end_date") + end_date = ( + market.get("endDate") or market.get("end_date") or event.get("endDate") or event.get("end_date") + ) if end_date: try: if isinstance(end_date, (int, float)): @@ -717,28 +813,36 @@ class PolymarketDataSource: elif isinstance(end_date, str): # Try to parse the ISO format string end_date_iso = end_date - except: - pass - + except Exception as e: + logger.debug(f"Failed to parse end date: {e}") + # Get the slug used to build the URL # According to Polymarket API documentation: slug should be obtained directly from the data returned by the API # URL format: https://polymarket.com/event/{slug} # slug is a string identifier, not a numeric ID slug = None - + # Get the slug from the event first (because the event contains markets) if event.get("slug"): slug_str = str(event.get("slug", "")).strip() # If the slug is not a pure number and contains letters or hyphens, it is a valid slug - if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)): + if ( + slug_str + and not slug_str.isdigit() + and ("-" in slug_str or any(c.isalpha() for c in slug_str)) + ): slug = slug_str - + # If the event does not have a valid slug, try to get it from the market if not slug and market.get("slug"): slug_str = str(market.get("slug", "")).strip() - if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)): + if ( + slug_str + and not slug_str.isdigit() + and ("-" in slug_str or any(c.isalpha() for c in slug_str)) + ): slug = slug_str - + # If there is still no valid slug, try to obtain it through API query if not slug and market_id: try: @@ -746,16 +850,20 @@ class PolymarketDataSource: detail_market = self._fetch_market_detail_by_id(market_id) if detail_market and detail_market.get("slug"): slug_str = str(detail_market.get("slug", "")).strip() - if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)): + if ( + slug_str + and not slug_str.isdigit() + and ("-" in slug_str or any(c.isalpha() for c in slug_str)) + ): slug = slug_str except Exception as e: logger.debug(f"Failed to fetch slug for market {market_id}: {e}") - + # Build URL (using unified helper methods) polymarket_url = self._build_polymarket_url(slug, market_id) if not slug: logger.warning(f"Market {market_id} has no valid slug, using markets endpoint as fallback") - + market_data = { "market_id": market_id, "question": question, @@ -767,21 +875,26 @@ class PolymarketDataSource: "status": "active" if market.get("active", event.get("active", True)) else "closed", "outcome_tokens": outcome_tokens, "polymarket_url": polymarket_url, - "slug": slug if slug else None # Save slug (if not a number) + "slug": slug if slug else None, # Save slug (if not a number) } - + parsed.append(market_data) - - if idx < 3 and market_idx < 2: # Record successfully parsed markets - logger.info(f"Successfully parsed market: {question[:50]}, prob={current_probability:.1f}%, volume={volume_24h}") - + + if idx < 3 and market_idx < 2: # Record successfully parsed markets + logger.info( + f"Successfully parsed market: {question[:50]}, prob={current_probability:.1f}%, volume={volume_24h}" + ) + except Exception as e: - logger.warning(f"Failed to parse event {idx} (id={event.get('id', event.get('slug', 'unknown'))}): {e}", exc_info=True) + logger.warning( + f"Failed to parse event {idx} (id={event.get('id', event.get('slug', 'unknown'))}): {e}", + exc_info=True, + ) continue - + logger.info(f"Successfully parsed {len(parsed)} markets from {len(events_data)} events") return parsed - + def _parse_rest_markets(self, markets_data: List[Dict]) -> List[Dict]: """Parse REST API data""" parsed = [] @@ -790,47 +903,42 @@ class PolymarketDataSource: # Extract basic information market_id = market.get("id") or market.get("slug") or market.get("market_id", "") question = market.get("question") or market.get("title", "") - + # Calculate probability current_probability = 50.0 outcome_tokens = {} - + if "outcomes" in market: for outcome in market["outcomes"]: title = str(outcome.get("title", "")).upper() price = float(outcome.get("price", outcome.get("probability", 0)) or 0) if "YES" in title or title == "YES": current_probability = price * 100 - outcome_tokens["YES"] = { - "price": price, - "volume": float(outcome.get("volume", 0) or 0) - } + outcome_tokens["YES"] = {"price": price, "volume": float(outcome.get("volume", 0) or 0)} elif "NO" in title or title == "NO": - outcome_tokens["NO"] = { - "price": price, - "volume": float(outcome.get("volume", 0) or 0) - } - + outcome_tokens["NO"] = {"price": price, "volume": float(outcome.get("volume", 0) or 0)} + volume_24h = float(market.get("volume_24h", market.get("volume", 0)) or 0) liquidity = float(market.get("liquidity", 0) or 0) - + # inferred category category = self._infer_category(question) - + # parse end date end_date_iso = market.get("end_date") or market.get("endDate") if isinstance(end_date_iso, (int, float)): try: end_date_iso = datetime.fromtimestamp(end_date_iso).isoformat() + "Z" - except: + except Exception as e: + logger.debug(f"Failed to parse end date for market {market_id}: {e}") end_date_iso = None - + # Get the slug used to build the URL slug = None - slug_str = str(market.get('slug', '')).strip() if market.get('slug') else '' - + slug_str = str(market.get("slug", "")).strip() if market.get("slug") else "" + # Check if the slug is valid (not a number and contains letters or hyphens) - if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)): + if slug_str and not slug_str.isdigit() and ("-" in slug_str or any(c.isalpha() for c in slug_str)): slug = slug_str else: # If the slug is invalid, try to obtain it through API query @@ -838,149 +946,293 @@ class PolymarketDataSource: detail_market = self._fetch_market_detail_by_id(market_id) if detail_market and detail_market.get("slug"): slug_str = str(detail_market.get("slug", "")).strip() - if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)): + if ( + slug_str + and not slug_str.isdigit() + and ("-" in slug_str or any(c.isalpha() for c in slug_str)) + ): slug = slug_str except Exception as e: logger.debug(f"Failed to fetch slug for market {market_id}: {e}") - + # Build URL (using unity helper methods) polymarket_url = self._build_polymarket_url(slug, market_id) if not slug: logger.warning(f"Market {market_id} has no valid slug, using markets endpoint as fallback") - - parsed.append({ - "market_id": market_id, - "question": question, - "category": category, - "current_probability": round(current_probability, 2), - "volume_24h": volume_24h, - "liquidity": liquidity, - "end_date_iso": end_date_iso, - "status": "active" if market.get("active", True) else "closed", - "outcome_tokens": outcome_tokens, - "polymarket_url": polymarket_url, - "slug": slug if slug else None - }) + + parsed.append( + { + "market_id": market_id, + "question": question, + "category": category, + "current_probability": round(current_probability, 2), + "volume_24h": volume_24h, + "liquidity": liquidity, + "end_date_iso": end_date_iso, + "status": "active" if market.get("active", True) else "closed", + "outcome_tokens": outcome_tokens, + "polymarket_url": polymarket_url, + "slug": slug if slug else None, + } + ) except Exception as e: logger.debug(f"Failed to parse market {market.get('id')}: {e}") continue - + return parsed - + def _infer_category(self, question: str) -> str: """Infer categories from questions""" question_lower = question.lower() - + # Cryptocurrency Keywords - crypto_keywords = ['btc', 'bitcoin', 'eth', 'ethereum', 'sol', 'solana', 'crypto', 'token', 'coin', 'defi', 'nft'] + crypto_keywords = [ + "btc", + "bitcoin", + "eth", + "ethereum", + "sol", + "solana", + "crypto", + "token", + "coin", + "defi", + "nft", + ] if any(kw in question_lower for kw in crypto_keywords): return "crypto" - + # political keywords - politics_keywords = ['election', 'president', 'trump', 'biden', 'senate', 'congress', 'vote', 'political', 'democrat', 'republican'] + politics_keywords = [ + "election", + "president", + "trump", + "biden", + "senate", + "congress", + "vote", + "political", + "democrat", + "republican", + ] if any(kw in question_lower for kw in politics_keywords): return "politics" - + # economic keywords - economics_keywords = ['gdp', 'inflation', 'unemployment', 'fed', 'federal reserve', 'interest rate', 'economic', 'economy', 'recession', 'gdp growth', 'cpi', 'ppi'] + economics_keywords = [ + "gdp", + "inflation", + "unemployment", + "fed", + "federal reserve", + "interest rate", + "economic", + "economy", + "recession", + "gdp growth", + "cpi", + "ppi", + ] if any(kw in question_lower for kw in economics_keywords): return "economics" - + # Sports keywords - sports_keywords = ['nfl', 'nba', 'mlb', 'soccer', 'football', 'basketball', 'baseball', 'championship', 'world cup', 'olympics', 'super bowl', 'stanley cup', 'world series'] + sports_keywords = [ + "nfl", + "nba", + "mlb", + "soccer", + "football", + "basketball", + "baseball", + "championship", + "world cup", + "olympics", + "super bowl", + "stanley cup", + "world series", + ] if any(kw in question_lower for kw in sports_keywords): return "sports" - + # Technology keywords - tech_keywords = ['ai', 'artificial intelligence', 'chatgpt', 'openai', 'tech', 'technology', 'apple', 'google', 'microsoft', 'meta', 'tesla', 'ipo', 'startup'] + tech_keywords = [ + "ai", + "artificial intelligence", + "chatgpt", + "openai", + "tech", + "technology", + "apple", + "google", + "microsoft", + "meta", + "tesla", + "ipo", + "startup", + ] if any(kw in question_lower for kw in tech_keywords): return "tech" - + # financial keywords - finance_keywords = ['stock', 's&p', 'dow', 'nasdaq', 'market cap', 'earnings', 'revenue', 'profit', 'bank', 'banking', 'financial', 'trading'] + finance_keywords = [ + "stock", + "s&p", + "dow", + "nasdaq", + "market cap", + "earnings", + "revenue", + "profit", + "bank", + "banking", + "financial", + "trading", + ] if any(kw in question_lower for kw in finance_keywords): return "finance" - + # geopolitical keywords - geopolitics_keywords = ['war', 'conflict', 'russia', 'ukraine', 'china', 'taiwan', 'north korea', 'iran', 'israel', 'palestine', 'middle east', 'nato', 'sanctions'] + geopolitics_keywords = [ + "war", + "conflict", + "russia", + "ukraine", + "china", + "taiwan", + "north korea", + "iran", + "israel", + "palestine", + "middle east", + "nato", + "sanctions", + ] if any(kw in question_lower for kw in geopolitics_keywords): return "geopolitics" - + # Cultural keywords - culture_keywords = ['movie', 'film', 'oscar', 'grammy', 'award', 'celebrity', 'music', 'album', 'tv show', 'series', 'netflix', 'disney'] + culture_keywords = [ + "movie", + "film", + "oscar", + "grammy", + "award", + "celebrity", + "music", + "album", + "tv show", + "series", + "netflix", + "disney", + ] if any(kw in question_lower for kw in culture_keywords): return "culture" - + # climate keywords - climate_keywords = ['climate', 'global warming', 'temperature', 'carbon', 'emission', 'renewable', 'solar', 'wind energy', 'paris agreement', 'cop'] + climate_keywords = [ + "climate", + "global warming", + "temperature", + "carbon", + "emission", + "renewable", + "solar", + "wind energy", + "paris agreement", + "cop", + ] if any(kw in question_lower for kw in climate_keywords): return "climate" - + # Entertainment keywords - entertainment_keywords = ['game', 'gaming', 'esports', 'tournament', 'streaming', 'youtube', 'twitch', 'podcast', 'comic', 'anime', 'manga'] + entertainment_keywords = [ + "game", + "gaming", + "esports", + "tournament", + "streaming", + "youtube", + "twitch", + "podcast", + "comic", + "anime", + "manga", + ] if any(kw in question_lower for kw in entertainment_keywords): return "entertainment" - + return "other" - + def _build_polymarket_url(self, slug: Optional[str], market_id: str) -> str: """ Build Polymarket URL based on slug Reference: https://docs.polymarket.com/market-data/fetching-markets - + Args: slug: slug obtained from API or database (may be None or numeric string) market_id: Market ID (as an alternative) - + Returns: Polymarket URL string """ if slug: slug_str = str(slug).strip() # Check if the slug is valid (not a number and contains letters or hyphens) - if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)): + if slug_str and not slug_str.isdigit() and ("-" in slug_str or any(c.isalpha() for c in slug_str)): import re - slug_clean = re.sub(r'[^a-zA-Z0-9\-]', '-', slug_str) - slug_clean = slug_clean.strip('-') + + slug_clean = re.sub(r"[^a-zA-Z0-9\-]", "-", slug_str) + slug_clean = slug_clean.strip("-") if slug_clean: return f"https://polymarket.com/event/{slug_clean}" - + # If there is no valid slug, try to obtain the slug through the API if market_id: try: detail_market = self._fetch_market_detail_by_id(market_id) if detail_market: # Try to get the slug from detail - event_slug = detail_market.get('slug') + event_slug = detail_market.get("slug") if event_slug: slug_str = str(event_slug).strip() - if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)): + if ( + slug_str + and not slug_str.isdigit() + and ("-" in slug_str or any(c.isalpha() for c in slug_str)) + ): import re - slug_clean = re.sub(r'[^a-zA-Z0-9\-]', '-', slug_str) - slug_clean = slug_clean.strip('-') + + slug_clean = re.sub(r"[^a-zA-Z0-9\-]", "-", slug_str) + slug_clean = slug_clean.strip("-") if slug_clean: return f"https://polymarket.com/event/{slug_clean}" - + # If the event does not have a slug, try to get it from markets - markets = detail_market.get('markets', []) + markets = detail_market.get("markets", []) if markets: for m in markets: - market_slug = m.get('slug') + market_slug = m.get("slug") if market_slug: slug_str = str(market_slug).strip() - if slug_str and not slug_str.isdigit() and ('-' in slug_str or any(c.isalpha() for c in slug_str)): + if ( + slug_str + and not slug_str.isdigit() + and ("-" in slug_str or any(c.isalpha() for c in slug_str)) + ): import re - slug_clean = re.sub(r'[^a-zA-Z0-9\-]', '-', slug_str) - slug_clean = slug_clean.strip('-') + + slug_clean = re.sub(r"[^a-zA-Z0-9\-]", "-", slug_str) + slug_clean = slug_clean.strip("-") if slug_clean: return f"https://polymarket.com/event/{slug_clean}" except Exception as e: logger.debug(f"Failed to fetch slug for market {market_id}: {e}") - + # If all else fails, return to the search page (more reliable) # Note: Polymarket's URL format may have changed, use search as fallback return f"https://polymarket.com/search?q={market_id}" - + def _fetch_market_detail_by_id(self, market_id: str) -> Optional[Dict]: """ Get market details from API by market ID (used to get slug) @@ -991,7 +1243,7 @@ class PolymarketDataSource: url = f"{self.gamma_api}/events" params = {"active": "true", "closed": "false", "limit": 100} response = self.session.get(url, params=params, timeout=10) - + if response.status_code == 200: events = response.json() if isinstance(events, list): @@ -999,7 +1251,7 @@ class PolymarketDataSource: markets = event.get("markets", []) if not markets and ("question" in event or "slug" in event): markets = [event] - + for market in markets: m_id = market.get("id") or market.get("slug") or "" e_id = event.get("id") or event.get("slug") or "" @@ -1014,30 +1266,30 @@ class PolymarketDataSource: markets = event.get("markets", []) if not markets and ("question" in event or "slug" in event): markets = [event] - + for market in markets: m_id = market.get("id") or market.get("slug") or "" e_id = event.get("id") or event.get("slug") or "" if str(m_id) == str(market_id) or str(e_id) == str(market_id): return event - + # Method 2: Try querying through the markets endpoint url = f"{self.gamma_api}/markets" params = {"id": market_id, "limit": 1} response = self.session.get(url, params=params, timeout=10) - + if response.status_code == 200: data = response.json() if isinstance(data, list) and len(data) > 0: return data[0] elif isinstance(data, dict) and "id" in data: return data - + return None except Exception as e: logger.debug(f"Failed to fetch market detail by ID {market_id}: {e}") return None - + def _fetch_market_by_slug(self, slug: str) -> Optional[Dict]: """ Query the market directly through slug (the most efficient way) @@ -1050,7 +1302,7 @@ class PolymarketDataSource: params = {"slug": slug, "limit": 10} logger.info(f"Fetching market by slug from Gamma API: {url} with params: {params}") response = self.session.get(url, params=params, timeout=10) - + if response.status_code == 200: data = response.json() if isinstance(data, list) and len(data) > 0: @@ -1072,16 +1324,16 @@ class PolymarketDataSource: if markets: logger.info(f"Found market by slug: {slug}") return markets[0] - + # Method 2: Try to query through the events endpoint (events may contain slug information) url = f"{self.gamma_api}/events" params = {"active": "true", "closed": "false", "limit": 100} response = self.session.get(url, params=params, timeout=10) - + if response.status_code == 200: data = response.json() events = data if isinstance(data, list) else (data.get("data", []) if isinstance(data, dict) else []) - + # Find the matching slug in the returned event for event in events: event_slug = (event.get("slug") or "").lower() @@ -1090,14 +1342,14 @@ class PolymarketDataSource: if parsed: logger.info(f"Found market by slug via events: {slug}") return parsed[0] - + logger.warning(f"Market with slug '{slug}' not found via direct query") return None - + except Exception as e: logger.error(f"Failed to fetch market by slug {slug}: {e}", exc_info=True) return None - + def _fetch_market_from_api(self, market_id: str) -> Optional[Dict]: """ Get individual market data from Gamma API @@ -1105,19 +1357,19 @@ class PolymarketDataSource: """ try: # Determine whether it is slug or market_id - is_slug = not market_id.isdigit() and ('-' in market_id or any(c.isalpha() for c in market_id)) - + is_slug = not market_id.isdigit() and ("-" in market_id or any(c.isalpha() for c in market_id)) + # If it is a slug, the direct query method is preferred. if is_slug: market = self._fetch_market_by_slug(market_id) if market: return market - + # Method 1: Query through markets endpoint (supports id and slug) url = f"{self.gamma_api}/markets" params = {"id": market_id, "limit": 10} if not is_slug else {"slug": market_id, "limit": 10} response = self.session.get(url, params=params, timeout=10) - + if response.status_code == 200: data = response.json() if isinstance(data, list) and len(data) > 0: @@ -1128,39 +1380,35 @@ class PolymarketDataSource: markets = self._parse_gamma_events([data]) if markets: return markets[0] - + # Method 2: Search via events endpoint (as an alternative) url = f"{self.gamma_api}/events" - params = { - "active": "true", - "closed": "false", - "limit": 100 - } + params = {"active": "true", "closed": "false", "limit": 100} response = self.session.get(url, params=params, timeout=10) - + if response.status_code == 200: data = response.json() events = data if isinstance(data, list) else (data.get("data", []) if isinstance(data, dict) else []) - + # Find matching markets in returned events for event in events: markets = event.get("markets", []) if not markets: markets = [event] - + for market in markets: m_id = market.get("id") or market.get("slug") or event.get("id") or event.get("slug", "") if str(m_id) == str(market_id) or market.get("slug") == market_id: parsed = self._parse_gamma_events([event]) if parsed: return parsed[0] - + return None - + except Exception as e: logger.error(f"Failed to fetch market {market_id}: {e}", exc_info=True) return None - + def _save_markets_to_db(self, markets: List[Dict]): """Save market data to database""" try: @@ -1168,20 +1416,22 @@ class PolymarketDataSource: cur = db.cursor() for market in markets: # Get the slug, but don't use it if it's a number (numbers are not valid slugs) - slug = market.get('slug') or None + slug = market.get("slug") or None # If the slug is a number, it means it is not a valid slug and is set to None. if slug and str(slug).isdigit(): slug = None # Clean slug, keep only alphanumerics and hyphens import re + if slug: - slug = re.sub(r'[^a-zA-Z0-9\-]', '-', str(slug)) - slug = slug.strip('-') + slug = re.sub(r"[^a-zA-Z0-9\-]", "-", str(slug)) + slug = slug.strip("-") # If it is empty or still a number after cleaning, set to None if not slug or slug.isdigit(): slug = None - - cur.execute(""" + + cur.execute( + """ INSERT INTO qd_polymarket_markets (market_id, question, category, current_probability, volume_24h, liquidity, end_date_iso, status, outcome_tokens, slug, updated_at) @@ -1197,23 +1447,25 @@ class PolymarketDataSource: outcome_tokens = EXCLUDED.outcome_tokens, slug = EXCLUDED.slug, updated_at = NOW() - """, ( - market.get('market_id'), - market.get('question'), - market.get('category', 'other'), - market.get('current_probability', 50.0), - market.get('volume_24h', 0), - market.get('liquidity', 0), - market.get('end_date_iso'), - market.get('status', 'active'), - json.dumps(market.get('outcome_tokens', {})), - slug - )) + """, + ( + market.get("market_id"), + market.get("question"), + market.get("category", "other"), + market.get("current_probability", 50.0), + market.get("volume_24h", 0), + market.get("liquidity", 0), + market.get("end_date_iso"), + market.get("status", "active"), + json.dumps(market.get("outcome_tokens", {})), + slug, + ), + ) db.commit() cur.close() except Exception as e: logger.error(f"Failed to save markets to DB: {type(e).__name__}: {e}", exc_info=True) - + def _get_sample_markets(self, category: str = None, limit: int = 50) -> List[Dict]: """ Get sample market data (deprecated) diff --git a/backend_api_python/app/data_sources/rate_limiter.py b/backend_api_python/app/data_sources/rate_limiter.py index 0183b22..3c185b0 100644 --- a/backend_api_python/app/data_sources/rate_limiter.py +++ b/backend_api_python/app/data_sources/rate_limiter.py @@ -12,11 +12,11 @@ Provide anti-crawler strategies: 4. Request frequency limit """ -import time -import random import logging -from typing import Optional, Callable, Any, Type, Tuple +import random +import time from functools import wraps +from typing import Any, Callable, Optional, Tuple, Type logger = logging.getLogger(__name__) @@ -27,23 +27,23 @@ logger = logging.getLogger(__name__) USER_AGENTS = [ # Chrome Windows - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", # Chrome Mac - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", # Firefox - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0', + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0", # Safari - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15', + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", # Edge - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0", # Linux Chrome - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", ] @@ -55,24 +55,24 @@ def get_random_user_agent() -> str: def get_request_headers(referer: Optional[str] = None) -> dict: """ Get request header with random User-Agent - + Args: referer: optional Referer header - + Returns: Request header dictionary """ headers = { - 'User-Agent': get_random_user_agent(), - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', - 'Accept-Encoding': 'gzip, deflate', - 'Connection': 'keep-alive', + "User-Agent": get_random_user_agent(), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", } - + if referer: - headers['Referer'] = referer - + headers["Referer"] = referer + return headers @@ -80,17 +80,14 @@ def get_request_headers(referer: Optional[str] = None) -> dict: # Random sleep # ============================================ -def random_sleep( - min_seconds: float = 1.0, - max_seconds: float = 3.0, - log: bool = False -) -> None: + +def random_sleep(min_seconds: float = 1.0, max_seconds: float = 3.0, log: bool = False) -> None: """ Random sleep (Jitter) - + Anti-ban strategy: simulate random delays in human behavior Incorporate irregular wait times between requests - + Args: min_seconds: Minimum sleep time (seconds) max_seconds: Maximum sleep time (seconds) @@ -106,22 +103,18 @@ def random_sleep( # Request frequency limiter # ============================================ + class RateLimiter: """ Request frequency limiter - + Ensure there is a minimum amount of time between requests """ - - def __init__( - self, - min_interval: float = 1.0, - jitter_min: float = 0.5, - jitter_max: float = 1.5 - ): + + def __init__(self, min_interval: float = 1.0, jitter_min: float = 0.5, jitter_max: float = 1.5): """ Initialize frequency limiter - + Args: min_interval: Minimum request interval (seconds) jitter_min: minimum random jitter (seconds) @@ -131,33 +124,33 @@ class RateLimiter: self.jitter_min = jitter_min self.jitter_max = jitter_max self._last_request_time: Optional[float] = None - + def wait(self) -> float: """ Wait until the next request can be made - + Returns: Actual waiting time (seconds) """ wait_time = 0.0 - + if self._last_request_time is not None: elapsed = time.time() - self._last_request_time if elapsed < self.min_interval: # Supplement sleep to minimum interval wait_time = self.min_interval - elapsed time.sleep(wait_time) - + # Add random jitter jitter = random.uniform(self.jitter_min, self.jitter_max) time.sleep(jitter) wait_time += jitter - + # Record the time of this request self._last_request_time = time.time() - + return wait_time - + def reset(self) -> None: """reset limiter""" self._last_request_time = None @@ -167,17 +160,18 @@ class RateLimiter: # Exponential backoff retry decorator # ============================================ + def retry_with_backoff( max_attempts: int = 3, base_delay: float = 2.0, max_delay: float = 30.0, exponential_base: float = 2.0, exceptions: Tuple[Type[Exception], ...] = (Exception,), - on_retry: Optional[Callable[[int, Exception], None]] = None + on_retry: Optional[Callable[[int, Exception], None]] = None, ): """ Exponential backoff retry decorator - + Args: max_attempts: Maximum number of retries base_delay: base delay time (seconds) @@ -185,49 +179,50 @@ def retry_with_backoff( exponential_base: exponential base exceptions: Exception types that need to be retried on_retry: callback function when retrying - + Usage example: @retry_with_backoff(max_attempts=3, exceptions=(ConnectionError, TimeoutError)) def fetch_data(): ... """ + def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs) -> Any: last_exception = None - + for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except exceptions as e: last_exception = e - + if attempt == max_attempts: - logger.error(f"[Retry] {func.__name__} has reached the maximum number of retries ({max_attempts}), giving up") + logger.error( + f"[Retry] {func.__name__} has reached the maximum number of retries ({max_attempts}), giving up" + ) raise - + # Calculate the backoff delay: base_delay * (exponential_base ^ (attempt - 1)) - delay = min( - base_delay * (exponential_base ** (attempt - 1)), - max_delay - ) + delay = min(base_delay * (exponential_base ** (attempt - 1)), max_delay) # Add random jitter (±20%) delay *= random.uniform(0.8, 1.2) - + logger.warning( f"[Retry] {func.__name__} failed for the {attempt}/{max_attempts} time: {e}, " f"waiting {delay:.1f}s before retrying..." ) - + if on_retry: on_retry(attempt, e) - + time.sleep(delay) - + # Shouldn't have gotten here raise last_exception - + return wrapper + return decorator @@ -236,25 +231,13 @@ def retry_with_backoff( # ============================================ # Oriental Fortune interface current limiter (more stringent) -_eastmoney_limiter = RateLimiter( - min_interval=2.0, - jitter_min=1.0, - jitter_max=3.0 -) +_eastmoney_limiter = RateLimiter(min_interval=2.0, jitter_min=1.0, jitter_max=3.0) # Tencent Finance interface current limiter (relatively loose) -_tencent_limiter = RateLimiter( - min_interval=1.0, - jitter_min=0.5, - jitter_max=1.5 -) +_tencent_limiter = RateLimiter(min_interval=1.0, jitter_min=0.5, jitter_max=1.5) # Akshare interface current limiter -_akshare_limiter = RateLimiter( - min_interval=2.0, - jitter_min=1.5, - jitter_max=3.5 -) +_akshare_limiter = RateLimiter(min_interval=2.0, jitter_min=1.5, jitter_max=3.5) def get_eastmoney_limiter() -> RateLimiter: diff --git a/backend_api_python/app/data_sources/tencent.py b/backend_api_python/app/data_sources/tencent.py index f50c006..ad7abdf 100644 --- a/backend_api_python/app/data_sources/tencent.py +++ b/backend_api_python/app/data_sources/tencent.py @@ -11,61 +11,16 @@ This is used as a stable alternative when Yahoo/yfinance gets rate-limited. from __future__ import annotations from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional import requests -from app.data_sources.rate_limiter import get_request_headers, retry_with_backoff, get_tencent_limiter +from app.data_sources.rate_limiter import get_request_headers, get_tencent_limiter, retry_with_backoff from app.utils.logger import get_logger logger = get_logger(__name__) -def normalize_cn_code(symbol: str) -> str: - """ - Normalize A-share symbol to Tencent code: sh600519 / sz000001. - Accepts: - - 600519 / 600519.SH / 600519.SS - - 000001 / 000001.SZ - """ - s = (symbol or "").strip().upper() - if not s: - return s - if s.endswith(".SH"): - s = s[:-3] - return f"SH{s}" - if s.endswith(".SS"): - s = s[:-3] - return f"SH{s}" - if s.endswith(".SZ"): - s = s[:-3] - return f"SZ{s}" - - if s.isdigit() and len(s) == 6: - return ("SH" + s) if s.startswith("6") else ("SZ" + s) - - return s - - -def normalize_hk_code(symbol: str) -> str: - """ - Normalize HK stock symbol to Tencent code: hk00700 (5 digits). - Accepts: - - 700 / 0700 / 00700.HK / 0700.HK - """ - s = (symbol or "").strip().upper() - if not s: - return s - if s.endswith(".HK"): - s = s[:-3] - if s.isdigit(): - return "HK" + s.zfill(5) - # If user already passed HKxxxxx - if s.startswith("HK") and s[2:].isdigit(): - return "HK" + s[2:].zfill(5) - return s - - def _lower_code(code: str) -> str: return (code or "").strip().lower() @@ -109,6 +64,7 @@ def parse_quote_to_ticker(parts: List[str]) -> Dict[str, Any]: """ Best-effort conversion to a unified ticker dict. """ + def _f(i: int, default: float = 0.0) -> float: try: v = parts[i] @@ -198,7 +154,7 @@ def fetch_kline(code: str, period: str, count: int = 300, adj: str = "qfq", time period examples: - day, week, month (supported by Tencent fqkline) - Note: Minute periods (m1/m5/…) return **bad params** on this endpoint; use AkShare in ``asia_stock_kline``. + Note: Minute periods (m1/m5/…) return **bad params** on this endpoint. """ c = _lower_code(code) if not c: @@ -235,4 +191,3 @@ def fetch_kline(code: str, period: str, count: int = 300, adj: str = "qfq", time if isinstance(v, list) and v and str(k).lower().endswith(str(period).lower()): return v return [] - diff --git a/backend_api_python/app/data_sources/us_stock.py b/backend_api_python/app/data_sources/us_stock.py index 499dbdf..170f42b 100644 --- a/backend_api_python/app/data_sources/us_stock.py +++ b/backend_api_python/app/data_sources/us_stock.py @@ -2,64 +2,57 @@ US stock data source Get data using yfinance and finnhub """ -from typing import Dict, List, Any, Optional + from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional import yfinance as yf +from app.config import APIKeys from app.data_sources.base import BaseDataSource from app.utils.logger import get_logger -from app.config import APIKeys, YFinanceConfig logger = get_logger(__name__) class USStockDataSource(BaseDataSource): """US stock data source""" - + name = "USStock/yfinance" - + # yfinance time period mapping - INTERVAL_MAP = { - '1m': '1m', - '5m': '5m', - '15m': '15m', - '30m': '30m', - '1H': '1h', - '4H': '4h', - '1D': '1d', - '1W': '1wk' - } - + INTERVAL_MAP = {"1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m", "1H": "1h", "4H": "4h", "1D": "1d", "1W": "1wk"} + # The range of days to obtain data in different periods DAYS_MAP = { - '1m': lambda limit: min(7, max(1, (limit // 390) + 2)), - '5m': lambda limit: min(60, max(1, (limit // 78) + 2)), - '15m': lambda limit: min(60, max(1, (limit // 26) + 2)), - '30m': lambda limit: min(60, max(1, (limit // 13) + 2)), - '1H': lambda limit: min(730, max(1, (limit // 24) + 2)), - '4H': lambda limit: min(730, max(1, (limit // 6) + 2)), - '1D': lambda limit: min(3650, limit + 1), - '1W': lambda limit: min(3650, (limit * 7) + 7) + "1m": lambda limit: min(7, max(1, (limit // 390) + 2)), + "5m": lambda limit: min(60, max(1, (limit // 78) + 2)), + "15m": lambda limit: min(60, max(1, (limit // 26) + 2)), + "30m": lambda limit: min(60, max(1, (limit // 13) + 2)), + "1H": lambda limit: min(730, max(1, (limit // 24) + 2)), + "4H": lambda limit: min(730, max(1, (limit // 6) + 2)), + "1D": lambda limit: min(3650, limit + 1), + "1W": lambda limit: min(3650, (limit * 7) + 7), } - + def __init__(self): # Initialize finnhub as an alternative self.finnhub_client = None try: import finnhub - if APIKeys.is_configured('FINNHUB_API_KEY'): + + if APIKeys.is_configured("FINNHUB_API_KEY"): self.finnhub_client = finnhub.Client(api_key=APIKeys.FINNHUB_API_KEY) logger.info("Finnhub client initialized") except Exception as e: logger.warning(f"Finnhub init failed: {e}") - + def get_ticker(self, symbol: str) -> Dict[str, Any]: """ Get realtime quotes for U.S. stocks - + Use Finnhub first (more real-time), downgrade to yfinance fast_info - + Returns: dict: { 'last': current price, @@ -71,21 +64,21 @@ class USStockDataSource(BaseDataSource): 'previousClose': yesterday's closing price } """ - symbol = (symbol or '').strip().upper() - + symbol = (symbol or "").strip().upper() + # Prefer using Finnhub (live data) if self.finnhub_client: try: quote = self.finnhub_client.quote(symbol) - if quote and quote.get('c'): + if quote and quote.get("c"): return { - 'last': quote.get('c', 0), # current price - 'change': quote.get('d', 0), # Changes - 'changePercent': quote.get('dp', 0), # Increase or decrease - 'high': quote.get('h', 0), # Best in Japan - 'low': quote.get('l', 0), # Lowest within the day - 'open': quote.get('o', 0), # opening price - 'previousClose': quote.get('pc', 0) # Yesterday's closing price + "last": quote.get("c", 0), # current price + "change": quote.get("d", 0), # Changes + "changePercent": quote.get("dp", 0), # Increase or decrease + "high": quote.get("h", 0), # Best in Japan + "low": quote.get("l", 0), # Lowest within the day + "open": quote.get("o", 0), # opening price + "previousClose": quote.get("pc", 0), # Yesterday's closing price } except Exception as e: msg = str(e).lower() @@ -93,94 +86,94 @@ class USStockDataSource(BaseDataSource): logger.debug(f"Finnhub quote skipped (no access): {symbol}: {e}") else: logger.warning(f"Finnhub quote failed for {symbol}: {e}") - + # Downgrade to use yfinance try: ticker = yf.Ticker(symbol) - + # Try fast_info (faster) try: fast_info = ticker.fast_info - last_price = fast_info.get('lastPrice') or fast_info.get('last_price') - prev_close = fast_info.get('previousClose') or fast_info.get('previous_close') or fast_info.get('regularMarketPreviousClose') - + last_price = fast_info.get("lastPrice") or fast_info.get("last_price") + prev_close = ( + fast_info.get("previousClose") + or fast_info.get("previous_close") + or fast_info.get("regularMarketPreviousClose") + ) + if last_price: change = (last_price - prev_close) if prev_close else 0 change_pct = (change / prev_close * 100) if prev_close else 0 return { - 'last': float(last_price), - 'change': round(change, 4), - 'changePercent': round(change_pct, 2), - 'high': float(fast_info.get('dayHigh') or fast_info.get('day_high') or last_price), - 'low': float(fast_info.get('dayLow') or fast_info.get('day_low') or last_price), - 'open': float(fast_info.get('open') or fast_info.get('regularMarketOpen') or last_price), - 'previousClose': float(prev_close) if prev_close else 0 + "last": float(last_price), + "change": round(change, 4), + "changePercent": round(change_pct, 2), + "high": float(fast_info.get("dayHigh") or fast_info.get("day_high") or last_price), + "low": float(fast_info.get("dayLow") or fast_info.get("day_low") or last_price), + "open": float(fast_info.get("open") or fast_info.get("regularMarketOpen") or last_price), + "previousClose": float(prev_close) if prev_close else 0, } except Exception as e: logger.debug(f"yfinance fast_info failed for {symbol}: {e}") - + # Downgrade to use info (slower but more complete data) try: info = ticker.info - last_price = info.get('regularMarketPrice') or info.get('currentPrice') - prev_close = info.get('regularMarketPreviousClose') or info.get('previousClose') - + last_price = info.get("regularMarketPrice") or info.get("currentPrice") + prev_close = info.get("regularMarketPreviousClose") or info.get("previousClose") + if last_price: change = (last_price - prev_close) if prev_close else 0 change_pct = (change / prev_close * 100) if prev_close else 0 return { - 'last': float(last_price), - 'change': round(change, 4), - 'changePercent': round(change_pct, 2), - 'high': float(info.get('regularMarketDayHigh') or info.get('dayHigh') or last_price), - 'low': float(info.get('regularMarketDayLow') or info.get('dayLow') or last_price), - 'open': float(info.get('regularMarketOpen') or info.get('open') or last_price), - 'previousClose': float(prev_close) if prev_close else 0 + "last": float(last_price), + "change": round(change, 4), + "changePercent": round(change_pct, 2), + "high": float(info.get("regularMarketDayHigh") or info.get("dayHigh") or last_price), + "low": float(info.get("regularMarketDayLow") or info.get("dayLow") or last_price), + "open": float(info.get("regularMarketOpen") or info.get("open") or last_price), + "previousClose": float(prev_close) if prev_close else 0, } except Exception as e: logger.debug(f"yfinance info failed for {symbol}: {e}") - + # Last downgrade: use the most recent 1-minute K-line try: - hist = ticker.history(period='1d', interval='1m') + hist = ticker.history(period="1d", interval="1m") if hist is not None and not hist.empty: last_row = hist.iloc[-1] first_row = hist.iloc[0] - last_price = float(last_row['Close']) - open_price = float(first_row['Open']) - + last_price = float(last_row["Close"]) + open_price = float(first_row["Open"]) + return { - 'last': last_price, - 'change': round(last_price - open_price, 4), - 'changePercent': round((last_price - open_price) / open_price * 100, 2) if open_price else 0, - 'high': float(hist['High'].max()), - 'low': float(hist['Low'].min()), - 'open': open_price, - 'previousClose': open_price # approximate + "last": last_price, + "change": round(last_price - open_price, 4), + "changePercent": round((last_price - open_price) / open_price * 100, 2) if open_price else 0, + "high": float(hist["High"].max()), + "low": float(hist["Low"].min()), + "open": open_price, + "previousClose": open_price, # approximate } except Exception as e: logger.debug(f"yfinance history fallback failed for {symbol}: {e}") - + except Exception as e: logger.error(f"Failed to get ticker for {symbol}: {e}") - - return {'last': 0, 'symbol': symbol} - + + return {"last": 0, "symbol": symbol} + def get_kline( - self, - symbol: str, - timeframe: str, - limit: int, - before_time: Optional[int] = None + self, symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None ) -> List[Dict[str, Any]]: """Get U.S. stock K-line data""" klines = [] - + try: - interval = self.INTERVAL_MAP.get(timeframe, '1d') + interval = self.INTERVAL_MAP.get(timeframe, "1d") days_func = self.DAYS_MAP.get(timeframe, lambda x: x + 1) days = days_func(limit) - + # Calculate date range if before_time: end_date = datetime.fromtimestamp(before_time) @@ -188,80 +181,75 @@ class USStockDataSource(BaseDataSource): else: end_date = datetime.now() start_date = end_date - timedelta(days=days) - + # logger.info(f"Use yfinance to get {symbol}, period: {interval}, date: {start_date.date()} ~ {end_date.date()}") - + # Try yfinance df = self._fetch_yfinance(symbol, interval, start_date, end_date) - + if df is None or df.empty: # try finnhub - if self.finnhub_client and timeframe == '1D': + if self.finnhub_client and timeframe == "1D": klines = self._fetch_finnhub(symbol, start_date, end_date, limit) if klines: return klines else: klines = self._convert_dataframe(df, limit) - + # Filter and restrict klines = self.filter_and_limit(klines, limit, before_time) - + # Record results self.log_result(symbol, klines, timeframe) - + except Exception as e: logger.error(f"Failed to fetch US stock K-lines {symbol}: {str(e)}") import traceback + logger.error(traceback.format_exc()) - + return klines - + def _fetch_yfinance(self, symbol: str, interval: str, start_date: datetime, end_date: datetime): """Use yfinance to get data""" try: ticker = yf.Ticker(symbol) - + # The end parameter of yfinance is not included (exclusive), so you need to add one day to include the end_date data of the current day. # For example: end="2026-01-12" actually only returns the data of 2026-01-11 end_date_inclusive = end_date + timedelta(days=1) - + df = ticker.history( - start=start_date.strftime('%Y-%m-%d'), - end=end_date_inclusive.strftime('%Y-%m-%d'), - interval=interval + start=start_date.strftime("%Y-%m-%d"), end=end_date_inclusive.strftime("%Y-%m-%d"), interval=interval ) # logger.info(f"yfinance returns {len(df) if df is not None and not df.empty else 0} pieces of data") return df except Exception as e: logger.warning(f"yfinance fetch failed: {e}") return None - - def _fetch_finnhub( - self, - symbol: str, - start_date: datetime, - end_date: datetime, - limit: int - ) -> List[Dict[str, Any]]: + + def _fetch_finnhub(self, symbol: str, start_date: datetime, end_date: datetime, limit: int) -> List[Dict[str, Any]]: """Use finnhub to get daily data""" klines = [] try: start_ts = int(start_date.timestamp()) end_ts = int(end_date.timestamp()) - + # logger.info(f"Use Finnhub to obtain {symbol} daily data") - candles = self.finnhub_client.stock_candles(symbol, 'D', start_ts, end_ts) - - if candles and candles.get('s') == 'ok': - for i in range(len(candles['t'])): - klines.append(self.format_kline( - timestamp=candles['t'][i], - open_price=candles['o'][i], - high=candles['h'][i], - low=candles['l'][i], - close=candles['c'][i], - volume=candles['v'][i] - )) + candles = self.finnhub_client.stock_candles(symbol, "D", start_ts, end_ts) + + if candles and candles.get("s") == "ok": + for i in range(len(candles["t"])): + klines.append( + self.format_kline( + timestamp=candles["t"][i], + open_price=candles["o"][i], + high=candles["h"][i], + low=candles["l"][i], + close=candles["c"][i], + volume=candles["v"][i], + ) + ) # logger.info(f"Finnhub returns {len(klines)} pieces of data") except Exception as e: msg = str(e).lower() @@ -270,47 +258,48 @@ class USStockDataSource(BaseDataSource): logger.debug(f"Finnhub candles skipped (no access): {symbol}: {e}") else: logger.warning(f"Finnhub fetch failed: {e}") - + return klines - + def _convert_dataframe(self, df, limit: int) -> List[Dict[str, Any]]: """Convert DataFrame to K-line list""" klines = [] df = df.tail(limit).reset_index() - + # Determine the time column name (the daily line is Date, the minute level is Datetime) time_col = None - if 'Datetime' in df.columns: - time_col = 'Datetime' - elif 'Date' in df.columns: - time_col = 'Date' - elif 'index' in df.columns: - time_col = 'index' - + if "Datetime" in df.columns: + time_col = "Datetime" + elif "Date" in df.columns: + time_col = "Date" + elif "index" in df.columns: + time_col = "index" + if time_col is None: logger.warning(f"Unable to determine time column; available columns: {df.columns.tolist()}") return klines - + for _, row in df.iterrows(): try: # Processing timestamps time_value = row[time_col] - if hasattr(time_value, 'timestamp'): + if hasattr(time_value, "timestamp"): ts = int(time_value.timestamp()) else: continue - - klines.append(self.format_kline( - timestamp=ts, - open_price=row['Open'], - high=row['High'], - low=row['Low'], - close=row['Close'], - volume=row['Volume'] - )) + + klines.append( + self.format_kline( + timestamp=ts, + open_price=row["Open"], + high=row["High"], + low=row["Low"], + close=row["Close"], + volume=row["Volume"], + ) + ) except Exception as e: logger.debug(f"Failed to parse row data: {e}") continue - - return klines + return klines diff --git a/backend_api_python/app/routes/__init__.py b/backend_api_python/app/routes/__init__.py index 5098199..12a903a 100644 --- a/backend_api_python/app/routes/__init__.py +++ b/backend_api_python/app/routes/__init__.py @@ -1,51 +1,52 @@ """ API Routes Module """ + from flask import Flask def register_routes(app: Flask): """Register all API route blueprints""" - from app.routes.kline import kline_bp - from app.routes.backtest import backtest_bp - from app.routes.health import health_bp - from app.routes.market import market_bp - from app.routes.strategy import strategy_bp - from app.routes.credentials import credentials_bp - from app.routes.auth import auth_bp from app.routes.ai_chat import ai_chat_bp - 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 - from app.routes.ibkr import ibkr_bp - from app.routes.mt5 import mt5_bp - from app.routes.user import user_bp - from app.routes.global_market import global_market_bp - from app.routes.community import community_bp - from app.routes.fast_analysis import fast_analysis_bp + from app.routes.auth import auth_bp + from app.routes.backtest import backtest_bp from app.routes.billing import billing_bp - from app.routes.quick_trade import quick_trade_bp + from app.routes.community import community_bp + from app.routes.credentials import credentials_bp + from app.routes.dashboard import dashboard_bp + from app.routes.fast_analysis import fast_analysis_bp + from app.routes.global_market import global_market_bp + from app.routes.health import health_bp + from app.routes.ibkr import ibkr_bp + from app.routes.indicator import indicator_bp + from app.routes.kline import kline_bp + from app.routes.market import market_bp + from app.routes.mt5 import mt5_bp from app.routes.polymarket import polymarket_bp - + from app.routes.portfolio import portfolio_bp + from app.routes.quick_trade import quick_trade_bp + from app.routes.settings import settings_bp + from app.routes.strategy import strategy_bp + from app.routes.user import user_bp + app.register_blueprint(health_bp) - app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes - app.register_blueprint(user_bp, url_prefix='/api/users') # User management - app.register_blueprint(kline_bp, url_prefix='/api/indicator') - app.register_blueprint(backtest_bp, url_prefix='/api/indicator') - app.register_blueprint(market_bp, url_prefix='/api/market') - app.register_blueprint(ai_chat_bp, url_prefix='/api/ai') - app.register_blueprint(indicator_bp, url_prefix='/api/indicator') - app.register_blueprint(strategy_bp, url_prefix='/api') - 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') - app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr') - app.register_blueprint(mt5_bp, url_prefix='/api/mt5') - app.register_blueprint(global_market_bp, url_prefix='/api/global-market') - app.register_blueprint(community_bp, url_prefix='/api/community') - app.register_blueprint(fast_analysis_bp, url_prefix='/api/fast-analysis') - app.register_blueprint(billing_bp, url_prefix='/api/billing') - app.register_blueprint(quick_trade_bp, url_prefix='/api/quick-trade') - app.register_blueprint(polymarket_bp, url_prefix='/api/polymarket') \ No newline at end of file + app.register_blueprint(auth_bp, url_prefix="/api/auth") # Auth routes + app.register_blueprint(user_bp, url_prefix="/api/users") # User management + app.register_blueprint(kline_bp, url_prefix="/api/indicator") + app.register_blueprint(backtest_bp, url_prefix="/api/indicator") + app.register_blueprint(market_bp, url_prefix="/api/market") + app.register_blueprint(ai_chat_bp, url_prefix="/api/ai") + app.register_blueprint(indicator_bp, url_prefix="/api/indicator") + app.register_blueprint(strategy_bp, url_prefix="/api") + 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") + app.register_blueprint(ibkr_bp, url_prefix="/api/ibkr") + app.register_blueprint(mt5_bp, url_prefix="/api/mt5") + app.register_blueprint(global_market_bp, url_prefix="/api/global-market") + app.register_blueprint(community_bp, url_prefix="/api/community") + app.register_blueprint(fast_analysis_bp, url_prefix="/api/fast-analysis") + app.register_blueprint(billing_bp, url_prefix="/api/billing") + app.register_blueprint(quick_trade_bp, url_prefix="/api/quick-trade") + app.register_blueprint(polymarket_bp, url_prefix="/api/polymarket") diff --git a/backend_api_python/app/routes/ai_chat.py b/backend_api_python/app/routes/ai_chat.py index 14831eb..38e567d 100644 --- a/backend_api_python/app/routes/ai_chat.py +++ b/backend_api_python/app/routes/ai_chat.py @@ -3,44 +3,41 @@ AI chat API routes (optional). Currently kept as a minimal compatibility layer for legacy frontend calls. """ -from flask import Blueprint, request, jsonify +from flask import Blueprint, jsonify, request from app.utils.logger import get_logger logger = get_logger(__name__) -ai_chat_bp = Blueprint('ai_chat', __name__) +ai_chat_bp = Blueprint("ai_chat", __name__) -@ai_chat_bp.route('/chat/message', methods=['POST']) +@ai_chat_bp.route("/chat/message", methods=["POST"]) def chat_message(): """ Minimal placeholder for legacy chat. Return a friendly message instead of 404, so the UI can evolve gradually. """ data = request.get_json() or {} - msg = (data.get('message') or '').strip() + msg = (data.get("message") or "").strip() if not msg: - return jsonify({'code': 0, 'msg': 'Missing message', 'data': None}), 400 - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'reply': 'Chat API is not implemented yet in local-only mode.', - 'echo': msg + return jsonify({"code": 0, "msg": "Missing message", "data": None}), 400 + return jsonify( + { + "code": 1, + "msg": "success", + "data": {"reply": "Chat API is not implemented yet in local-only mode.", "echo": msg}, } - }) + ) -@ai_chat_bp.route('/chat/history', methods=['GET']) +@ai_chat_bp.route("/chat/history", methods=["GET"]) def get_chat_history(): """Return empty history (compatibility stub).""" - return jsonify({'code': 1, 'msg': 'success', 'data': []}) + return jsonify({"code": 1, "msg": "success", "data": []}) -@ai_chat_bp.route('/chat/history/save', methods=['POST']) +@ai_chat_bp.route("/chat/history/save", methods=["POST"]) def save_chat_history(): """No-op save (compatibility stub).""" - return jsonify({'code': 1, 'msg': 'success', 'data': None}) - - + return jsonify({"code": 1, "msg": "success", "data": None}) diff --git a/backend_api_python/app/routes/auth.py b/backend_api_python/app/routes/auth.py index 5c9e384..c712b1a 100644 --- a/backend_api_python/app/routes/auth.py +++ b/backend_api_python/app/routes/auth.py @@ -4,16 +4,20 @@ Authentication API Routes Handles login, logout, registration, password reset, and OAuth authentication. Supports both multi-user (database) and single-user (legacy) modes. """ + import os -from flask import Blueprint, request, jsonify, g, redirect from urllib.parse import urlencode + +from flask import Blueprint, g, jsonify, redirect, request + from app.config.settings import Config -from app.utils.auth import generate_token, login_required, authenticate_legacy +from app.utils.auth import authenticate_legacy, generate_token, login_required from app.utils.logger import get_logger logger = get_logger(__name__) -auth_bp = Blueprint('auth', __name__) +auth_bp = Blueprint("auth", __name__) + def _build_frontend_login_redirect(frontend_url: str, **params) -> str: """ @@ -22,51 +26,52 @@ def _build_frontend_login_redirect(frontend_url: str, **params) -> str: Frontend uses Vue Router hash mode (`/#/user/login`), so redirecting to `/user/login` will 404 on static hosting. Always normalize to `{origin}/#/user/login`. """ - base = (frontend_url or '').strip().rstrip('/') + base = (frontend_url or "").strip().rstrip("/") if not base: - base = 'http://localhost:8080' + base = "http://localhost:8080" - if '/#/' in base: - origin = base.split('/#/', 1)[0].rstrip('/') - elif '#' in base: - origin = base.split('#', 1)[0].rstrip('/') + if "/#/" in base: + origin = base.split("/#/", 1)[0].rstrip("/") + elif "#" in base: + origin = base.split("#", 1)[0].rstrip("/") else: origin = base login_url = f"{origin}/#/user/login" - qs = urlencode({k: v for k, v in params.items() if v is not None and v != ''}) + qs = urlencode({k: v for k, v in params.items() if v is not None and v != ""}) return f"{login_url}?{qs}" if qs else login_url def _is_single_user_mode() -> bool: """Check if system is in single-user (legacy) mode""" - return os.getenv('SINGLE_USER_MODE', 'false').lower() == 'true' + return os.getenv("SINGLE_USER_MODE", "false").lower() == "true" def _get_client_ip() -> str: """Get client IP address from request""" # Check for proxy headers - if request.headers.get('X-Forwarded-For'): - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - if request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - return request.remote_addr or '0.0.0.0' + if request.headers.get("X-Forwarded-For"): + return request.headers.get("X-Forwarded-For").split(",")[0].strip() + if request.headers.get("X-Real-IP"): + return request.headers.get("X-Real-IP") + return request.remote_addr or "0.0.0.0" def _get_user_agent() -> str: """Get user agent from request""" - return request.headers.get('User-Agent', '')[:500] + return request.headers.get("User-Agent", "")[:500] # ============================================================================= # Security Config Endpoint # ============================================================================= -@auth_bp.route('/security-config', methods=['GET']) + +@auth_bp.route("/security-config", methods=["GET"]) def get_security_config(): """ Get public security configuration for frontend. - + Returns: turnstile_enabled: bool turnstile_site_key: str @@ -76,168 +81,173 @@ def get_security_config(): """ try: from app.services.security_service import get_security_service + config = get_security_service().get_security_config() - return jsonify({'code': 1, 'msg': 'success', 'data': config}) + return jsonify({"code": 1, "msg": "success", "data": config}) except Exception as e: logger.error(f"get_security_config error: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # ============================================================================= # Login Endpoint (Enhanced with security) # ============================================================================= -@auth_bp.route('/login', methods=['POST']) + +@auth_bp.route("/login", methods=["POST"]) def login(): """ User login endpoint. - + Request body: username: str password: str turnstile_token: str (optional, required if Turnstile is enabled) - + Returns: token: JWT token userinfo: User information """ ip_address = _get_client_ip() user_agent = _get_user_agent() - + try: from app.services.security_service import get_security_service + security = get_security_service() - + data = request.get_json() if not data: - return jsonify({'code': 400, 'msg': 'No data provided', 'data': None}), 400 - - username = data.get('username') or data.get('account') - password = data.get('password') - turnstile_token = data.get('turnstile_token') - + return jsonify({"code": 400, "msg": "No data provided", "data": None}), 400 + + username = data.get("username") or data.get("account") + password = data.get("password") + turnstile_token = data.get("turnstile_token") + if not username or not password: - return jsonify({'code': 400, 'msg': 'Missing username/email or password', 'data': None}), 400 - + return jsonify({"code": 400, "msg": "Missing username/email or password", "data": None}), 400 + # Step 1: Verify Turnstile (if enabled) turnstile_ok, turnstile_msg = security.verify_turnstile(turnstile_token, ip_address) if not turnstile_ok: - return jsonify({'code': 0, 'msg': turnstile_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": turnstile_msg, "data": None}), 400 + # Step 2: Check rate limiting allowed, block_msg = security.check_login_allowed(username, ip_address) if not allowed: - return jsonify({'code': 0, 'msg': block_msg, 'data': {'blocked': True}}), 429 - + return jsonify({"code": 0, "msg": block_msg, "data": {"blocked": True}}), 429 + user = None - + # Step 3: Authenticate if not _is_single_user_mode(): try: from app.services.user_service import get_user_service + user = get_user_service().authenticate(username, password) - + # Check if user has no password set (code-login user) - if user and user.get('_no_password'): - user.pop('_no_password', None) + if user and user.get("_no_password"): + user.pop("_no_password", None) # Record failed attempt - security.record_login_attempt(ip_address, 'ip', False, ip_address, user_agent) - security.record_login_attempt(username, 'account', False, ip_address, user_agent) - security.log_security_event('login_failed', user.get('id'), ip_address, user_agent, - {'username': username, 'reason': 'no_password_set'}) - return jsonify({ - 'code': 0, - 'msg': 'This account was created with email verification code and has no password set. Please use email code login or set a password first in your profile settings.', - 'data': None - }), 401 + security.record_login_attempt(ip_address, "ip", False, ip_address, user_agent) + security.record_login_attempt(username, "account", False, ip_address, user_agent) + security.log_security_event( + "login_failed", + user.get("id"), + ip_address, + user_agent, + {"username": username, "reason": "no_password_set"}, + ) + return jsonify( + { + "code": 0, + "msg": "This account was created with email verification code and has no password set. Please use email code login or set a password first in your profile settings.", + "data": None, + } + ), 401 except Exception as e: logger.warning(f"Multi-user auth failed, trying legacy: {e}") - + # Fallback to legacy single-user mode if not user: user = authenticate_legacy(username, password) - + if not user: # Record failed attempt - security.record_login_attempt(ip_address, 'ip', False, ip_address, user_agent) - security.record_login_attempt(username, 'account', False, ip_address, user_agent) - security.log_security_event('login_failed', None, ip_address, user_agent, - {'username': username, 'reason': 'invalid_credentials'}) - return jsonify({'code': 0, 'msg': 'Invalid credentials', 'data': None}), 401 - + security.record_login_attempt(ip_address, "ip", False, ip_address, user_agent) + security.record_login_attempt(username, "account", False, ip_address, user_agent) + security.log_security_event( + "login_failed", None, ip_address, user_agent, {"username": username, "reason": "invalid_credentials"} + ) + return jsonify({"code": 0, "msg": "Invalid credentials", "data": None}), 401 + # Check user status - if user.get('status') == 'disabled': - security.log_security_event('login_blocked', user.get('id'), ip_address, user_agent, - {'reason': 'account_disabled'}) - return jsonify({'code': 0, 'msg': 'Account is disabled', 'data': None}), 403 - - if user.get('status') == 'pending': - return jsonify({'code': 0, 'msg': 'Account is pending activation', 'data': None}), 403 - + if user.get("status") == "disabled": + security.log_security_event( + "login_blocked", user.get("id"), ip_address, user_agent, {"reason": "account_disabled"} + ) + return jsonify({"code": 0, "msg": "Account is disabled", "data": None}), 403 + + if user.get("status") == "pending": + return jsonify({"code": 0, "msg": "Account is pending activation", "data": None}), 403 + # Step 4: Increment token_version (invalidates old sessions for single-client login) - user_id = user.get('id') or user.get('user_id', 1) + user_id = user.get("id") or user.get("user_id", 1) try: from app.services.user_service import get_user_service + new_token_version = get_user_service().increment_token_version(user_id) except Exception as e: logger.warning(f"Failed to increment token_version: {e}") new_token_version = 1 - + # Step 5: Generate token with new token_version token = generate_token( user_id=user_id, - username=user.get('username', username), - role=user.get('role', 'admin'), - token_version=new_token_version # Contains new token_version + username=user.get("username", username), + role=user.get("role", "admin"), + token_version=new_token_version, # Contains new token_version ) - + if not token: - return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500 - + return jsonify({"code": 500, "msg": "Token generation error", "data": None}), 500 + # Step 6: Record successful login - security.record_login_attempt(ip_address, 'ip', True, ip_address, user_agent) - security.record_login_attempt(username, 'account', True, ip_address, user_agent) - security.clear_login_attempts(ip_address, 'ip') - security.clear_login_attempts(username, 'account') - security.log_security_event('login_success', user.get('id'), ip_address, user_agent) - + security.record_login_attempt(ip_address, "ip", True, ip_address, user_agent) + security.record_login_attempt(username, "account", True, ip_address, user_agent) + security.clear_login_attempts(ip_address, "ip") + security.clear_login_attempts(username, "account") + security.log_security_event("login_success", user.get("id"), ip_address, user_agent) + # Build user info for frontend userinfo = { - 'id': user.get('id') or user.get('user_id', 1), - 'username': user.get('username', username), - 'nickname': user.get('nickname', 'User'), - 'avatar': user.get('avatar', '/avatar2.jpg'), - 'timezone': str(user.get('timezone') or '').strip(), - 'role': { - 'id': user.get('role', 'admin'), - 'permissions': _get_permissions(user.get('role', 'admin')) - } + "id": user.get("id") or user.get("user_id", 1), + "username": user.get("username", username), + "nickname": user.get("nickname", "User"), + "avatar": user.get("avatar", "/avatar2.jpg"), + "timezone": str(user.get("timezone") or "").strip(), + "role": {"id": user.get("role", "admin"), "permissions": _get_permissions(user.get("role", "admin"))}, } - - return jsonify({ - 'code': 1, - 'msg': 'Login successful', - 'data': { - 'token': token, - 'userinfo': userinfo - } - }) - + + return jsonify({"code": 1, "msg": "Login successful", "data": {"token": token, "userinfo": userinfo}}) + except Exception as e: logger.error(f"Login error: {e}") - return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 500, "msg": str(e), "data": None}), 500 # ============================================================================= # Email Code Login # ============================================================================= -@auth_bp.route('/login-code', methods=['POST']) + +@auth_bp.route("/login-code", methods=["POST"]) def login_with_code(): """ Login with email verification code (quick login / register). If user doesn't exist, create a new account automatically. - + Request body: email: str code: str (verification code) @@ -246,155 +256,153 @@ def login_with_code(): """ ip_address = _get_client_ip() user_agent = _get_user_agent() - + try: - from app.services.security_service import get_security_service - from app.services.email_service import get_email_service - from app.services.user_service import get_user_service from app.services.billing_service import get_billing_service - + from app.services.email_service import get_email_service + from app.services.security_service import get_security_service + from app.services.user_service import get_user_service + security = get_security_service() email_service = get_email_service() user_service = get_user_service() billing_service = get_billing_service() - + data = request.get_json() if not data: - return jsonify({'code': 0, 'msg': 'No data provided', 'data': None}), 400 - - email = (data.get('email') or '').strip().lower() - code = data.get('code', '').strip() - turnstile_token = data.get('turnstile_token') - referral_code = data.get('referral_code', '').strip() - + return jsonify({"code": 0, "msg": "No data provided", "data": None}), 400 + + email = (data.get("email") or "").strip().lower() + code = data.get("code", "").strip() + turnstile_token = data.get("turnstile_token") + referral_code = data.get("referral_code", "").strip() + # Validate inputs if not email or not email_service.is_valid_email(email): - return jsonify({'code': 0, 'msg': 'Invalid email address', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Invalid email address", "data": None}), 400 + if not code: - return jsonify({'code': 0, 'msg': 'Verification code is required', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Verification code is required", "data": None}), 400 + # Verify Turnstile turnstile_ok, turnstile_msg = security.verify_turnstile(turnstile_token, ip_address) if not turnstile_ok: - return jsonify({'code': 0, 'msg': turnstile_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": turnstile_msg, "data": None}), 400 + # Verify email code - code_valid, code_msg = email_service.verify_code(email, code, 'login') + code_valid, code_msg = email_service.verify_code(email, code, "login") if not code_valid: - return jsonify({'code': 0, 'msg': code_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": code_msg, "data": None}), 400 + # Check if user exists user = user_service.get_user_by_email(email) is_new_user = False - + if not user: # Check if registration is enabled - if os.getenv('ENABLE_REGISTRATION', 'true').lower() != 'true': - return jsonify({'code': 0, 'msg': 'User not found and registration is disabled', 'data': None}), 403 - + if os.getenv("ENABLE_REGISTRATION", "true").lower() != "true": + return jsonify({"code": 0, "msg": "User not found and registration is disabled", "data": None}), 403 + # Auto-create user with email as username import re + # Generate username from email (before @) - base_username = re.sub(r'[^a-zA-Z0-9_]', '', email.split('@')[0]) + base_username = re.sub(r"[^a-zA-Z0-9_]", "", email.split("@")[0]) if not base_username or not base_username[0].isalpha(): - base_username = 'user_' + base_username - + base_username = "user_" + base_username + # Make sure username is unique username = base_username counter = 1 while user_service.get_user_by_username(username): username = f"{base_username}_{counter}" counter += 1 - + # Validate referral code (user ID) referred_by = None if referral_code: try: referrer_id = int(referral_code) referrer = user_service.get_user_by_id(referrer_id) - if referrer and referrer.get('status') == 'active': + if referrer and referrer.get("status") == "active": referred_by = referrer_id except (ValueError, TypeError): pass # Invalid referral code, ignore - + # Create user without password (can set later) user_id = user_service.create_user( username=username, password=None, # No password for code-login users email=email, nickname=username, - role='user', - status='active', + role="user", + status="active", email_verified=True, - referred_by=referred_by + referred_by=referred_by, ) - + if not user_id: - return jsonify({'code': 0, 'msg': 'Failed to create account', 'data': None}), 500 - + return jsonify({"code": 0, "msg": "Failed to create account", "data": None}), 500 + # Grant registration bonus credits - register_bonus = int(os.getenv('CREDITS_REGISTER_BONUS', '0')) + register_bonus = int(os.getenv("CREDITS_REGISTER_BONUS", "0")) if register_bonus > 0: billing_service.add_credits( - user_id=user_id, - amount=register_bonus, - action='register_bonus', - remark='Registration bonus' + user_id=user_id, amount=register_bonus, action="register_bonus", remark="Registration bonus" ) - + # Grant referral bonus to referrer if referred_by: - referral_bonus = int(os.getenv('CREDITS_REFERRAL_BONUS', '0')) + referral_bonus = int(os.getenv("CREDITS_REFERRAL_BONUS", "0")) if referral_bonus > 0: billing_service.add_credits( user_id=referred_by, amount=referral_bonus, - action='referral_bonus', - remark=f'Referral bonus for inviting user {username}', - reference_id=str(user_id) + action="referral_bonus", + remark=f"Referral bonus for inviting user {username}", + reference_id=str(user_id), ) - + user = user_service.get_user_by_id(user_id) is_new_user = True - + # Log registration - security.log_security_event('register_via_code', user_id, ip_address, user_agent, - {'email': email, 'referred_by': referred_by}) - + security.log_security_event( + "register_via_code", user_id, ip_address, user_agent, {"email": email, "referred_by": referred_by} + ) + # Check user status - if user.get('status') == 'disabled': - security.log_security_event('login_blocked', user.get('id'), ip_address, user_agent, - {'reason': 'account_disabled'}) - return jsonify({'code': 0, 'msg': 'Account is disabled', 'data': None}), 403 - + if user.get("status") == "disabled": + security.log_security_event( + "login_blocked", user.get("id"), ip_address, user_agent, {"reason": "account_disabled"} + ) + return jsonify({"code": 0, "msg": "Account is disabled", "data": None}), 403 + # Increment token_version (invalidates old sessions for single-client login) try: - new_token_version = user_service.increment_token_version(user['id']) + new_token_version = user_service.increment_token_version(user["id"]) except Exception as e: logger.warning(f"Failed to increment token_version: {e}") new_token_version = 1 - + # Generate token with new token_version token = generate_token( - user_id=user['id'], - username=user['username'], - role=user.get('role', 'user'), - token_version=new_token_version + user_id=user["id"], + username=user["username"], + role=user.get("role", "user"), + token_version=new_token_version, ) - + if not token: - return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500 - + return jsonify({"code": 500, "msg": "Token generation error", "data": None}), 500 + # Update last login time try: from app.utils.db import get_db_connection + with get_db_connection() as db: cur = db.cursor() - cur.execute( - "UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", - (user['id'],) - ) + cur.execute("UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", (user["id"],)) db.commit() affected = cur.rowcount cur.close() @@ -404,135 +412,144 @@ def login_with_code(): logger.info(f"Updated last_login_at for user_id={user['id']}") except Exception as e: logger.error(f"Failed to update last_login_at for user_id={user.get('id')}: {e}") - + # Log login - security.log_security_event('login_via_code', user['id'], ip_address, user_agent) - - return jsonify({ - 'code': 1, - 'msg': 'Login successful' + (' (new account created)' if is_new_user else ''), - 'data': { - 'token': token, - 'is_new_user': is_new_user, - 'userinfo': { - 'id': user['id'], - 'username': user['username'], - 'nickname': user.get('nickname', user['username']), - 'email': user.get('email'), - 'avatar': user.get('avatar', '/avatar2.jpg'), - 'timezone': str(user.get('timezone') or '').strip(), - 'role': { - 'id': user.get('role', 'user'), - 'permissions': _get_permissions(user.get('role', 'user')) - } - } + security.log_security_event("login_via_code", user["id"], ip_address, user_agent) + + return jsonify( + { + "code": 1, + "msg": "Login successful" + (" (new account created)" if is_new_user else ""), + "data": { + "token": token, + "is_new_user": is_new_user, + "userinfo": { + "id": user["id"], + "username": user["username"], + "nickname": user.get("nickname", user["username"]), + "email": user.get("email"), + "avatar": user.get("avatar", "/avatar2.jpg"), + "timezone": str(user.get("timezone") or "").strip(), + "role": { + "id": user.get("role", "user"), + "permissions": _get_permissions(user.get("role", "user")), + }, + }, + }, } - }) - + ) + except Exception as e: logger.error(f"login_with_code error: {e}") - return jsonify({'code': 0, 'msg': 'Login failed', 'data': None}), 500 + return jsonify({"code": 0, "msg": "Login failed", "data": None}), 500 # ============================================================================= # Registration Endpoints # ============================================================================= -@auth_bp.route('/send-code', methods=['POST']) + +@auth_bp.route("/send-code", methods=["POST"]) def send_verification_code(): """ Send verification code to email. - + Request body: email: str type: str (register, reset_password, change_password, change_email) turnstile_token: str (optional) """ ip_address = _get_client_ip() - + try: - from app.services.security_service import get_security_service from app.services.email_service import get_email_service - + from app.services.security_service import get_security_service + security = get_security_service() email_service = get_email_service() - + data = request.get_json() if not data: - return jsonify({'code': 0, 'msg': 'No data provided', 'data': None}), 400 - - email = (data.get('email') or '').strip().lower() - code_type = data.get('type', 'register') - turnstile_token = data.get('turnstile_token') - + return jsonify({"code": 0, "msg": "No data provided", "data": None}), 400 + + email = (data.get("email") or "").strip().lower() + code_type = data.get("type", "register") + turnstile_token = data.get("turnstile_token") + # Validate email if not email or not email_service.is_valid_email(email): - return jsonify({'code': 0, 'msg': 'Invalid email address', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Invalid email address", "data": None}), 400 + # For change_password type with logged-in user, skip Turnstile verification # because user already authenticated skip_turnstile = False - if code_type == 'change_password': + if code_type == "change_password": # Try to get user_id from token (this route doesn't require login) from app.utils.auth import verify_token - auth_header = request.headers.get('Authorization') + + auth_header = request.headers.get("Authorization") if auth_header: parts = auth_header.split() - if len(parts) == 2 and parts[0].lower() == 'bearer': + if len(parts) == 2 and parts[0].lower() == "bearer": payload = verify_token(parts[1]) - if payload and payload.get('user_id'): + if payload and payload.get("user_id"): skip_turnstile = True - + # Verify Turnstile (skip for authenticated change_password requests) if not skip_turnstile: turnstile_ok, turnstile_msg = security.verify_turnstile(turnstile_token, ip_address) if not turnstile_ok: - return jsonify({'code': 0, 'msg': turnstile_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": turnstile_msg, "data": None}), 400 + # Check rate limit can_send, rate_msg = security.can_send_verification_code(email, ip_address) if not can_send: - return jsonify({'code': 0, 'msg': rate_msg, 'data': None}), 429 - + return jsonify({"code": 0, "msg": rate_msg, "data": None}), 429 + # For registration, check if email already exists - if code_type == 'register': + if code_type == "register": from app.services.user_service import get_user_service + existing = get_user_service().get_user_by_email(email) if existing: - return jsonify({'code': 0, 'msg': 'Email already registered', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Email already registered", "data": None}), 400 + # For login type - always allow (will auto-create if not exists) # No special check needed - + # For reset_password, check if email exists - if code_type == 'reset_password': + if code_type == "reset_password": from app.services.user_service import get_user_service + existing = get_user_service().get_user_by_email(email) if not existing: # Don't reveal if email exists or not (security best practice) # But still return success to prevent email enumeration - return jsonify({'code': 1, 'msg': 'If the email exists, a verification code has been sent', 'data': None}) - + return jsonify( + {"code": 1, "msg": "If the email exists, a verification code has been sent", "data": None} + ) + # Send verification code success, msg = email_service.send_verification_code(email, code_type, ip_address) - + if success: - security.log_security_event('verification_code_sent', None, ip_address, - _get_user_agent(), {'email': email, 'type': code_type}) - return jsonify({'code': 1, 'msg': 'Verification code sent', 'data': None}) + security.log_security_event( + "verification_code_sent", None, ip_address, _get_user_agent(), {"email": email, "type": code_type} + ) + return jsonify({"code": 1, "msg": "Verification code sent", "data": None}) else: - return jsonify({'code': 0, 'msg': msg, 'data': None}), 500 - + return jsonify({"code": 0, "msg": msg, "data": None}), 500 + except Exception as e: logger.error(f"send_verification_code error: {e}") - return jsonify({'code': 0, 'msg': 'Failed to send verification code', 'data': None}), 500 + return jsonify({"code": 0, "msg": "Failed to send verification code", "data": None}), 500 -@auth_bp.route('/register', methods=['POST']) +@auth_bp.route("/register", methods=["POST"]) def register(): """ Register new user with email verification. - + Request body: email: str code: str (verification code) @@ -543,169 +560,168 @@ def register(): """ ip_address = _get_client_ip() user_agent = _get_user_agent() - + try: # Check if registration is enabled - if os.getenv('ENABLE_REGISTRATION', 'true').lower() != 'true': - return jsonify({'code': 0, 'msg': 'Registration is disabled', 'data': None}), 403 - - from app.services.security_service import get_security_service - from app.services.email_service import get_email_service - from app.services.user_service import get_user_service + if os.getenv("ENABLE_REGISTRATION", "true").lower() != "true": + return jsonify({"code": 0, "msg": "Registration is disabled", "data": None}), 403 + from app.services.billing_service import get_billing_service - + from app.services.email_service import get_email_service + from app.services.security_service import get_security_service + from app.services.user_service import get_user_service + security = get_security_service() email_service = get_email_service() user_service = get_user_service() billing_service = get_billing_service() - + data = request.get_json() if not data: - return jsonify({'code': 0, 'msg': 'No data provided', 'data': None}), 400 - - email = (data.get('email') or '').strip().lower() - code = data.get('code', '').strip() - username = (data.get('username') or '').strip() - password = data.get('password', '') - turnstile_token = data.get('turnstile_token') - referral_code = data.get('referral_code', '').strip() - + return jsonify({"code": 0, "msg": "No data provided", "data": None}), 400 + + email = (data.get("email") or "").strip().lower() + code = data.get("code", "").strip() + username = (data.get("username") or "").strip() + password = data.get("password", "") + turnstile_token = data.get("turnstile_token") + referral_code = data.get("referral_code", "").strip() + # Validate inputs if not email or not email_service.is_valid_email(email): - return jsonify({'code': 0, 'msg': 'Invalid email address', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Invalid email address", "data": None}), 400 + if not code: - return jsonify({'code': 0, 'msg': 'Verification code is required', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Verification code is required", "data": None}), 400 + if not username or len(username) < 3 or len(username) > 30: - return jsonify({'code': 0, 'msg': 'Username must be 3-30 characters', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Username must be 3-30 characters", "data": None}), 400 + # Validate username format (alphanumeric and underscore only) import re - if not re.match(r'^[a-zA-Z][a-zA-Z0-9_]*$', username): - return jsonify({'code': 0, 'msg': 'Username must start with letter and contain only letters, numbers, and underscores', 'data': None}), 400 - + + if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", username): + return jsonify( + { + "code": 0, + "msg": "Username must start with letter and contain only letters, numbers, and underscores", + "data": None, + } + ), 400 + # Validate password strength pwd_valid, pwd_msg = security.validate_password_strength(password) if not pwd_valid: - return jsonify({'code': 0, 'msg': pwd_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": pwd_msg, "data": None}), 400 + # Verify Turnstile turnstile_ok, turnstile_msg = security.verify_turnstile(turnstile_token, ip_address) if not turnstile_ok: - return jsonify({'code': 0, 'msg': turnstile_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": turnstile_msg, "data": None}), 400 + # Verify email code - code_valid, code_msg = email_service.verify_code(email, code, 'register') + code_valid, code_msg = email_service.verify_code(email, code, "register") if not code_valid: - return jsonify({'code': 0, 'msg': code_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": code_msg, "data": None}), 400 + # Check if username already exists existing_user = user_service.get_user_by_username(username) if existing_user: - return jsonify({'code': 0, 'msg': 'Username already taken', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Username already taken", "data": None}), 400 + # Check if email already exists existing_email = user_service.get_user_by_email(email) if existing_email: - return jsonify({'code': 0, 'msg': 'Email already registered', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Email already registered", "data": None}), 400 + # Validate referral code (user ID) referred_by = None if referral_code: try: referrer_id = int(referral_code) referrer = user_service.get_user_by_id(referrer_id) - if referrer and referrer.get('status') == 'active': + if referrer and referrer.get("status") == "active": referred_by = referrer_id except (ValueError, TypeError): pass # Invalid referral code, ignore - + # Create user user_id = user_service.create_user( username=username, password=password, email=email, nickname=username, - role='user', - status='active', + role="user", + status="active", email_verified=True, - referred_by=referred_by + referred_by=referred_by, ) - + if not user_id: - return jsonify({'code': 0, 'msg': 'Failed to create account', 'data': None}), 500 - + return jsonify({"code": 0, "msg": "Failed to create account", "data": None}), 500 + # Grant registration bonus credits - register_bonus = int(os.getenv('CREDITS_REGISTER_BONUS', '0')) + register_bonus = int(os.getenv("CREDITS_REGISTER_BONUS", "0")) if register_bonus > 0: billing_service.add_credits( - user_id=user_id, - amount=register_bonus, - action='register_bonus', - remark='Registration bonus' + user_id=user_id, amount=register_bonus, action="register_bonus", remark="Registration bonus" ) - + # Grant referral bonus to referrer if referred_by: - referral_bonus = int(os.getenv('CREDITS_REFERRAL_BONUS', '0')) + referral_bonus = int(os.getenv("CREDITS_REFERRAL_BONUS", "0")) if referral_bonus > 0: billing_service.add_credits( user_id=referred_by, amount=referral_bonus, - action='referral_bonus', - remark=f'Referral bonus for inviting user {username}', - reference_id=str(user_id) + action="referral_bonus", + remark=f"Referral bonus for inviting user {username}", + reference_id=str(user_id), ) - + # Log registration - security.log_security_event('register', user_id, ip_address, user_agent, - {'email': email, 'referred_by': referred_by}) - + security.log_security_event( + "register", user_id, ip_address, user_agent, {"email": email, "referred_by": referred_by} + ) + # Auto login after registration (get token_version for new user) try: new_token_version = user_service.get_token_version(user_id) except Exception as e: logger.warning(f"Failed to get token_version: {e}") new_token_version = 1 - - token = generate_token( - user_id=user_id, - username=username, - role='user', - token_version=new_token_version - ) - - return jsonify({ - 'code': 1, - 'msg': 'Registration successful', - 'data': { - 'token': token, - 'userinfo': { - 'id': user_id, - 'username': username, - 'nickname': username, - 'email': email, - 'avatar': '/avatar2.jpg', - 'timezone': '', - 'role': { - 'id': 'user', - 'permissions': _get_permissions('user') - } - } + + token = generate_token(user_id=user_id, username=username, role="user", token_version=new_token_version) + + return jsonify( + { + "code": 1, + "msg": "Registration successful", + "data": { + "token": token, + "userinfo": { + "id": user_id, + "username": username, + "nickname": username, + "email": email, + "avatar": "/avatar2.jpg", + "timezone": "", + "role": {"id": "user", "permissions": _get_permissions("user")}, + }, + }, } - }) - + ) + except Exception as e: logger.error(f"register error: {e}") - return jsonify({'code': 0, 'msg': 'Registration failed', 'data': None}), 500 + return jsonify({"code": 0, "msg": "Registration failed", "data": None}), 500 -@auth_bp.route('/reset-password', methods=['POST']) +@auth_bp.route("/reset-password", methods=["POST"]) def reset_password(): """ Reset password with email verification. - + Request body: email: str code: str (verification code) @@ -714,73 +730,73 @@ def reset_password(): """ ip_address = _get_client_ip() user_agent = _get_user_agent() - + try: - from app.services.security_service import get_security_service from app.services.email_service import get_email_service + from app.services.security_service import get_security_service from app.services.user_service import get_user_service - + security = get_security_service() email_service = get_email_service() user_service = get_user_service() - + data = request.get_json() if not data: - return jsonify({'code': 0, 'msg': 'No data provided', 'data': None}), 400 - - email = (data.get('email') or '').strip().lower() - code = data.get('code', '').strip() - new_password = data.get('new_password', '') - turnstile_token = data.get('turnstile_token') - + return jsonify({"code": 0, "msg": "No data provided", "data": None}), 400 + + email = (data.get("email") or "").strip().lower() + code = data.get("code", "").strip() + new_password = data.get("new_password", "") + turnstile_token = data.get("turnstile_token") + # Validate inputs if not email or not code or not new_password: - return jsonify({'code': 0, 'msg': 'Missing required fields', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Missing required fields", "data": None}), 400 + # Validate password strength pwd_valid, pwd_msg = security.validate_password_strength(new_password) if not pwd_valid: - return jsonify({'code': 0, 'msg': pwd_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": pwd_msg, "data": None}), 400 + # Verify Turnstile turnstile_ok, turnstile_msg = security.verify_turnstile(turnstile_token, ip_address) if not turnstile_ok: - return jsonify({'code': 0, 'msg': turnstile_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": turnstile_msg, "data": None}), 400 + # Verify email code - code_valid, code_msg = email_service.verify_code(email, code, 'reset_password') + code_valid, code_msg = email_service.verify_code(email, code, "reset_password") if not code_valid: - return jsonify({'code': 0, 'msg': code_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": code_msg, "data": None}), 400 + # Get user by email user = user_service.get_user_by_email(email) if not user: - return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 - + return jsonify({"code": 0, "msg": "User not found", "data": None}), 404 + # Update password - success = user_service.update_password(user['id'], new_password) + success = user_service.update_password(user["id"], new_password) if not success: - return jsonify({'code': 0, 'msg': 'Failed to reset password', 'data': None}), 500 - + return jsonify({"code": 0, "msg": "Failed to reset password", "data": None}), 500 + # Clear any existing login blocks for this account - security.clear_login_attempts(user['username'], 'account') - + security.clear_login_attempts(user["username"], "account") + # Log password reset - security.log_security_event('password_reset', user['id'], ip_address, user_agent) - - return jsonify({'code': 1, 'msg': 'Password reset successful', 'data': None}) - + security.log_security_event("password_reset", user["id"], ip_address, user_agent) + + return jsonify({"code": 1, "msg": "Password reset successful", "data": None}) + except Exception as e: logger.error(f"reset_password error: {e}") - return jsonify({'code': 0, 'msg': 'Password reset failed', 'data': None}), 500 + return jsonify({"code": 0, "msg": "Password reset failed", "data": None}), 500 -@auth_bp.route('/change-password', methods=['POST']) +@auth_bp.route("/change-password", methods=["POST"]) @login_required def change_password(): """ Change password with email verification (for logged-in users). - + Request body: code: str (verification code sent to user's email) new_password: str @@ -788,307 +804,325 @@ def change_password(): ip_address = _get_client_ip() user_agent = _get_user_agent() user_id = g.user_id - + try: - from app.services.security_service import get_security_service from app.services.email_service import get_email_service + from app.services.security_service import get_security_service from app.services.user_service import get_user_service - + security = get_security_service() email_service = get_email_service() user_service = get_user_service() - + data = request.get_json() if not data: - return jsonify({'code': 0, 'msg': 'No data provided', 'data': None}), 400 - - code = data.get('code', '').strip() - new_password = data.get('new_password', '') - + return jsonify({"code": 0, "msg": "No data provided", "data": None}), 400 + + code = data.get("code", "").strip() + new_password = data.get("new_password", "") + if not code or not new_password: - return jsonify({'code': 0, 'msg': 'Missing required fields', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Missing required fields", "data": None}), 400 + # Validate password strength pwd_valid, pwd_msg = security.validate_password_strength(new_password) if not pwd_valid: - return jsonify({'code': 0, 'msg': pwd_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": pwd_msg, "data": None}), 400 + # Get user user = user_service.get_user_by_id(user_id) - if not user or not user.get('email'): - return jsonify({'code': 0, 'msg': 'User email not found', 'data': None}), 400 - + if not user or not user.get("email"): + return jsonify({"code": 0, "msg": "User email not found", "data": None}), 400 + # Verify email code - code_valid, code_msg = email_service.verify_code(user['email'], code, 'change_password') + code_valid, code_msg = email_service.verify_code(user["email"], code, "change_password") if not code_valid: - return jsonify({'code': 0, 'msg': code_msg, 'data': None}), 400 - + return jsonify({"code": 0, "msg": code_msg, "data": None}), 400 + # Update password success = user_service.update_password(user_id, new_password) if not success: - return jsonify({'code': 0, 'msg': 'Failed to change password', 'data': None}), 500 - + return jsonify({"code": 0, "msg": "Failed to change password", "data": None}), 500 + # Log password change - security.log_security_event('password_changed', user_id, ip_address, user_agent) - - return jsonify({'code': 1, 'msg': 'Password changed successfully', 'data': None}) - + security.log_security_event("password_changed", user_id, ip_address, user_agent) + + return jsonify({"code": 1, "msg": "Password changed successfully", "data": None}) + except Exception as e: logger.error(f"change_password error: {e}") - return jsonify({'code': 0, 'msg': 'Password change failed', 'data': None}), 500 + return jsonify({"code": 0, "msg": "Password change failed", "data": None}), 500 # ============================================================================= # OAuth Endpoints # ============================================================================= -@auth_bp.route('/oauth/google', methods=['GET']) + +@auth_bp.route("/oauth/google", methods=["GET"]) def oauth_google(): """Redirect to Google OAuth authorization page""" try: from app.services.oauth_service import get_oauth_service + oauth = get_oauth_service() - + if not oauth.google_enabled: - return jsonify({'code': 0, 'msg': 'Google OAuth is not configured', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Google OAuth is not configured", "data": None}), 400 + auth_url, state = oauth.get_google_auth_url() return redirect(auth_url) - + except Exception as e: logger.error(f"oauth_google error: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@auth_bp.route('/oauth/google/callback', methods=['GET']) +@auth_bp.route("/oauth/google/callback", methods=["GET"]) def oauth_google_callback(): """Handle Google OAuth callback""" ip_address = _get_client_ip() user_agent = _get_user_agent() - + try: from app.services.oauth_service import get_oauth_service from app.services.security_service import get_security_service - + oauth = get_oauth_service() security = get_security_service() - - code = request.args.get('code') - state = request.args.get('state') - error = request.args.get('error') - + + code = request.args.get("code") + state = request.args.get("state") + error = request.args.get("error") + frontend_url = oauth.frontend_url - + if error: return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error)) - + if not code or not state: - return redirect(_build_frontend_login_redirect(frontend_url, oauth_error='missing_params')) - + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error="missing_params")) + # Handle callback success, result = oauth.handle_google_callback(code, state) if not success: - error_msg = result.get('error', 'unknown_error') + error_msg = result.get("error", "unknown_error") return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error_msg)) - + # Get or create user user_success, user_result = oauth.get_or_create_user_from_oauth(result) if not user_success: - error_msg = user_result.get('error', 'user_creation_failed') + error_msg = user_result.get("error", "user_creation_failed") return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error_msg)) - + # Increment token_version (invalidates old sessions for single-client login) from app.services.user_service import get_user_service + user_service = get_user_service() try: - new_token_version = user_service.increment_token_version(user_result['id']) + new_token_version = user_service.increment_token_version(user_result["id"]) except Exception as e: logger.warning(f"Failed to increment token_version: {e}") new_token_version = 1 - + # Generate token with new token_version token = generate_token( - user_id=user_result['id'], - username=user_result['username'], - role=user_result.get('role', 'user'), - token_version=new_token_version + user_id=user_result["id"], + username=user_result["username"], + role=user_result.get("role", "user"), + token_version=new_token_version, ) - + # Log OAuth login - security.log_security_event('oauth_login', user_result['id'], ip_address, user_agent, - {'provider': 'google'}) - + security.log_security_event("oauth_login", user_result["id"], ip_address, user_agent, {"provider": "google"}) + # Redirect to frontend with token return redirect(_build_frontend_login_redirect(frontend_url, oauth_token=token)) - + except Exception as e: logger.error(f"oauth_google_callback error: {e}") from app.services.oauth_service import get_oauth_service + frontend_url = get_oauth_service().frontend_url - return redirect(_build_frontend_login_redirect(frontend_url, oauth_error='server_error')) + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error="server_error")) -@auth_bp.route('/oauth/github', methods=['GET']) +@auth_bp.route("/oauth/github", methods=["GET"]) def oauth_github(): """Redirect to GitHub OAuth authorization page""" try: from app.services.oauth_service import get_oauth_service + oauth = get_oauth_service() - + if not oauth.github_enabled: - return jsonify({'code': 0, 'msg': 'GitHub OAuth is not configured', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "GitHub OAuth is not configured", "data": None}), 400 + auth_url, state = oauth.get_github_auth_url() return redirect(auth_url) - + except Exception as e: logger.error(f"oauth_github error: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@auth_bp.route('/oauth/github/callback', methods=['GET']) +@auth_bp.route("/oauth/github/callback", methods=["GET"]) def oauth_github_callback(): """Handle GitHub OAuth callback""" ip_address = _get_client_ip() user_agent = _get_user_agent() - + try: from app.services.oauth_service import get_oauth_service from app.services.security_service import get_security_service - + oauth = get_oauth_service() security = get_security_service() - - code = request.args.get('code') - state = request.args.get('state') - error = request.args.get('error') - + + code = request.args.get("code") + state = request.args.get("state") + error = request.args.get("error") + frontend_url = oauth.frontend_url - + if error: return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error)) - + if not code or not state: - return redirect(_build_frontend_login_redirect(frontend_url, oauth_error='missing_params')) - + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error="missing_params")) + # Handle callback success, result = oauth.handle_github_callback(code, state) if not success: - error_msg = result.get('error', 'unknown_error') + error_msg = result.get("error", "unknown_error") return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error_msg)) - + # Get or create user user_success, user_result = oauth.get_or_create_user_from_oauth(result) if not user_success: - error_msg = user_result.get('error', 'user_creation_failed') + error_msg = user_result.get("error", "user_creation_failed") return redirect(_build_frontend_login_redirect(frontend_url, oauth_error=error_msg)) - + # Increment token_version (invalidates old sessions for single-client login) from app.services.user_service import get_user_service + user_service = get_user_service() try: - new_token_version = user_service.increment_token_version(user_result['id']) + new_token_version = user_service.increment_token_version(user_result["id"]) except Exception as e: logger.warning(f"Failed to increment token_version: {e}") new_token_version = 1 - + # Generate token with new token_version token = generate_token( - user_id=user_result['id'], - username=user_result['username'], - role=user_result.get('role', 'user'), - token_version=new_token_version + user_id=user_result["id"], + username=user_result["username"], + role=user_result.get("role", "user"), + token_version=new_token_version, ) - + # Log OAuth login - security.log_security_event('oauth_login', user_result['id'], ip_address, user_agent, - {'provider': 'github'}) - + security.log_security_event("oauth_login", user_result["id"], ip_address, user_agent, {"provider": "github"}) + # Redirect to frontend with token return redirect(_build_frontend_login_redirect(frontend_url, oauth_token=token)) - + except Exception as e: logger.error(f"oauth_github_callback error: {e}") from app.services.oauth_service import get_oauth_service + frontend_url = get_oauth_service().frontend_url - return redirect(_build_frontend_login_redirect(frontend_url, oauth_error='server_error')) + return redirect(_build_frontend_login_redirect(frontend_url, oauth_error="server_error")) # ============================================================================= # Other Endpoints # ============================================================================= -@auth_bp.route('/logout', methods=['POST']) + +@auth_bp.route("/logout", methods=["POST"]) def logout(): """Logout (client removes token; server is stateless).""" - return jsonify({'code': 1, 'msg': 'Logout successful', 'data': None}) + return jsonify({"code": 1, "msg": "Logout successful", "data": None}) -@auth_bp.route('/info', methods=['GET']) +@auth_bp.route("/info", methods=["GET"]) @login_required def get_user_info(): """Get current user info.""" try: - user_id = getattr(g, 'user_id', 1) - username = getattr(g, 'user', Config.ADMIN_USER) - role = getattr(g, 'user_role', 'admin') - + user_id = getattr(g, "user_id", 1) + username = getattr(g, "user", Config.ADMIN_USER) + role = getattr(g, "user_role", "admin") + # Try to get full user info from database user_data = None if not _is_single_user_mode(): try: from app.services.user_service import get_user_service + user_data = get_user_service().get_user_by_id(user_id) except Exception as e: logger.warning(f"Failed to get user from database: {e}") - + if user_data: - return jsonify({ - 'code': 1, - 'msg': 'Success', - 'data': { - 'id': user_data.get('id'), - 'username': user_data.get('username'), - 'nickname': user_data.get('nickname', 'User'), - 'email': user_data.get('email'), - 'avatar': user_data.get('avatar', '/avatar2.jpg'), - 'timezone': str(user_data.get('timezone') or '').strip(), - 'role': { - 'id': user_data.get('role', 'user'), - 'permissions': _get_permissions(user_data.get('role', 'user')) - } + return jsonify( + { + "code": 1, + "msg": "Success", + "data": { + "id": user_data.get("id"), + "username": user_data.get("username"), + "nickname": user_data.get("nickname", "User"), + "email": user_data.get("email"), + "avatar": user_data.get("avatar", "/avatar2.jpg"), + "timezone": str(user_data.get("timezone") or "").strip(), + "role": { + "id": user_data.get("role", "user"), + "permissions": _get_permissions(user_data.get("role", "user")), + }, + }, } - }) - + ) + # Fallback for legacy mode - return jsonify({ - 'code': 1, - 'msg': 'Success', - 'data': { - 'id': user_id, - 'username': username, - 'nickname': 'Admin', - 'avatar': '/avatar2.jpg', - 'timezone': '', - 'role': { - 'id': role, - 'permissions': _get_permissions(role) - } + return jsonify( + { + "code": 1, + "msg": "Success", + "data": { + "id": user_id, + "username": username, + "nickname": "Admin", + "avatar": "/avatar2.jpg", + "timezone": "", + "role": {"id": role, "permissions": _get_permissions(role)}, + }, } - }) + ) except Exception as e: logger.error(f"get_user_info error: {e}") - return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 500, "msg": str(e), "data": None}), 500 def _get_permissions(role: str) -> list: """Get permissions list for a role""" try: from app.services.user_service import get_user_service + return get_user_service().get_user_permissions(role) except Exception: # Default permissions for admin - if role == 'admin': - return ['dashboard', 'view', 'indicator', 'backtest', 'strategy', - 'portfolio', 'settings', 'user_manage', 'credentials'] - return ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio'] + if role == "admin": + return [ + "dashboard", + "view", + "indicator", + "backtest", + "strategy", + "portfolio", + "settings", + "user_manage", + "credentials", + ] + return ["dashboard", "view", "indicator", "backtest", "strategy", "portfolio"] diff --git a/backend_api_python/app/routes/backtest.py b/backend_api_python/app/routes/backtest.py index f94552d..16201d4 100644 --- a/backend_api_python/app/routes/backtest.py +++ b/backend_api_python/app/routes/backtest.py @@ -1,27 +1,29 @@ """ Backtest API routes """ -from flask import Blueprint, request, jsonify, g -from datetime import datetime -import traceback + import json -import time import os +import traceback +from datetime import datetime + +import requests +from flask import Blueprint, g, jsonify, request from app.services.backtest import BacktestService -from app.utils.logger import get_logger -from app.utils.db import get_db_connection from app.utils.auth import login_required -import requests +from app.utils.db import get_db_connection +from app.utils.logger import get_logger logger = get_logger(__name__) -backtest_bp = Blueprint('backtest', __name__) +backtest_bp = Blueprint("backtest", __name__) backtest_service = BacktestService() def _openrouter_base_and_key() -> tuple[str, str]: 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() @@ -55,9 +57,9 @@ def _normalize_lang(lang: str | None) -> str: "fr-FR", "ja-JP", } - l = (lang or "").strip() - if not l: - return "zh-CN" + normalized_lang = (lang or "").strip() + if not normalized_lang: + return "en-US" alias = { "zh": "zh-CN", "zh-cn": "zh-CN", @@ -81,53 +83,49 @@ def _normalize_lang(lang: str | None) -> str: "ar": "ar-SA", "ar-sa": "ar-SA", } - l2 = alias.get(l.lower(), l) + l2 = alias.get(normalized_lang.lower(), normalized_lang) return l2 if l2 in supported else "zh-CN" -@backtest_bp.route('/backtest/precision-info', methods=['GET']) +@backtest_bp.route("/backtest/precision-info", methods=["GET"]) def get_precision_info(): """ Get backtest accuracy information (for front-end prompts) - + Params (Query String): market: market type startDate: start date (YYYY-MM-DD) endDate: end date (YYYY-MM-DD) - + Returns: Accuracy information, including recommended execution time frame and estimated number of K-lines """ try: # Use request.args for GET params - market = request.args.get('market', 'crypto') - start_date_str = request.args.get('startDate', '') - end_date_str = request.args.get('endDate', '') - + market = request.args.get("market", "crypto") + start_date_str = request.args.get("startDate", "") + end_date_str = request.args.get("endDate", "") + if not start_date_str or not end_date_str: - return jsonify({'code': 0, 'msg': 'startDate and endDate are required'}), 400 - - start_date = datetime.strptime(start_date_str, '%Y-%m-%d') - end_date = datetime.strptime(end_date_str, '%Y-%m-%d') - + return jsonify({"code": 0, "msg": "startDate and endDate are required"}), 400 + + start_date = datetime.strptime(start_date_str, "%Y-%m-%d") + end_date = datetime.strptime(end_date_str, "%Y-%m-%d") + exec_tf, precision_info = backtest_service.get_execution_timeframe(start_date, end_date, market) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': precision_info - }) + + return jsonify({"code": 1, "msg": "success", "data": precision_info}) except Exception as e: logger.error(f"Get precision info failed: {e}") - return jsonify({'code': 0, 'msg': str(e)}), 400 + return jsonify({"code": 0, "msg": str(e)}), 400 -@backtest_bp.route('/backtest', methods=['POST']) +@backtest_bp.route("/backtest", methods=["POST"]) @login_required def run_backtest(): """ Run indicator backtest for the current user. - + Params: indicatorId: Indicator ID (optional) indicatorCode: Indicator Python code @@ -143,34 +141,30 @@ def run_backtest(): try: data = request.get_json() if not data: - return jsonify({ - 'code': 0, - 'msg': 'Request body is required', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": "Request body is required", "data": None}), 400 + # Extract params - use current user's ID user_id = g.user_id - indicator_code = data.get('indicatorCode', '') - indicator_id = data.get('indicatorId') - symbol = data.get('symbol', '') - market = data.get('market', '') - timeframe = data.get('timeframe', '1D') - start_date_str = data.get('startDate', '') - end_date_str = data.get('endDate', '') - initial_capital = float(data.get('initialCapital', 10000)) - commission = float(data.get('commission', 0.001)) - slippage = float(data.get('slippage', 0.0)) - leverage = int(data.get('leverage', 1)) - trade_direction = data.get('tradeDirection', 'long') # long, short, both - strategy_config = data.get('strategyConfig') or {} + indicator_code = data.get("indicatorCode", "") + indicator_id = data.get("indicatorId") + symbol = data.get("symbol", "") + market = data.get("market", "") + timeframe = data.get("timeframe", "1D") + start_date_str = data.get("startDate", "") + end_date_str = data.get("endDate", "") + initial_capital = float(data.get("initialCapital", 10000)) + commission = float(data.get("commission", 0.001)) + slippage = float(data.get("slippage", 0.0)) + leverage = int(data.get("leverage", 1)) + trade_direction = data.get("tradeDirection", "long") # long, short, both + strategy_config = data.get("strategyConfig") or {} # Multi-timeframe backtesting switch (enabled by default, only valid for cryptocurrency markets) - enable_mtf = data.get('enableMtf', True) + enable_mtf = data.get("enableMtf", True) if isinstance(enable_mtf, str): - enable_mtf = enable_mtf.lower() in ['true', '1', 'yes'] - + enable_mtf = enable_mtf.lower() in ["true", "1", "yes"] + # (Debug) log received params if needed - + # If frontend only provides indicatorId, load code from local DB. if (not indicator_code or not str(indicator_code).strip()) and indicator_id: try: @@ -180,53 +174,50 @@ def run_backtest(): cur.execute("SELECT code FROM qd_indicator_codes WHERE id = ?", (iid,)) row = cur.fetchone() cur.close() - if row and row.get('code'): - indicator_code = row.get('code') + if row and row.get("code"): + indicator_code = row.get("code") except Exception: pass # Parameter validation if not all([indicator_code, symbol, market, timeframe, start_date_str, end_date_str]): - return jsonify({ - 'code': 0, - 'msg': 'Missing required parameters', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": "Missing required parameters", "data": None}), 400 + # conversion date # Start date: 00:00:00 today - start_date = datetime.strptime(start_date_str, '%Y-%m-%d') + start_date = datetime.strptime(start_date_str, "%Y-%m-%d") # End date: 23:59:59 of the current day, ensuring that the entire day's data is included - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) - + end_date = datetime.strptime(end_date_str, "%Y-%m-%d").replace(hour=23, minute=59, second=59) + # Validation time range limit days_diff = (end_date - start_date).days - + # Set different time limits based on cycles - if timeframe == '1m': + if timeframe == "1m": max_days = 30 # 1 minute K-line up to 1 month - max_range_text = '1 month' - elif timeframe == '5m': + max_range_text = "1 month" + elif timeframe == "5m": max_days = 180 # 5 minute K-line up to 6 months - max_range_text = '6 months' - elif timeframe in ['15m', '30m']: + max_range_text = "6 months" + elif timeframe in ["15m", "30m"]: max_days = 365 # 15-minute and 30-minute K-line up to 1 year - max_range_text = '1 year' + max_range_text = "1 year" else: # 1H, 4H, 1D, 1W max_days = 1095 # 1 hour and above up to 3 years - max_range_text = '3 years' - + max_range_text = "3 years" + if days_diff > max_days: - return jsonify({ - 'code': 0, - 'msg': f'Backtest range exceeds limit: timeframe {timeframe} supports up to {max_range_text} ({max_days} days), but you selected {days_diff} days', - 'data': None - }), 400 - - + return jsonify( + { + "code": 0, + "msg": f"Backtest range exceeds limit: timeframe {timeframe} supports up to {max_range_text} ({max_days} days), but you selected {days_diff} days", + "data": None, + } + ), 400 + # Execute backtesting (supports multi-time frame high-precision backtesting) # Cryptocurrency markets and using multi-timeframe backtesting when MTF is enabled - if enable_mtf and market.lower() in ['crypto', 'cryptocurrency']: + if enable_mtf and market.lower() in ["crypto", "cryptocurrency"]: result = backtest_service.run_multi_timeframe( indicator_code=indicator_code, market=market, @@ -240,7 +231,7 @@ def run_backtest(): leverage=leverage, trade_direction=trade_direction, strategy_config=strategy_config, - enable_mtf=True + enable_mtf=True, ) else: result = backtest_service.run( @@ -255,20 +246,20 @@ def run_backtest(): slippage=slippage, leverage=leverage, trade_direction=trade_direction, - strategy_config=strategy_config + strategy_config=strategy_config, ) # Add accuracy information for standard backtests - result['precision_info'] = { - 'enabled': False, - 'timeframe': timeframe, - 'precision': 'standard', - 'message': '使用标准K线回测' + result["precision_info"] = { + "enabled": False, + "timeframe": timeframe, + "precision": "standard", + "message": "使用标准K线回测", } run_id = backtest_service.persist_run( user_id=user_id, indicator_id=int(indicator_id) if indicator_id is not None else None, - run_type='indicator', + run_type="indicator", market=market, symbol=symbol, timeframe=timeframe, @@ -280,67 +271,52 @@ def run_backtest(): leverage=leverage, trade_direction=trade_direction, strategy_config=strategy_config, - config_snapshot={'indicatorId': int(indicator_id) if indicator_id is not None else None}, - status='success', - error_message='', + config_snapshot={"indicatorId": int(indicator_id) if indicator_id is not None else None}, + status="success", + error_message="", result=result, code=indicator_code, ) - - return jsonify({ - 'code': 1, - 'msg': 'Backtest succeeded', - 'data': { - 'runId': run_id, - 'result': result - } - }) - + + return jsonify({"code": 1, "msg": "Backtest succeeded", "data": {"runId": run_id, "result": result}}) + except ValueError as e: logger.warning(f"Invalid backtest parameters: {str(e)}") - return jsonify({ - 'code': 0, - 'msg': str(e), - 'data': None - }), 400 + return jsonify({"code": 0, "msg": str(e), "data": None}), 400 except Exception as e: logger.error(f"Backtest failed: {str(e)}") logger.error(traceback.format_exc()) try: data = data if isinstance(data, dict) else {} user_id = g.user_id - indicator_id = data.get('indicatorId') + indicator_id = data.get("indicatorId") backtest_service.persist_run( user_id=user_id, indicator_id=int(indicator_id) if indicator_id is not None else None, - run_type='indicator', - market=str(data.get('market', '') or ''), - symbol=str(data.get('symbol', '') or ''), - timeframe=str(data.get('timeframe', '') or ''), - start_date_str=str(data.get('startDate', '') or ''), - end_date_str=str(data.get('endDate', '') or ''), - initial_capital=float(data.get('initialCapital', 0) or 0), - commission=float(data.get('commission', 0) or 0), - slippage=float(data.get('slippage', 0) or 0), - leverage=int(data.get('leverage', 1) or 1), - trade_direction=str(data.get('tradeDirection', 'long') or 'long'), - strategy_config=data.get('strategyConfig') or {}, - config_snapshot={'indicatorId': int(indicator_id) if indicator_id is not None else None}, - status='failed', + run_type="indicator", + market=str(data.get("market", "") or ""), + symbol=str(data.get("symbol", "") or ""), + timeframe=str(data.get("timeframe", "") or ""), + start_date_str=str(data.get("startDate", "") or ""), + end_date_str=str(data.get("endDate", "") or ""), + initial_capital=float(data.get("initialCapital", 0) or 0), + commission=float(data.get("commission", 0) or 0), + slippage=float(data.get("slippage", 0) or 0), + leverage=int(data.get("leverage", 1) or 1), + trade_direction=str(data.get("tradeDirection", "long") or "long"), + strategy_config=data.get("strategyConfig") or {}, + config_snapshot={"indicatorId": int(indicator_id) if indicator_id is not None else None}, + status="failed", error_message=str(e), result=None, - code=str(data.get('indicatorCode', '') or ''), + code=str(data.get("indicatorCode", "") or ""), ) except Exception: pass - return jsonify({ - 'code': 0, - 'msg': f'Backtest failed: {str(e)}', - 'data': None - }), 500 + return jsonify({"code": 0, "msg": f"Backtest failed: {str(e)}", "data": None}), 500 -@backtest_bp.route('/backtest/history', methods=['GET']) +@backtest_bp.route("/backtest/history", methods=["GET"]) @login_required def get_backtest_history(): """ @@ -357,17 +333,17 @@ def get_backtest_history(): try: # Use current user's ID user_id = g.user_id - limit = int(request.args.get('limit') or 50) - offset = int(request.args.get('offset') or 0) + limit = int(request.args.get("limit") or 50) + offset = int(request.args.get("offset") or 0) limit = max(1, min(limit, 200)) offset = max(0, offset) - indicator_id = request.args.get('indicatorId') - strategy_id = request.args.get('strategyId') - run_type = (request.args.get('runType') or '').strip() - symbol = (request.args.get('symbol') or '').strip() - market = (request.args.get('market') or '').strip() - timeframe = (request.args.get('timeframe') or '').strip() + indicator_id = request.args.get("indicatorId") + strategy_id = request.args.get("strategyId") + run_type = (request.args.get("runType") or "").strip() + symbol = (request.args.get("symbol") or "").strip() + market = (request.args.get("market") or "").strip() + timeframe = (request.args.get("timeframe") or "").strip() rows = backtest_service.list_runs( user_id=user_id, limit=limit, @@ -380,14 +356,14 @@ def get_backtest_history(): timeframe=timeframe, ) - return jsonify({'code': 1, 'msg': 'OK', 'data': rows}) + return jsonify({"code": 1, "msg": "OK", "data": rows}) except Exception as e: logger.error(f"get_backtest_history failed: {e}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@backtest_bp.route('/backtest/get', methods=['GET']) +@backtest_bp.route("/backtest/get", methods=["GET"]) @login_required def get_backtest_run(): """ @@ -398,19 +374,19 @@ def get_backtest_run(): """ try: user_id = g.user_id - run_id = int(request.args.get('runId') or 0) + run_id = int(request.args.get("runId") or 0) if not run_id: - return jsonify({'code': 0, 'msg': 'runId is required', 'data': None}), 400 + return jsonify({"code": 0, "msg": "runId is required", "data": None}), 400 row = backtest_service.get_run(user_id=user_id, run_id=run_id) if not row: - return jsonify({'code': 0, 'msg': 'run not found', 'data': None}), 404 + return jsonify({"code": 0, "msg": "run not found", "data": None}), 404 - return jsonify({'code': 1, 'msg': 'OK', 'data': row}) + return jsonify({"code": 1, "msg": "OK", "data": row}) except Exception as e: logger.error(f"get_backtest_run failed: {e}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 def _heuristic_ai_advice(runs: list[dict], lang: str) -> str: @@ -463,15 +439,43 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str: # Minimal localized headings to keep heuristic readable across locales. headings = { - "zh-CN": {"overall": "【总体建议】", "params": "【参数建议(可直接改回测配置测试)】", "next": "【下一步建议的回测方法】"}, - "zh-TW": {"overall": "【總體建議】", "params": "【參數建議(可直接改回測配置測試)】", "next": "【下一步回測方法建議】"}, - "en-US": {"overall": "Overall", "params": "Parameter suggestions (edit backtest config and re-run)", "next": "Next steps"}, + "zh-CN": { + "overall": "【总体建议】", + "params": "【参数建议(可直接改回测配置测试)】", + "next": "【下一步建议的回测方法】", + }, + "zh-TW": { + "overall": "【總體建議】", + "params": "【參數建議(可直接改回測配置測試)】", + "next": "【下一步回測方法建議】", + }, + "en-US": { + "overall": "Overall", + "params": "Parameter suggestions (edit backtest config and re-run)", + "next": "Next steps", + }, "ko-KR": {"overall": "요약", "params": "파라미터 제안(백테스트 설정 변경)", "next": "다음 단계"}, "th-TH": {"overall": "สรุป", "params": "ข้อเสนอแนะพารามิเตอร์ (ปรับค่าที่ตั้งแบ็กเทสต์)", "next": "ขั้นตอนถัดไป"}, - "vi-VN": {"overall": "Tổng quan", "params": "Gợi ý tham số (sửa cấu hình backtest và chạy lại)", "next": "Bước tiếp theo"}, - "ar-SA": {"overall": "ملخص", "params": "اقتراحات المعلمات (عدّل إعدادات الاختبار وأعد التشغيل)", "next": "الخطوات التالية"}, - "de-DE": {"overall": "Überblick", "params": "Parameter-Vorschläge (Backtest-Konfiguration anpassen)", "next": "Nächste Schritte"}, - "fr-FR": {"overall": "Vue d’ensemble", "params": "Suggestions de paramètres (modifier la config et relancer)", "next": "Étapes suivantes"}, + "vi-VN": { + "overall": "Tổng quan", + "params": "Gợi ý tham số (sửa cấu hình backtest và chạy lại)", + "next": "Bước tiếp theo", + }, + "ar-SA": { + "overall": "ملخص", + "params": "اقتراحات المعلمات (عدّل إعدادات الاختبار وأعد التشغيل)", + "next": "الخطوات التالية", + }, + "de-DE": { + "overall": "Überblick", + "params": "Parameter-Vorschläge (Backtest-Konfiguration anpassen)", + "next": "Nächste Schritte", + }, + "fr-FR": { + "overall": "Vue d’ensemble", + "params": "Suggestions de paramètres (modifier la config et relancer)", + "next": "Étapes suivantes", + }, "ja-JP": {"overall": "概要", "params": "パラメータ提案(設定変更→再バックテスト)", "next": "次のステップ"}, } h = headings.get(lang, headings["en-US"]) @@ -479,62 +483,96 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str: lines = [] if lang == "en-US": if len(runs) > 1: - lines.append(f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id','')}; validate with A/B tests across runs.") + lines.append( + f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id', '')}; validate with A/B tests across runs." + ) lines.append(h["overall"]) elif lang == "zh-TW": if len(runs) > 1: - lines.append(f"已收到 {len(runs)} 條回測記錄。以下以記錄 #{r0.get('id','')} 為主給出參數調整建議,並建議你用多組記錄做 A/B 驗證。") + lines.append( + f"已收到 {len(runs)} 條回測記錄。以下以記錄 #{r0.get('id', '')} 為主給出參數調整建議,並建議你用多組記錄做 A/B 驗證。" + ) lines.append(h["overall"]) else: if len(runs) > 1: if lang == "ko-KR": - lines.append(f"{len(runs)}개의 백테스트 기록을 받았습니다. 아래는 #{r0.get('id','')} 기준으로 제안하며, 여러 기록으로 A/B 검증을 권장합니다.") + lines.append( + f"{len(runs)}개의 백테스트 기록을 받았습니다. 아래는 #{r0.get('id', '')} 기준으로 제안하며, 여러 기록으로 A/B 검증을 권장합니다." + ) elif lang == "th-TH": - lines.append(f"ได้รับประวัติแบ็กเทสต์ {len(runs)} รายการ ข้อเสนอแนะด้านล่างอิงจาก #{r0.get('id','')} และแนะนำให้ทำ A/B test เทียบหลายชุด") + lines.append( + f"ได้รับประวัติแบ็กเทสต์ {len(runs)} รายการ ข้อเสนอแนะด้านล่างอิงจาก #{r0.get('id', '')} และแนะนำให้ทำ A/B test เทียบหลายชุด" + ) elif lang == "vi-VN": - lines.append(f"Đã nhận {len(runs)} bản ghi backtest. Gợi ý bên dưới tập trung vào #{r0.get('id','')} và khuyến nghị A/B test với nhiều bản ghi.") + lines.append( + f"Đã nhận {len(runs)} bản ghi backtest. Gợi ý bên dưới tập trung vào #{r0.get('id', '')} và khuyến nghị A/B test với nhiều bản ghi." + ) elif lang == "ar-SA": - lines.append(f"تم استلام {len(runs)} من سجلات الاختبار الخلفي. تركّز الاقتراحات أدناه على التشغيل #{r0.get('id','')} مع توصية باختبارات A/B.") + lines.append( + f"تم استلام {len(runs)} من سجلات الاختبار الخلفي. تركّز الاقتراحات أدناه على التشغيل #{r0.get('id', '')} مع توصية باختبارات A/B." + ) elif lang == "de-DE": - lines.append(f"{len(runs)} Backtest-Läufe empfangen. Vorschläge unten fokussieren auf Lauf #{r0.get('id','')}; A/B-Tests über mehrere Läufe empfohlen.") + lines.append( + f"{len(runs)} Backtest-Läufe empfangen. Vorschläge unten fokussieren auf Lauf #{r0.get('id', '')}; A/B-Tests über mehrere Läufe empfohlen." + ) elif lang == "fr-FR": - lines.append(f"{len(runs)} exécutions de backtest reçues. Suggestions ci-dessous centrées sur #{r0.get('id','')}; A/B tests recommandés.") + lines.append( + f"{len(runs)} exécutions de backtest reçues. Suggestions ci-dessous centrées sur #{r0.get('id', '')}; A/B tests recommandés." + ) elif lang == "ja-JP": - lines.append(f"{len(runs)} 件のバックテスト記録を受け取りました。以下は #{r0.get('id','')} を中心に提案し、複数記録でA/B検証を推奨します。") + lines.append( + f"{len(runs)} 件のバックテスト記録を受け取りました。以下は #{r0.get('id', '')} を中心に提案し、複数記録でA/B検証を推奨します。" + ) else: - lines.append(f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id','')}; validate with A/B tests across runs.") + lines.append( + f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id', '')}; validate with A/B tests across runs." + ) lines.append(h["overall"]) if sharpe < 0 or total_return < 0: if lang == "en-US": - lines.append("- Strategy is losing/unstable: reduce risk first (lower entryPct, fewer/smaller scale-ins), then refine signal filters.") + lines.append( + "- Strategy is losing/unstable: reduce risk first (lower entryPct, fewer/smaller scale-ins), then refine signal filters." + ) elif lang == "zh-TW": - lines.append("- 目前策略偏虧損/不穩定:先降低風險暴露(降低開倉資金占比 entryPct、減少加倉次數/比例),再調整信號過濾。") + lines.append( + "- 目前策略偏虧損/不穩定:先降低風險暴露(降低開倉資金占比 entryPct、減少加倉次數/比例),再調整信號過濾。" + ) else: - lines.append("- 当前策略整体偏亏损/不稳定:优先降低风险暴露(降低开仓资金占比 entryPct、减少加仓次数/比例),再调信号过滤。") + lines.append( + "- 当前策略整体偏亏损/不稳定:优先降低风险暴露(降低开仓资金占比 entryPct、减少加仓次数/比例),再调信号过滤。" + ) if max_dd > 30: if lang == "en-US": - lines.append("- Max drawdown is high: tighten stop-loss or reduce leverage/entry size; consider enabling trailing to protect profits.") + lines.append( + "- Max drawdown is high: tighten stop-loss or reduce leverage/entry size; consider enabling trailing to protect profits." + ) elif lang == "zh-TW": lines.append("- 最大回撤偏大:建議優先收緊止損或降低槓桿/開倉倉位;同時考慮啟用移動止盈以保護盈利回撤。") else: lines.append("- 最大回撤较大:建议优先收紧止损或降低杠杆/开仓仓位;同时考虑启用移动止盈保护盈利回撤。") if trades < 10: if lang == "en-US": - lines.append("- Too few trades: rules may be too strict; relax thresholds or remove one filter to get enough samples.") + lines.append( + "- Too few trades: rules may be too strict; relax thresholds or remove one filter to get enough samples." + ) elif lang == "zh-TW": lines.append("- 交易次數偏少:可能條件過嚴,建議適度放寬信號門檻或減少過濾條件,確保有足夠樣本驗證。") else: lines.append("- 交易次数偏少:可能条件过严,建议适当放宽信号阈值或减少过滤条件,确保有足够样本验证。") if win_rate < 35 and profit_factor >= 1.2: if lang == "en-US": - lines.append("- Low win rate but decent PF: consider slightly wider stop-loss and use trailing to lock profits.") + lines.append( + "- Low win rate but decent PF: consider slightly wider stop-loss and use trailing to lock profits." + ) elif lang == "zh-TW": lines.append("- 勝率偏低但盈虧比不差:可考慮略放寬止損(讓盈利單跑起來),並用移動止盈鎖住利潤。") else: lines.append("- 胜率偏低但盈亏比不差:可以考虑放宽止损(让盈利单跑起来)并用移动止盈锁利润。") if win_rate >= 55 and profit_factor < 1.1: if lang == "en-US": - lines.append("- Win rate is OK but PF is low: raise take-profit or enable trailing to improve winners; avoid taking profits too early.") + lines.append( + "- Win rate is OK but PF is low: raise take-profit or enable trailing to improve winners; avoid taking profits too early." + ) elif lang == "zh-TW": lines.append("- 勝率不低但盈虧比偏小:考慮提高止盈或啟用移動止盈,讓單筆盈利更充分;避免過早止盈。") else: @@ -543,32 +581,56 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str: lines.append("\n" + h["params"]) if stop_loss <= 0: if lang == "en-US": - lines.append("- Stop-loss: set stopLossPct (margin PnL basis). For crypto leverage, start with 2%~6% (then consider leverage conversion) and grid test.") + lines.append( + "- Stop-loss: set stopLossPct (margin PnL basis). For crypto leverage, start with 2%~6% (then consider leverage conversion) and grid test." + ) elif lang == "zh-TW": - lines.append("- 止損:建議設定 stopLossPct(按保證金口徑)。在加密+槓桿下,先從 2%~6%(再結合槓桿換算)做網格測試。") + lines.append( + "- 止損:建議設定 stopLossPct(按保證金口徑)。在加密+槓桿下,先從 2%~6%(再結合槓桿換算)做網格測試。" + ) else: - lines.append("- 止损:建议设置 stopLossPct(按保证金口径)。在加密+杠杆下,先从 2%~6%(再结合杠杆换算)做网格测试。") + lines.append( + "- 止损:建议设置 stopLossPct(按保证金口径)。在加密+杠杆下,先从 2%~6%(再结合杠杆换算)做网格测试。" + ) else: if lang == "en-US": - lines.append(f"- Stop-loss: current stopLossPct={stop_loss:.4f} (margin basis). Test ±30% around it and monitor drawdown/liquidations.") + lines.append( + f"- Stop-loss: current stopLossPct={stop_loss:.4f} (margin basis). Test ±30% around it and monitor drawdown/liquidations." + ) elif lang == "zh-TW": - lines.append(f"- 止損:目前 stopLossPct={stop_loss:.4f}(保證金口徑)。建議圍繞它做 ±30% 區間測試,並觀察回撤/爆倉次數變化。") + lines.append( + f"- 止損:目前 stopLossPct={stop_loss:.4f}(保證金口徑)。建議圍繞它做 ±30% 區間測試,並觀察回撤/爆倉次數變化。" + ) else: - lines.append(f"- 止损:当前 stopLossPct={stop_loss:.4f}(保证金口径)。建议围绕它做 ±30% 的区间测试,并观察回撤/爆仓次数变化。") + lines.append( + f"- 止损:当前 stopLossPct={stop_loss:.4f}(保证金口径)。建议围绕它做 ±30% 的区间测试,并观察回撤/爆仓次数变化。" + ) if take_profit > 0 and (not trailing_enabled): if lang == "en-US": - lines.append(f"- Take-profit: current takeProfitPct={take_profit:.4f}. Also test enabling trailing to reduce profit giveback.") + lines.append( + f"- Take-profit: current takeProfitPct={take_profit:.4f}. Also test enabling trailing to reduce profit giveback." + ) elif lang == "zh-TW": - lines.append(f"- 止盈:目前 takeProfitPct={take_profit:.4f}。建議同時測試啟用移動止盈(trailing)以降低盈利回撤。") + lines.append( + f"- 止盈:目前 takeProfitPct={take_profit:.4f}。建議同時測試啟用移動止盈(trailing)以降低盈利回撤。" + ) else: - lines.append(f"- 止盈:当前 takeProfitPct={take_profit:.4f}。建议同时测试开启移动止盈(trailing)以降低盈利回撤。") + lines.append( + f"- 止盈:当前 takeProfitPct={take_profit:.4f}。建议同时测试开启移动止盈(trailing)以降低盈利回撤。" + ) if trailing_enabled: if lang == "en-US": - lines.append(f"- Trailing: enabled, pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}. Set activation near typical winner PnL and test pct at 0.5x~1.5x.") + lines.append( + f"- Trailing: enabled, pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}. Set activation near typical winner PnL and test pct at 0.5x~1.5x." + ) elif lang == "zh-TW": - lines.append(f"- 移動止盈:已啟用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建議將 activationPct 設為略低於常見單筆盈利水平,並把 pct 做 0.5x~1.5x 測試。") + lines.append( + f"- 移動止盈:已啟用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建議將 activationPct 設為略低於常見單筆盈利水平,並把 pct 做 0.5x~1.5x 測試。" + ) else: - lines.append(f"- 移动止盈:已启用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建议把 activationPct 设为略低于常见单笔盈利水平,并把 pct 做 0.5x~1.5x 测试。") + lines.append( + f"- 移动止盈:已启用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建议把 activationPct 设为略低于常见单笔盈利水平,并把 pct 做 0.5x~1.5x 测试。" + ) else: if lang == "en-US": lines.append("- Trailing: consider trailing.enabled=true; start with pct=1%~3% (margin basis) and test.") @@ -577,23 +639,37 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str: else: lines.append("- 移动止盈:建议开启 trailing.enabled=true,并从 pct=1%~3%(保证金口径换算后)开始测试。") if lang == "en-US": - lines.append(f"- Entry sizing: entryPct={entry_pct:.4f}. Test 0.2/0.3/0.5/0.8 to find a better return/drawdown sweet spot.") + lines.append( + f"- Entry sizing: entryPct={entry_pct:.4f}. Test 0.2/0.3/0.5/0.8 to find a better return/drawdown sweet spot." + ) elif lang == "zh-TW": - lines.append(f"- 開倉倉位:目前 entryPct={entry_pct:.4f}。建議先用 0.2/0.3/0.5/0.8 分層回測,找收益/回撤更優的甜區。") + lines.append( + f"- 開倉倉位:目前 entryPct={entry_pct:.4f}。建議先用 0.2/0.3/0.5/0.8 分層回測,找收益/回撤更優的甜區。" + ) else: - lines.append(f"- 开仓仓位:当前 entryPct={entry_pct:.4f}。建议先用 0.2/0.3/0.5/0.8 做分层回测,找收益/回撤更优的甜区。") + lines.append( + f"- 开仓仓位:当前 entryPct={entry_pct:.4f}。建议先用 0.2/0.3/0.5/0.8 做分层回测,找收益/回撤更优的甜区。" + ) # Scaling (very light guidance) if isinstance(trend_add, dict) and trend_add.get("enabled"): if lang == "en-US": - lines.append("- Trend scale-in: reduce sizePct or maxTimes to avoid drawdown expansion; verify same-bar conflict rules match expectations.") + lines.append( + "- Trend scale-in: reduce sizePct or maxTimes to avoid drawdown expansion; verify same-bar conflict rules match expectations." + ) elif lang == "zh-TW": - lines.append("- 順勢加倉:建議優先降低 sizePct 或 maxTimes,避免回撤擴大;並確認同K線主信號禁用加減倉規則符合預期。") + lines.append( + "- 順勢加倉:建議優先降低 sizePct 或 maxTimes,避免回撤擴大;並確認同K線主信號禁用加減倉規則符合預期。" + ) else: - lines.append("- 顺势加仓:建议优先降低 sizePct 或 maxTimes,避免回撤扩大;并确保同K线主信号禁用加减仓的规则与你预期一致。") + lines.append( + "- 顺势加仓:建议优先降低 sizePct 或 maxTimes,避免回撤扩大;并确保同K线主信号禁用加减仓的规则与你预期一致。" + ) if isinstance(dca_add, dict) and dca_add.get("enabled"): if lang == "en-US": - lines.append("- DCA scale-in: very risky under leverage; keep maxTimes small, sizePct low, and use stricter stop-loss.") + lines.append( + "- DCA scale-in: very risky under leverage; keep maxTimes small, sizePct low, and use stricter stop-loss." + ) elif lang == "zh-TW": lines.append("- 逆勢加倉:加密槓桿下風險極高,建議 maxTimes 更小、sizePct 更低,並採用更嚴格止損。") else: @@ -607,7 +683,9 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str: lines.append("- 顺势减仓:适合降低波动,但可能降低收益;建议和移动止盈一起对比测试。") if isinstance(adverse_reduce, dict) and adverse_reduce.get("enabled"): if lang == "en-US": - lines.append("- Adverse reduce: can control drawdowns but increases fees/slippage; consider enabling under higher leverage.") + lines.append( + "- Adverse reduce: can control drawdowns but increases fees/slippage; consider enabling under higher leverage." + ) elif lang == "zh-TW": lines.append("- 逆勢減倉:可用於控回撤,但可能增加手續費/滑點成本;建議優先在高槓桿時開啟。") else: @@ -622,12 +700,14 @@ def _heuristic_ai_advice(runs: list[dict], lang: str) -> str: lines.append("- 重點同時看:總收益、最大回撤、夏普、交易次數、爆倉/止損觸發次數。") else: # Keep English for other locales to ensure readability in fallback mode. - lines.append("- Keep signal logic fixed; run parameter grid tests (coarse → fine). Change only 1-2 params per run.") + lines.append( + "- Keep signal logic fixed; run parameter grid tests (coarse → fine). Change only 1-2 params per run." + ) lines.append("- Track: total return, max drawdown, Sharpe, trade count, liquidation/stop-loss triggers.") return "\n".join(lines) -@backtest_bp.route('/backtest/aiAnalyze', methods=['POST']) +@backtest_bp.route("/backtest/aiAnalyze", methods=["POST"]) @login_required def ai_analyze_backtest_runs(): """ @@ -641,16 +721,16 @@ def ai_analyze_backtest_runs(): data = request.get_json() or {} user_id = g.user_id backtest_service.ensure_storage_schema() - lang = _normalize_lang(data.get('lang')) - run_ids = data.get('runIds') or [] + lang = _normalize_lang(data.get("lang")) + run_ids = data.get("runIds") or [] if not isinstance(run_ids, list) or not run_ids: - return jsonify({'code': 0, 'msg': 'runIds is required', 'data': None}), 400 + return jsonify({"code": 0, "msg": "runIds is required", "data": None}), 400 # Limit to avoid huge prompts / payload. run_ids = [int(x) for x in run_ids if str(x).strip().isdigit()] run_ids = run_ids[:10] if not run_ids: - return jsonify({'code': 0, 'msg': 'runIds is required', 'data': None}), 400 + return jsonify({"code": 0, "msg": "runIds is required", "data": None}), 400 placeholders = ",".join(["?"] * len(run_ids)) with get_db_connection() as db: @@ -673,28 +753,28 @@ def ai_analyze_backtest_runs(): runs: list[dict] = [] for r in rows: try: - r['strategy_config'] = json.loads(r.get('strategy_config') or '{}') + r["strategy_config"] = json.loads(r.get("strategy_config") or "{}") except Exception: - r['strategy_config'] = {} + r["strategy_config"] = {} try: - r['config_snapshot'] = json.loads(r.get('config_snapshot') or '{}') + r["config_snapshot"] = json.loads(r.get("config_snapshot") or "{}") except Exception: - r['config_snapshot'] = {} + r["config_snapshot"] = {} try: - r['result'] = json.loads(r.get('result_json') or '{}') + r["result"] = json.loads(r.get("result_json") or "{}") except Exception: - r['result'] = {} - r.pop('result_json', None) + r["result"] = {} + r.pop("result_json", None) runs.append(r) if not runs: - return jsonify({'code': 0, 'msg': 'runs not found', 'data': None}), 404 + return jsonify({"code": 0, "msg": "runs not found", "data": None}), 404 # OpenRouter (optional) base_url, api_key = _openrouter_base_and_key() if not api_key: analysis = _heuristic_ai_advice(runs, lang) - return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'heuristic', 'lang': lang}}) + return jsonify({"code": 1, "msg": "OK", "data": {"analysis": analysis, "mode": "heuristic", "lang": lang}}) model = (os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini") or "").strip() or "openai/gpt-4o-mini" temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.4") or 0.4) @@ -767,21 +847,23 @@ def ai_analyze_backtest_runs(): analysis = content.strip() if not analysis: analysis = _heuristic_ai_advice(runs, lang) - return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'heuristic_fallback', 'lang': lang}}) - return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'llm', 'lang': lang}}) + return jsonify( + {"code": 1, "msg": "OK", "data": {"analysis": analysis, "mode": "heuristic_fallback", "lang": lang}} + ) + return jsonify({"code": 1, "msg": "OK", "data": {"analysis": analysis, "mode": "llm", "lang": lang}}) except requests.exceptions.RequestException as e: # Do not fail the whole endpoint if LLM provider is misconfigured or rate-limited. logger.error(f"OpenRouter request failed, falling back to heuristic: {e}") analysis = _heuristic_ai_advice(runs, lang) return jsonify( { - 'code': 1, - 'msg': 'OK', - 'data': { - 'analysis': analysis, - 'mode': 'heuristic_fallback', - 'lang': lang, - 'llmError': str(e), + "code": 1, + "msg": "OK", + "data": { + "analysis": analysis, + "mode": "heuristic_fallback", + "lang": lang, + "llmError": str(e), }, } ) @@ -789,5 +871,4 @@ def ai_analyze_backtest_runs(): except Exception as e: logger.error(f"ai_analyze_backtest_runs failed: {e}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 - + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 diff --git a/backend_api_python/app/routes/billing.py b/backend_api_python/app/routes/billing.py index 00875f6..fd83944 100644 --- a/backend_api_python/app/routes/billing.py +++ b/backend_api_python/app/routes/billing.py @@ -6,12 +6,12 @@ The current version first implements the minimum availability of "fast commercia - Users activate/issue points immediately after purchasing on the front end (can be replaced with a real payment gateway later) """ -from flask import Blueprint, jsonify, request, g +from flask import Blueprint, g, jsonify, request -from app.utils.auth import login_required -from app.utils.logger import get_logger from app.services.billing_service import get_billing_service from app.services.usdt_payment_service import get_usdt_payment_service +from app.utils.auth import login_required +from app.utils.logger import get_logger logger = get_logger(__name__) @@ -102,4 +102,3 @@ def usdt_get_order(order_id: int): except Exception as e: logger.error(f"usdt_get_order failed: {e}", exc_info=True) return jsonify({"code": 0, "msg": str(e), "data": None}), 500 - diff --git a/backend_api_python/app/routes/community.py b/backend_api_python/app/routes/community.py index 8ba59ad..a9ff6ae 100644 --- a/backend_api_python/app/routes/community.py +++ b/backend_api_python/app/routes/community.py @@ -4,11 +4,11 @@ Community APIs - indicator community interface REST API that provides indicator markets, buying, commenting, and more. """ -from flask import Blueprint, jsonify, request, g +from flask import Blueprint, g, jsonify, request +from app.services.community_service import get_community_service from app.utils.auth import login_required from app.utils.logger import get_logger -from app.services.community_service import get_community_service logger = get_logger(__name__) @@ -19,12 +19,13 @@ community_bp = Blueprint("community", __name__) # indicator market # ========================================== + @community_bp.route("/indicators", methods=["GET"]) @login_required def get_market_indicators(): """ Get a list of market indicators - + Query params: page: page number (default 1) page_size: Number of pages per page (default 12) @@ -33,15 +34,15 @@ def get_market_indicators(): sort_by: 'newest' / 'hot' / 'price_asc' / 'price_desc' / 'rating' """ try: - page = int(request.args.get('page', 1)) - page_size = int(request.args.get('page_size', 12)) - keyword = request.args.get('keyword', '').strip() - pricing_type = request.args.get('pricing_type', '').strip() or None - sort_by = request.args.get('sort_by', 'newest').strip() - + page = int(request.args.get("page", 1)) + page_size = int(request.args.get("page_size", 12)) + keyword = request.args.get("keyword", "").strip() + pricing_type = request.args.get("pricing_type", "").strip() or None + sort_by = request.args.get("sort_by", "newest").strip() + # Limit the number of pages per page page_size = min(max(page_size, 1), 50) - + service = get_community_service() result = service.get_market_indicators( page=page, @@ -49,14 +50,14 @@ def get_market_indicators(): keyword=keyword if keyword else None, pricing_type=pricing_type, sort_by=sort_by, - user_id=g.user_id + user_id=g.user_id, ) - - return jsonify({'code': 1, 'msg': 'success', 'data': result}) - + + return jsonify({"code": 1, "msg": "success", "data": result}) + except Exception as e: logger.error(f"get_market_indicators failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @community_bp.route("/indicators/", methods=["GET"]) @@ -66,27 +67,28 @@ def get_indicator_detail(indicator_id: int): try: service = get_community_service() result = service.get_indicator_detail(indicator_id, user_id=g.user_id) - + if not result: - return jsonify({'code': 0, 'msg': 'indicator_not_found', 'data': None}), 404 - - return jsonify({'code': 1, 'msg': 'success', 'data': result}) - + return jsonify({"code": 0, "msg": "indicator_not_found", "data": None}), 404 + + return jsonify({"code": 1, "msg": "success", "data": result}) + except Exception as e: logger.error(f"get_indicator_detail failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # ========================================== # Purchase function # ========================================== + @community_bp.route("/indicators//purchase", methods=["POST"]) @login_required def purchase_indicator(indicator_id: int): """ buy indicator - + will automatically: 1. Check whether the points are sufficient 2. Deduct buyer points and increase seller points @@ -95,19 +97,16 @@ def purchase_indicator(indicator_id: int): """ try: service = get_community_service() - success, message, data = service.purchase_indicator( - buyer_id=g.user_id, - indicator_id=indicator_id - ) - + success, message, data = service.purchase_indicator(buyer_id=g.user_id, indicator_id=indicator_id) + if success: - return jsonify({'code': 1, 'msg': message, 'data': data}) + return jsonify({"code": 1, "msg": message, "data": data}) else: - return jsonify({'code': 0, 'msg': message, 'data': data}), 400 - + return jsonify({"code": 0, "msg": message, "data": data}), 400 + except Exception as e: logger.error(f"purchase_indicator failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @community_bp.route("/my-purchases", methods=["GET"]) @@ -115,49 +114,42 @@ def purchase_indicator(indicator_id: int): def get_my_purchases(): """Get a list of indicators I purchased""" try: - page = int(request.args.get('page', 1)) - page_size = int(request.args.get('page_size', 20)) + page = int(request.args.get("page", 1)) + page_size = int(request.args.get("page_size", 20)) page_size = min(max(page_size, 1), 50) - + service = get_community_service() - result = service.get_my_purchases( - user_id=g.user_id, - page=page, - page_size=page_size - ) - - return jsonify({'code': 1, 'msg': 'success', 'data': result}) - + result = service.get_my_purchases(user_id=g.user_id, page=page, page_size=page_size) + + return jsonify({"code": 1, "msg": "success", "data": result}) + except Exception as e: logger.error(f"get_my_purchases failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # ========================================== # Comment function # ========================================== + @community_bp.route("/indicators//comments", methods=["GET"]) @login_required def get_comments(indicator_id: int): """Get a list of indicator comments""" try: - page = int(request.args.get('page', 1)) - page_size = int(request.args.get('page_size', 20)) + page = int(request.args.get("page", 1)) + page_size = int(request.args.get("page_size", 20)) page_size = min(max(page_size, 1), 50) - + service = get_community_service() - result = service.get_comments( - indicator_id=indicator_id, - page=page, - page_size=page_size - ) - - return jsonify({'code': 1, 'msg': 'success', 'data': result}) - + result = service.get_comments(indicator_id=indicator_id, page=page, page_size=page_size) + + return jsonify({"code": 1, "msg": "success", "data": result}) + except Exception as e: logger.error(f"get_comments failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @community_bp.route("/indicators//comments", methods=["POST"]) @@ -165,34 +157,31 @@ def get_comments(indicator_id: int): def add_comment(indicator_id: int): """ Add comment - + Request body: rating: 1-5 star rating content: Comment content (optional, up to 500 words) - + Note: Only users who have purchased can comment, and they can only comment once """ try: data = request.get_json() or {} - rating = int(data.get('rating', 5)) - content = (data.get('content') or '').strip() - + rating = int(data.get("rating", 5)) + content = (data.get("content") or "").strip() + service = get_community_service() success, message, result = service.add_comment( - user_id=g.user_id, - indicator_id=indicator_id, - rating=rating, - content=content + user_id=g.user_id, indicator_id=indicator_id, rating=rating, content=content ) - + if success: - return jsonify({'code': 1, 'msg': message, 'data': result}) + return jsonify({"code": 1, "msg": message, "data": result}) else: - return jsonify({'code': 0, 'msg': message, 'data': result}), 400 - + return jsonify({"code": 0, "msg": message, "data": result}), 400 + except Exception as e: logger.error(f"add_comment failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @community_bp.route("/indicators//comments/", methods=["PUT"]) @@ -200,33 +189,29 @@ def add_comment(indicator_id: int): def update_comment(indicator_id: int, comment_id: int): """ Update comments (you can only modify your own comments) - + Request body: rating: 1-5 star rating content: Comment content (up to 500 words) """ try: data = request.get_json() or {} - rating = int(data.get('rating', 5)) - content = (data.get('content') or '').strip() - + rating = int(data.get("rating", 5)) + content = (data.get("content") or "").strip() + service = get_community_service() success, message, result = service.update_comment( - user_id=g.user_id, - comment_id=comment_id, - indicator_id=indicator_id, - rating=rating, - content=content + user_id=g.user_id, comment_id=comment_id, indicator_id=indicator_id, rating=rating, content=content ) - + if success: - return jsonify({'code': 1, 'msg': message, 'data': result}) + return jsonify({"code": 1, "msg": message, "data": result}) else: - return jsonify({'code': 0, 'msg': message, 'data': result}), 400 - + return jsonify({"code": 0, "msg": message, "data": result}), 400 + except Exception as e: logger.error(f"update_comment failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @community_bp.route("/indicators//my-comment", methods=["GET"]) @@ -235,22 +220,20 @@ def get_my_comment(indicator_id: int): """Get the current user's comments on the specified indicator (for editing)""" try: service = get_community_service() - result = service.get_user_comment( - user_id=g.user_id, - indicator_id=indicator_id - ) - - return jsonify({'code': 1, 'msg': 'success', 'data': result}) - + result = service.get_user_comment(user_id=g.user_id, indicator_id=indicator_id) + + return jsonify({"code": 1, "msg": "success", "data": result}) + except Exception as e: logger.error(f"get_my_comment failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # ========================================== # Real offer performance # ========================================== + @community_bp.route("/indicators//performance", methods=["GET"]) @login_required def get_indicator_performance(indicator_id: int): @@ -258,22 +241,23 @@ def get_indicator_performance(indicator_id: int): try: service = get_community_service() result = service.get_indicator_performance(indicator_id) - - return jsonify({'code': 1, 'msg': 'success', 'data': result}) - + + return jsonify({"code": 1, "msg": "success", "data": result}) + except Exception as e: logger.error(f"get_indicator_performance failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # ========================================== # Administrator review function # ========================================== + def _is_admin(): """Check if the current user is an administrator""" - role = getattr(g, 'user_role', None) - return role == 'admin' + role = getattr(g, "user_role", None) + return role == "admin" @community_bp.route("/admin/pending-indicators", methods=["GET"]) @@ -281,7 +265,7 @@ def _is_admin(): def get_pending_indicators(): """ Get the list of indicators to be reviewed (for administrators only) - + Query params: page: page number (default 1) page_size: Number of pages per page (default 20) @@ -289,25 +273,21 @@ def get_pending_indicators(): """ try: if not _is_admin(): - return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403 - - page = int(request.args.get('page', 1)) - page_size = int(request.args.get('page_size', 20)) - review_status = request.args.get('review_status', 'pending').strip() or 'pending' + return jsonify({"code": 0, "msg": "admin_required", "data": None}), 403 + + page = int(request.args.get("page", 1)) + page_size = int(request.args.get("page_size", 20)) + review_status = request.args.get("review_status", "pending").strip() or "pending" page_size = min(max(page_size, 1), 100) - + service = get_community_service() - result = service.get_pending_indicators( - page=page, - page_size=page_size, - review_status=review_status - ) - - return jsonify({'code': 1, 'msg': 'success', 'data': result}) - + result = service.get_pending_indicators(page=page, page_size=page_size, review_status=review_status) + + return jsonify({"code": 1, "msg": "success", "data": result}) + except Exception as e: logger.error(f"get_pending_indicators failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @community_bp.route("/admin/review-stats", methods=["GET"]) @@ -316,16 +296,16 @@ def get_review_stats(): """Get audit statistics (for administrators only)""" try: if not _is_admin(): - return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403 - + return jsonify({"code": 0, "msg": "admin_required", "data": None}), 403 + service = get_community_service() result = service.get_review_stats() - - return jsonify({'code': 1, 'msg': 'success', 'data': result}) - + + return jsonify({"code": 1, "msg": "success", "data": result}) + except Exception as e: logger.error(f"get_review_stats failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @community_bp.route("/admin/indicators//review", methods=["POST"]) @@ -333,38 +313,35 @@ def get_review_stats(): def review_indicator(indicator_id: int): """ Audit indicators (for administrators only) - + Request body: action: 'approve' / 'reject' note: review notes (optional) """ try: if not _is_admin(): - return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403 - + return jsonify({"code": 0, "msg": "admin_required", "data": None}), 403 + data = request.get_json() or {} - action = data.get('action', '').strip() - note = data.get('note', '').strip() - - if action not in ('approve', 'reject'): - return jsonify({'code': 0, 'msg': 'invalid_action', 'data': None}), 400 - + action = data.get("action", "").strip() + note = data.get("note", "").strip() + + if action not in ("approve", "reject"): + return jsonify({"code": 0, "msg": "invalid_action", "data": None}), 400 + service = get_community_service() success, message = service.review_indicator( - admin_id=g.user_id, - indicator_id=indicator_id, - action=action, - note=note + admin_id=g.user_id, indicator_id=indicator_id, action=action, note=note ) - + if success: - return jsonify({'code': 1, 'msg': message, 'data': None}) + return jsonify({"code": 1, "msg": message, "data": None}) else: - return jsonify({'code': 0, 'msg': message, 'data': None}), 400 - + return jsonify({"code": 0, "msg": message, "data": None}), 400 + except Exception as e: logger.error(f"review_indicator failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @community_bp.route("/admin/indicators//unpublish", methods=["POST"]) @@ -372,32 +349,28 @@ def review_indicator(indicator_id: int): def unpublish_indicator(indicator_id: int): """ Delisting indicator (for administrators only) - + Request body: note: Reason for delisting (optional) """ try: if not _is_admin(): - return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403 - + return jsonify({"code": 0, "msg": "admin_required", "data": None}), 403 + data = request.get_json() or {} - note = data.get('note', '').strip() - + note = data.get("note", "").strip() + service = get_community_service() - success, message = service.unpublish_indicator( - admin_id=g.user_id, - indicator_id=indicator_id, - note=note - ) - + success, message = service.unpublish_indicator(admin_id=g.user_id, indicator_id=indicator_id, note=note) + if success: - return jsonify({'code': 1, 'msg': message, 'data': None}) + return jsonify({"code": 1, "msg": message, "data": None}) else: - return jsonify({'code': 0, 'msg': message, 'data': None}), 400 - + return jsonify({"code": 0, "msg": message, "data": None}), 400 + except Exception as e: logger.error(f"unpublish_indicator failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @community_bp.route("/admin/indicators/", methods=["DELETE"]) @@ -406,19 +379,16 @@ def admin_delete_indicator(indicator_id: int): """Delete indicator (only for administrators)""" try: if not _is_admin(): - return jsonify({'code': 0, 'msg': 'admin_required', 'data': None}), 403 - + return jsonify({"code": 0, "msg": "admin_required", "data": None}), 403 + service = get_community_service() - success, message = service.admin_delete_indicator( - admin_id=g.user_id, - indicator_id=indicator_id - ) - + success, message = service.admin_delete_indicator(admin_id=g.user_id, indicator_id=indicator_id) + if success: - return jsonify({'code': 1, 'msg': message, 'data': None}) + return jsonify({"code": 1, "msg": message, "data": None}) else: - return jsonify({'code': 0, 'msg': message, 'data': None}), 400 - + return jsonify({"code": 0, "msg": message, "data": None}), 400 + except Exception as e: logger.error(f"admin_delete_indicator failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 diff --git a/backend_api_python/app/routes/credentials.py b/backend_api_python/app/routes/credentials.py index b036400..4c1b8ad 100644 --- a/backend_api_python/app/routes/credentials.py +++ b/backend_api_python/app/routes/credentials.py @@ -4,32 +4,32 @@ Exchange credentials vault. encrypted_config stores Fernet ciphertext derived from SECRET_KEY (see app.utils.credential_crypto). """ -import traceback import json -from flask import Blueprint, request, jsonify, g +import traceback import requests as rq +from flask import Blueprint, g, jsonify, request +from app.utils.auth import login_required +from app.utils.credential_crypto import decrypt_credential_blob, encrypt_credential_blob from app.utils.db import get_db_connection from app.utils.logger import get_logger -from app.utils.auth import login_required -from app.utils.credential_crypto import encrypt_credential_blob, decrypt_credential_blob logger = get_logger(__name__) -credentials_bp = Blueprint('credentials', __name__) +credentials_bp = Blueprint("credentials", __name__) def _api_key_hint(api_key: str) -> str: if not api_key: - return '' + return "" s = str(api_key) if len(s) <= 8: - return s[:2] + '***' + return s[:2] + "***" return f"{s[:4]}...{s[-4:]}" -@credentials_bp.route('/list', methods=['GET']) +@credentials_bp.route("/list", methods=["GET"]) @login_required def list_credentials(): """List all credentials for the current user.""" @@ -45,7 +45,7 @@ def list_credentials(): WHERE user_id = %s ORDER BY id DESC """, - (user_id,) + (user_id,), ) rows = cur.fetchall() or [] cur.close() @@ -53,26 +53,35 @@ def list_credentials(): items = [] for row in rows: item = dict(row or {}) - item['enable_demo_trading'] = False + item["enable_demo_trading"] = False try: - plain = decrypt_credential_blob(item.get('encrypted_config')) + plain = decrypt_credential_blob(item.get("encrypted_config")) cfg = json.loads(plain) if plain else {} - item['enable_demo_trading'] = bool(cfg.get('enable_demo_trading') or cfg.get('enableDemoTrading')) + item["enable_demo_trading"] = bool(cfg.get("enable_demo_trading") or cfg.get("enableDemoTrading")) except Exception: - item['enable_demo_trading'] = False - item.pop('encrypted_config', None) + item["enable_demo_trading"] = False + item.pop("encrypted_config", None) items.append(item) - return jsonify({'code': 1, 'msg': 'success', 'data': {'items': items}}) + return jsonify({"code": 1, "msg": "success", "data": {"items": items}}) except Exception as e: logger.error(f"list_credentials failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500 + return jsonify({"code": 0, "msg": str(e), "data": {"items": []}}), 500 CRYPTO_EXCHANGES = [ - 'binance', 'okx', 'bitget', 'bybit', 'coinbaseexchange', - 'kraken', 'kucoin', 'gate', 'bitfinex', 'deepcoin', 'htx' + "binance", + "okx", + "bitget", + "bybit", + "coinbaseexchange", + "kraken", + "kucoin", + "gate", + "bitfinex", + "deepcoin", + "htx", ] @@ -89,7 +98,7 @@ def _egress_ipify(url: str) -> str: return "" -@credentials_bp.route('/egress-ip', methods=['GET']) +@credentials_bp.route("/egress-ip", methods=["GET"]) @login_required def get_egress_ip(): """ @@ -112,7 +121,7 @@ def get_egress_ip(): ) -@credentials_bp.route('/create', methods=['POST']) +@credentials_bp.route("/create", methods=["POST"]) @login_required def create_credential(): """Create a new credential for the current user. @@ -122,53 +131,59 @@ def create_credential(): try: user_id = g.user_id data = request.get_json() or {} - name = (data.get('name') or '').strip() - exchange_id = (data.get('exchange_id') or '').strip().lower() + name = (data.get("name") or "").strip() + exchange_id = (data.get("exchange_id") or "").strip().lower() if not exchange_id: - return jsonify({'code': 0, 'msg': 'Missing exchange_id', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Missing exchange_id", "data": None}), 400 - config = {'exchange_id': exchange_id} - hint = '' + config = {"exchange_id": exchange_id} + hint = "" - if exchange_id == 'ibkr': + if exchange_id == "ibkr": # Interactive Brokers (US stocks) - config.update({ - 'ibkr_host': (data.get('ibkr_host') or '127.0.0.1').strip(), - 'ibkr_port': int(data.get('ibkr_port') or 7497), - 'ibkr_client_id': int(data.get('ibkr_client_id') or 1), - 'ibkr_account': (data.get('ibkr_account') or '').strip() - }) + config.update( + { + "ibkr_host": (data.get("ibkr_host") or "127.0.0.1").strip(), + "ibkr_port": int(data.get("ibkr_port") or 7497), + "ibkr_client_id": int(data.get("ibkr_client_id") or 1), + "ibkr_account": (data.get("ibkr_account") or "").strip(), + } + ) hint = f"{config['ibkr_host']}:{config['ibkr_port']}" - elif exchange_id == 'mt5': + elif exchange_id == "mt5": # MetaTrader 5 (Forex) - mt5_server = (data.get('mt5_server') or '').strip() - mt5_login = str(data.get('mt5_login') or '').strip() - mt5_password = (data.get('mt5_password') or '').strip() + mt5_server = (data.get("mt5_server") or "").strip() + mt5_login = str(data.get("mt5_login") or "").strip() + mt5_password = (data.get("mt5_password") or "").strip() if not mt5_server or not mt5_login or not mt5_password: - return jsonify({'code': 0, 'msg': 'Missing mt5_server/mt5_login/mt5_password', 'data': None}), 400 - config.update({ - 'mt5_server': mt5_server, - 'mt5_login': mt5_login, - 'mt5_password': mt5_password, - 'mt5_terminal_path': (data.get('mt5_terminal_path') or '').strip() - }) + return jsonify({"code": 0, "msg": "Missing mt5_server/mt5_login/mt5_password", "data": None}), 400 + config.update( + { + "mt5_server": mt5_server, + "mt5_login": mt5_login, + "mt5_password": mt5_password, + "mt5_terminal_path": (data.get("mt5_terminal_path") or "").strip(), + } + ) hint = f"{mt5_server}/{mt5_login}" elif exchange_id in CRYPTO_EXCHANGES: # Crypto exchanges - api_key = (data.get('api_key') or '').strip() - secret_key = (data.get('secret_key') or '').strip() + api_key = (data.get("api_key") or "").strip() + secret_key = (data.get("secret_key") or "").strip() if not api_key or not secret_key: - return jsonify({'code': 0, 'msg': 'Missing api_key/secret_key', 'data': None}), 400 - config.update({ - 'api_key': api_key, - 'secret_key': secret_key, - 'passphrase': (data.get('passphrase') or '').strip(), - 'enable_demo_trading': bool(data.get('enable_demo_trading', False)) - }) + return jsonify({"code": 0, "msg": "Missing api_key/secret_key", "data": None}), 400 + config.update( + { + "api_key": api_key, + "secret_key": secret_key, + "passphrase": (data.get("passphrase") or "").strip(), + "enable_demo_trading": bool(data.get("enable_demo_trading", False)), + } + ) hint = _api_key_hint(api_key) else: - return jsonify({'code': 0, 'msg': f'Unsupported exchange: {exchange_id}', 'data': None}), 400 + return jsonify({"code": 0, "msg": f"Unsupported exchange: {exchange_id}", "data": None}), 400 plaintext_config = json.dumps(config, ensure_ascii=False) stored_blob = encrypt_credential_blob(plaintext_config) @@ -181,47 +196,44 @@ def create_credential(): VALUES (%s, %s, %s, %s, %s, NOW(), NOW()) RETURNING id """, - (user_id, name, exchange_id, hint, stored_blob) + (user_id, name, exchange_id, hint, stored_blob), ) row = cur.fetchone() - new_id = (row or {}).get('id') + new_id = (row or {}).get("id") db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}}) + return jsonify({"code": 1, "msg": "success", "data": {"id": new_id}}) except Exception as e: logger.error(f"create_credential failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@credentials_bp.route('/delete', methods=['DELETE']) +@credentials_bp.route("/delete", methods=["DELETE"]) @login_required def delete_credential(): """Delete a credential for the current user.""" try: user_id = g.user_id - cred_id = request.args.get('id', type=int) + cred_id = request.args.get("id", type=int) if not cred_id: - return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Missing id", "data": None}), 400 with get_db_connection() as db: cur = db.cursor() - cur.execute( - "DELETE FROM qd_exchange_credentials WHERE id = %s AND user_id = %s", - (cred_id, user_id) - ) + cur.execute("DELETE FROM qd_exchange_credentials WHERE id = %s AND user_id = %s", (cred_id, user_id)) db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': None}) + return jsonify({"code": 1, "msg": "success", "data": None}) except Exception as e: logger.error(f"delete_credential failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@credentials_bp.route('/get', methods=['GET']) +@credentials_bp.route("/get", methods=["GET"]) @login_required def get_credential(): """ @@ -229,9 +241,9 @@ def get_credential(): """ try: user_id = g.user_id - cred_id = request.args.get('id', type=int) + cred_id = request.args.get("id", type=int) if not cred_id: - return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Missing id", "data": None}), 400 with get_db_connection() as db: cur = db.cursor() @@ -241,34 +253,34 @@ def get_credential(): FROM qd_exchange_credentials WHERE id = %s AND user_id = %s """, - (cred_id, user_id) + (cred_id, user_id), ) row = cur.fetchone() cur.close() if not row: - return jsonify({'code': 0, 'msg': 'Not found', 'data': None}), 404 + return jsonify({"code": 0, "msg": "Not found", "data": None}), 404 - raw = row.get('encrypted_config') + raw = row.get("encrypted_config") plain = decrypt_credential_blob(raw) decrypted = json.loads(plain) if plain else {} # Ensure exchange_id is present - decrypted['exchange_id'] = row.get('exchange_id') or decrypted.get('exchange_id') + decrypted["exchange_id"] = row.get("exchange_id") or decrypted.get("exchange_id") - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'id': row.get('id'), - 'name': row.get('name'), - 'exchange_id': row.get('exchange_id'), - 'api_key_hint': row.get('api_key_hint'), - 'config': decrypted + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "id": row.get("id"), + "name": row.get("name"), + "exchange_id": row.get("exchange_id"), + "api_key_hint": row.get("api_key_hint"), + "config": decrypted, + }, } - }) + ) except Exception as e: logger.error(f"get_credential failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 - - + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 diff --git a/backend_api_python/app/routes/dashboard.py b/backend_api_python/app/routes/dashboard.py index 7b3f841..47412df 100644 --- a/backend_api_python/app/routes/dashboard.py +++ b/backend_api_python/app/routes/dashboard.py @@ -13,13 +13,13 @@ from __future__ import annotations import json import time -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List -from flask import Blueprint, jsonify, request, g +from flask import Blueprint, g, jsonify, request +from app.utils.auth import login_required from app.utils.db import get_db_connection from app.utils.logger import get_logger -from app.utils.auth import login_required logger = get_logger(__name__) @@ -44,7 +44,7 @@ def _format_datetime(dt: Any) -> Any: """Convert datetime object to ISO format string for JSON serialization.""" if dt is None: return None - if hasattr(dt, 'isoformat'): + if hasattr(dt, "isoformat"): return dt.isoformat() return dt @@ -96,7 +96,9 @@ def _calc_unrealized_pnl(side: str, entry_price: float, current_price: float, si return 0.0 -def _calc_pnl_percent(entry_price: float, size: float, pnl: float, leverage: float = 1.0, market_type: str = "spot") -> float: +def _calc_pnl_percent( + entry_price: float, size: float, pnl: float, leverage: float = 1.0, market_type: str = "spot" +) -> float: try: denom = float(entry_price or 0.0) * float(size or 0.0) if denom <= 0: @@ -189,7 +191,7 @@ def _compute_performance_stats(trades: List[Dict[str, Any]], initial_capital: fl # Calculate drawdown percentage: drawdown / peak_equity * 100 # If peak_equity is 0 or very small, use a fallback calculation if peak_equity > 0: - max_drawdown_pct = (max_drawdown / peak_equity * 100) + max_drawdown_pct = max_drawdown / peak_equity * 100 elif initial_capital > 0: # Fallback: use initial capital as baseline max_drawdown_pct = (max_drawdown / initial_capital * 100) if initial_capital > 0 else 0.0 @@ -202,10 +204,10 @@ def _compute_performance_stats(trades: List[Dict[str, Any]], initial_capital: fl cumulative.append(acc) peak_profit = max(cumulative) if cumulative else 0.0 if peak_profit > 0: - max_drawdown_pct = (max_drawdown / peak_profit * 100) + max_drawdown_pct = max_drawdown / peak_profit * 100 else: max_drawdown_pct = 0.0 - + # Cap drawdown percentage at reasonable maximum (e.g., 10000%) to avoid display issues if max_drawdown_pct > 10000: max_drawdown_pct = 10000.0 @@ -277,16 +279,18 @@ def _compute_strategy_stats(trades: List[Dict[str, Any]], strategies: List[Dict[ total_pnl = sum(_safe_float(t.get("profit"), 0.0) for t in strades) roi = (total_pnl / capital * 100) if capital > 0 else 0.0 - result.append({ - "strategy_id": sid, - "strategy_name": sid_to_name.get(sid, f"Strategy_{sid}"), - "total_trades": stats["total_trades"], - "win_rate": stats["win_rate"], - "profit_factor": stats["profit_factor"], - "total_pnl": round(total_pnl, 2), - "roi": round(roi, 2), - "max_drawdown": stats["max_drawdown"], - }) + result.append( + { + "strategy_id": sid, + "strategy_name": sid_to_name.get(sid, f"Strategy_{sid}"), + "total_trades": stats["total_trades"], + "win_rate": stats["win_rate"], + "profit_factor": stats["profit_factor"], + "total_pnl": round(total_pnl, 2), + "roi": round(roi, 2), + "max_drawdown": stats["max_drawdown"], + } + ) # Sort by total PnL descending result.sort(key=lambda x: x.get("total_pnl", 0), reverse=True) @@ -301,7 +305,7 @@ def summary(): """ try: user_id = g.user_id - + # Strategy counts (filtered by user_id) with get_db_connection() as db: cur = db.cursor() @@ -311,7 +315,7 @@ def summary(): FROM qd_strategies_trading WHERE user_id = ? """, - (user_id,) + (user_id,), ) strategies = cur.fetchall() or [] cur.close() @@ -347,7 +351,7 @@ def summary(): WHERE p.user_id = ? ORDER BY p.updated_at DESC """, - (user_id,) + (user_id,), ) rows = cur.fetchall() or [] cur.close() @@ -397,17 +401,17 @@ def summary(): ORDER BY t.created_at DESC LIMIT 500 """, - (user_id,) + (user_id,), ) recent_trades_raw = cur.fetchall() or [] cur.close() - + # Convert datetime to timestamp for frontend compatibility recent_trades = [] for t in recent_trades_raw: trade = dict(t) - if trade.get('created_at') and hasattr(trade['created_at'], 'timestamp'): - trade['created_at'] = int(trade['created_at'].timestamp()) + if trade.get("created_at") and hasattr(trade["created_at"], "timestamp"): + trade["created_at"] = int(trade["created_at"].timestamp()) recent_trades.append(trade) # Total equity/pnl (best-effort) - calculate before performance stats for drawdown calculation @@ -487,7 +491,7 @@ def summary(): # Calendar data: organized by month for monthly calendar view # Format: { "2024-01": { "days": { "01": 123.45, "02": -50.0, ... }, "total": 500.0 }, ... } import calendar as cal_module - from datetime import datetime, timedelta + from datetime import datetime calendar_data: Dict[str, Dict[str, Any]] = {} for d, p in day_to_profit.items(): @@ -524,10 +528,7 @@ def summary(): calendar_months = [] for month_key in sorted(calendar_data.keys(), reverse=True): data = calendar_data[month_key] - calendar_months.append({ - "month_key": month_key, - **data - }) + calendar_months.append({"month_key": month_key, **data}) return jsonify( { @@ -612,16 +613,32 @@ def pending_orders(): # Frontend expects these keys: # - filled_amount, filled_price, error_message filled_amount = float(r.get("filled") or 0.0) - filled_price = float(r.get("avg_price") or 0.0) if float(r.get("avg_price") or 0.0) > 0 else float(r.get("price") or 0.0) + filled_price = ( + float(r.get("avg_price") or 0.0) + if float(r.get("avg_price") or 0.0) > 0 + else float(r.get("price") or 0.0) + ) # Derive exchange_id + notify channels without leaking secrets to frontend. ex_cfg = _safe_json_loads(r.get("strategy_exchange_config"), {}) or {} notify_cfg = _safe_json_loads(r.get("strategy_notification_config"), {}) or {} - exchange_id = (r.get("exchange_id") or ex_cfg.get("exchange_id") or ex_cfg.get("exchangeId") or "").strip().lower() + exchange_id = ( + (r.get("exchange_id") or ex_cfg.get("exchange_id") or ex_cfg.get("exchangeId") or "").strip().lower() + ) notify_channels = _as_list((notify_cfg or {}).get("channels")) if not notify_channels: notify_channels = ["browser"] - market_type = (r.get("market_type") or r.get("strategy_market_type") or ex_cfg.get("market_type") or ex_cfg.get("marketType") or "").strip().lower() + market_type = ( + ( + r.get("market_type") + or r.get("strategy_market_type") + or ex_cfg.get("market_type") + or ex_cfg.get("marketType") + or "" + ) + .strip() + .lower() + ) market_category = str(r.get("strategy_market_category") or "").strip().lower() execution_mode = str(r.get("strategy_execution_mode") or r.get("execution_mode") or "").strip().lower() diff --git a/backend_api_python/app/routes/fast_analysis.py b/backend_api_python/app/routes/fast_analysis.py index 2a712d1..304906f 100644 --- a/backend_api_python/app/routes/fast_analysis.py +++ b/backend_api_python/app/routes/fast_analysis.py @@ -3,19 +3,21 @@ Fast Analysis API Routes New high-performance analysis endpoints that replace the slow multi-agent system. """ -from flask import Blueprint, request, jsonify, g + import threading import time -from app.utils.auth import login_required -from app.utils.logger import get_logger -from app.services.fast_analysis import get_fast_analysis_service +from flask import Blueprint, g, jsonify, request + from app.services.analysis_memory import get_analysis_memory from app.services.billing_service import get_billing_service +from app.services.fast_analysis import get_fast_analysis_service +from app.utils.auth import login_required +from app.utils.logger import get_logger logger = get_logger(__name__) -fast_analysis_bp = Blueprint('fast_analysis', __name__) +fast_analysis_bp = Blueprint("fast_analysis", __name__) # In-memory in-flight guard to avoid duplicate analysis charges caused by rapid repeated clicks. _analysis_inflight_lock = threading.Lock() @@ -28,19 +30,22 @@ def _try_refund_credits(user_id: int, amount: int, remark: str): if int(amount or 0) <= 0: return billing = get_billing_service() - billing.add_credits( - user_id=int(user_id), - amount=int(amount), - action='refund', - remark=remark - ) + billing.add_credits(user_id=int(user_id), amount=int(amount), action="refund", remark=remark) except Exception as e: logger.error(f"Async auto refund failed: {e}", exc_info=True) -def _run_async_analysis_task(task_memory_id: int, market: str, symbol: str, language: str, - model: str, timeframe: str, user_id: int, inflight_key: str, - credits_charged: int = 0): +def _run_async_analysis_task( + task_memory_id: int, + market: str, + symbol: str, + language: str, + model: str, + timeframe: str, + user_id: int, + inflight_key: str, + credits_charged: int = 0, +): """ Background worker: execute analysis and update pending history record. """ @@ -48,19 +53,14 @@ def _run_async_analysis_task(task_memory_id: int, market: str, symbol: str, lang service = get_fast_analysis_service() memory = get_analysis_memory() result = service.analyze( - market=market, - symbol=symbol, - language=language, - model=model, - timeframe=timeframe, - user_id=user_id + market=market, symbol=symbol, language=language, model=model, timeframe=timeframe, user_id=user_id ) memory.finalize_pending_task(task_memory_id, result) if result.get("error"): _try_refund_credits( user_id=int(user_id), amount=int(credits_charged or 0), - remark=f'Auto refund: async fast-analysis failed ({market}:{symbol}:{timeframe})' + remark=f"Auto refund: async fast-analysis failed ({market}:{symbol}:{timeframe})", ) # analyze() already stores a separate memory row; remove it to avoid duplicates. @@ -75,7 +75,7 @@ def _run_async_analysis_task(task_memory_id: int, market: str, symbol: str, lang _try_refund_credits( user_id=int(user_id), amount=int(credits_charged or 0), - remark=f'Auto refund: async fast-analysis exception ({market}:{symbol}:{timeframe})' + remark=f"Auto refund: async fast-analysis exception ({market}:{symbol}:{timeframe})", ) try: get_analysis_memory().fail_pending_task(task_memory_id, str(e)) @@ -110,12 +110,12 @@ def _release_inflight(key: str): _analysis_inflight.pop(key, None) -@fast_analysis_bp.route('/analyze', methods=['POST']) +@fast_analysis_bp.route("/analyze", methods=["POST"]) @login_required def analyze(): """ Fast AI analysis for any symbol. - + POST /api/fast-analysis/analyze Body: { "market": "Crypto" | "USStock" | "Forex" | ..., @@ -124,39 +124,37 @@ def analyze(): "model": "openai/gpt-4o" (optional), "timeframe": "1D" (optional) } - + Returns: Fast analysis result with actionable recommendations. """ try: data = request.get_json() or {} - - market = (data.get('market') or '').strip() - symbol = (data.get('symbol') or '').strip() - language = data.get('language', 'en-US') - model = data.get('model') - timeframe = data.get('timeframe', '1D') - async_submit = bool(data.get('async_submit', False)) - + + market = (data.get("market") or "").strip() + symbol = (data.get("symbol") or "").strip() + language = data.get("language", "en-US") + model = data.get("model") + timeframe = data.get("timeframe", "1D") + async_submit = bool(data.get("async_submit", False)) + if not market or not symbol: - return jsonify({ - 'code': 0, - 'msg': 'market and symbol are required', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": "market and symbol are required", "data": None}), 400 + # Get current user's ID to associate analysis with user - user_id = getattr(g, 'user_id', None) + user_id = getattr(g, "user_id", None) if not user_id: - return jsonify({'code': 0, 'msg': 'Unauthorized', 'data': None}), 401 + return jsonify({"code": 0, "msg": "Unauthorized", "data": None}), 401 inflight_key = _build_inflight_key(user_id, market, symbol, timeframe) if not _acquire_inflight(inflight_key, ttl_sec=90): - return jsonify({ - 'code': 0, - 'msg': 'Analysis already in progress for this symbol/timeframe. Please wait.', - 'data': {'in_progress': True} - }), 429 + return jsonify( + { + "code": 0, + "msg": "Analysis already in progress for this symbol/timeframe. Please wait.", + "data": {"in_progress": True}, + } + ), 429 # Billing / credits (best-effort, consistent with polymarket deep analysis) credits_charged = 0 @@ -166,30 +164,32 @@ def analyze(): try: billing = get_billing_service() if billing.is_billing_enabled(): - credits_charged = int(billing.get_feature_cost('ai_analysis') or 0) + credits_charged = int(billing.get_feature_cost("ai_analysis") or 0) if credits_charged > 0: ok, msg = billing.check_and_consume( user_id=int(user_id), - feature='ai_analysis', - reference_id=f"fast_analysis_{market}:{symbol}:{timeframe}" + feature="ai_analysis", + reference_id=f"fast_analysis_{market}:{symbol}:{timeframe}", ) if not ok: # Standardize insufficient credits message - if str(msg or "").startswith('insufficient_credits'): + if str(msg or "").startswith("insufficient_credits"): # Format: insufficient_credits:: - parts = str(msg).split(':') + parts = str(msg).split(":") cur = float(parts[1]) if len(parts) >= 2 else 0.0 req = float(parts[2]) if len(parts) >= 3 else float(credits_charged) - return jsonify({ - 'code': 0, - 'msg': 'Insufficient credits', - 'data': { - 'required': req, - 'current': cur, - 'shortage': max(0.0, req - cur), + return jsonify( + { + "code": 0, + "msg": "Insufficient credits", + "data": { + "required": req, + "current": cur, + "shortage": max(0.0, req - cur), + }, } - }), 400 - return jsonify({'code': 0, 'msg': f'Failed to deduct credits: {msg}', 'data': None}), 500 + ), 400 + return jsonify({"code": 0, "msg": f"Failed to deduct credits: {msg}", "data": None}), 500 billing_consumed = True # Query remaining credits after successful consumption try: @@ -199,155 +199,157 @@ def analyze(): except Exception as e: # Billing failure should not crash analysis by default, but should be visible in logs. logger.warning(f"Billing check failed (skipped): {e}", exc_info=True) - + service = get_fast_analysis_service() # Async submit mode: record "processing" immediately and return task id. if async_submit: memory = get_analysis_memory() pending_id = memory.create_pending_task( - market=market, - symbol=symbol, - language=language, - model=model or "", - timeframe=timeframe, - user_id=user_id + market=market, symbol=symbol, language=language, model=model or "", timeframe=timeframe, user_id=user_id ) if not pending_id: - return jsonify({'code': 0, 'msg': 'Failed to create analysis task', 'data': None}), 500 + return jsonify({"code": 0, "msg": "Failed to create analysis task", "data": None}), 500 t = threading.Thread( target=_run_async_analysis_task, - args=(int(pending_id), market, symbol, language, model, timeframe, int(user_id), inflight_key, int(credits_charged or 0)), - daemon=True + args=( + int(pending_id), + market, + symbol, + language, + model, + timeframe, + int(user_id), + inflight_key, + int(credits_charged or 0), + ), + daemon=True, ) t.start() # worker owns inflight release inflight_key = None - return jsonify({ - 'code': 1, - 'msg': 'submitted', - 'data': { - 'task_id': int(pending_id), - 'memory_id': int(pending_id), - 'status': 'processing', - 'market': market, - 'symbol': symbol, - 'timeframe': timeframe, - 'credits_charged': credits_charged, - 'remaining_credits': remaining_credits, + return jsonify( + { + "code": 1, + "msg": "submitted", + "data": { + "task_id": int(pending_id), + "memory_id": int(pending_id), + "status": "processing", + "market": market, + "symbol": symbol, + "timeframe": timeframe, + "credits_charged": credits_charged, + "remaining_credits": remaining_credits, + }, } - }) + ) result = service.analyze( - market=market, - symbol=symbol, - language=language, - model=model, - timeframe=timeframe, - user_id=user_id + market=market, symbol=symbol, language=language, model=model, timeframe=timeframe, user_id=user_id ) - - if result.get('error'): + + if result.get("error"): # Best-effort refund if we already charged but analysis failed. if billing_consumed and billing and credits_charged > 0: try: billing.add_credits( user_id=int(user_id), amount=int(credits_charged), - action='refund', - remark=f'Auto refund: fast-analysis failed ({market}:{symbol}:{timeframe})' + action="refund", + remark=f"Auto refund: fast-analysis failed ({market}:{symbol}:{timeframe})", ) remaining_credits = float(billing.get_user_credits(int(user_id))) except Exception as re: logger.error(f"Auto refund failed: {re}", exc_info=True) - return jsonify({ - 'code': 0, - 'msg': result['error'], - 'data': result - }), 500 - + return jsonify({"code": 0, "msg": result["error"], "data": result}), 500 + # memory_id is already set in service.analyze() -> _store_analysis_memory() # No need to store again here (would create duplicates) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - **(result or {}), - 'credits_charged': credits_charged, - 'remaining_credits': remaining_credits, + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + **(result or {}), + "credits_charged": credits_charged, + "remaining_credits": remaining_credits, + }, } - }) - + ) + except Exception as e: # Best-effort refund on unexpected error after charge. try: - if 'billing_consumed' in locals() and billing_consumed and 'billing' in locals() and billing and credits_charged > 0 and 'user_id' in locals() and user_id: + if ( + "billing_consumed" in locals() + and billing_consumed + and "billing" in locals() + and billing + and credits_charged > 0 + and "user_id" in locals() + and user_id + ): billing.add_credits( user_id=int(user_id), amount=int(credits_charged), - action='refund', - remark=f'Auto refund: fast-analysis exception ({market}:{symbol}:{timeframe})' + action="refund", + remark=f"Auto refund: fast-analysis exception ({market}:{symbol}:{timeframe})", ) except Exception: pass logger.error(f"Fast analysis API failed: {e}", exc_info=True) - return jsonify({ - 'code': 0, - 'msg': str(e), - 'data': None - }), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 finally: try: - if 'inflight_key' in locals() and inflight_key: + if "inflight_key" in locals() and inflight_key: _release_inflight(inflight_key) except Exception: pass -@fast_analysis_bp.route('/analyze-legacy', methods=['POST']) +@fast_analysis_bp.route("/analyze-legacy", methods=["POST"]) @login_required def analyze_legacy(): """ Fast analysis with legacy format output. For backward compatibility with existing frontend. - + POST /api/fast-analysis/analyze-legacy Body: Same as /analyze - + Returns: Result in multi-agent format for frontend compatibility. """ try: data = request.get_json() or {} - - market = (data.get('market') or '').strip() - symbol = (data.get('symbol') or '').strip() - language = data.get('language', 'en-US') - model = data.get('model') - timeframe = data.get('timeframe', '1D') - + + market = (data.get("market") or "").strip() + symbol = (data.get("symbol") or "").strip() + language = data.get("language", "en-US") + model = data.get("model") + timeframe = data.get("timeframe", "1D") + if not market or not symbol: - return jsonify({ - 'code': 0, - 'msg': 'market and symbol are required', - 'data': None - }), 400 + return jsonify({"code": 0, "msg": "market and symbol are required", "data": None}), 400 # Billing / credits (same behavior as /analyze) - user_id = getattr(g, 'user_id', None) + user_id = getattr(g, "user_id", None) if not user_id: - return jsonify({'code': 0, 'msg': 'Unauthorized', 'data': None}), 401 + return jsonify({"code": 0, "msg": "Unauthorized", "data": None}), 401 inflight_key = _build_inflight_key(user_id, market, symbol, timeframe) if not _acquire_inflight(inflight_key, ttl_sec=90): - return jsonify({ - 'code': 0, - 'msg': 'Analysis already in progress for this symbol/timeframe. Please wait.', - 'data': {'in_progress': True} - }), 429 + return jsonify( + { + "code": 0, + "msg": "Analysis already in progress for this symbol/timeframe. Please wait.", + "data": {"in_progress": True}, + } + ), 429 credits_charged = 0 remaining_credits = None @@ -356,28 +358,30 @@ def analyze_legacy(): try: billing = get_billing_service() if billing.is_billing_enabled(): - credits_charged = int(billing.get_feature_cost('ai_analysis') or 0) + credits_charged = int(billing.get_feature_cost("ai_analysis") or 0) if credits_charged > 0: ok, msg = billing.check_and_consume( user_id=int(user_id), - feature='ai_analysis', - reference_id=f"fast_analysis_legacy_{market}:{symbol}:{timeframe}" + feature="ai_analysis", + reference_id=f"fast_analysis_legacy_{market}:{symbol}:{timeframe}", ) if not ok: - if str(msg or "").startswith('insufficient_credits'): - parts = str(msg).split(':') + if str(msg or "").startswith("insufficient_credits"): + parts = str(msg).split(":") cur = float(parts[1]) if len(parts) >= 2 else 0.0 req = float(parts[2]) if len(parts) >= 3 else float(credits_charged) - return jsonify({ - 'code': 0, - 'msg': 'Insufficient credits', - 'data': { - 'required': req, - 'current': cur, - 'shortage': max(0.0, req - cur), + return jsonify( + { + "code": 0, + "msg": "Insufficient credits", + "data": { + "required": req, + "current": cur, + "shortage": max(0.0, req - cur), + }, } - }), 400 - return jsonify({'code': 0, 'msg': f'Failed to deduct credits: {msg}', 'data': None}), 500 + ), 400 + return jsonify({"code": 0, "msg": f"Failed to deduct credits: {msg}", "data": None}), 500 billing_consumed = True try: remaining_credits = float(billing.get_user_credits(int(user_id))) @@ -385,192 +389,161 @@ def analyze_legacy(): remaining_credits = None except Exception as e: logger.warning(f"Billing check failed (skipped): {e}", exc_info=True) - + service = get_fast_analysis_service() result = service.analyze_legacy_format( - market=market, - symbol=symbol, - language=language, - model=model, - timeframe=timeframe + market=market, symbol=symbol, language=language, model=model, timeframe=timeframe ) - - if result.get('error'): + + if result.get("error"): if billing_consumed and billing and credits_charged > 0: try: billing.add_credits( user_id=int(user_id), amount=int(credits_charged), - action='refund', - remark=f'Auto refund: fast-analysis-legacy failed ({market}:{symbol}:{timeframe})' + action="refund", + remark=f"Auto refund: fast-analysis-legacy failed ({market}:{symbol}:{timeframe})", ) remaining_credits = float(billing.get_user_credits(int(user_id))) except Exception as re: logger.error(f"Auto refund failed (legacy): {re}", exc_info=True) - return jsonify({ - 'code': 0, - 'msg': result['error'], - 'data': result - }), 500 - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - **(result or {}), - 'credits_charged': credits_charged, - 'remaining_credits': remaining_credits, + return jsonify({"code": 0, "msg": result["error"], "data": result}), 500 + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + **(result or {}), + "credits_charged": credits_charged, + "remaining_credits": remaining_credits, + }, } - }) - + ) + except Exception as e: try: - if 'billing_consumed' in locals() and billing_consumed and 'billing' in locals() and billing and credits_charged > 0 and 'user_id' in locals() and user_id: + if ( + "billing_consumed" in locals() + and billing_consumed + and "billing" in locals() + and billing + and credits_charged > 0 + and "user_id" in locals() + and user_id + ): billing.add_credits( user_id=int(user_id), amount=int(credits_charged), - action='refund', - remark=f'Auto refund: fast-analysis-legacy exception ({market}:{symbol}:{timeframe})' + action="refund", + remark=f"Auto refund: fast-analysis-legacy exception ({market}:{symbol}:{timeframe})", ) except Exception: pass logger.error(f"Fast analysis legacy API failed: {e}", exc_info=True) - return jsonify({ - 'code': 0, - 'msg': str(e), - 'data': None - }), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 finally: try: - if 'inflight_key' in locals() and inflight_key: + if "inflight_key" in locals() and inflight_key: _release_inflight(inflight_key) except Exception: pass -@fast_analysis_bp.route('/history', methods=['GET']) +@fast_analysis_bp.route("/history", methods=["GET"]) @login_required def get_history(): """ Get analysis history for a symbol. - + GET /api/fast-analysis/history?market=Crypto&symbol=BTC/USDT&days=7&limit=10 """ try: - market = request.args.get('market', '').strip() - symbol = request.args.get('symbol', '').strip() - days = int(request.args.get('days', 7)) - limit = min(int(request.args.get('limit', 10)), 50) - + market = request.args.get("market", "").strip() + symbol = request.args.get("symbol", "").strip() + days = int(request.args.get("days", 7)) + limit = min(int(request.args.get("limit", 10)), 50) + if not market or not symbol: - return jsonify({ - 'code': 0, - 'msg': 'market and symbol are required', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": "market and symbol are required", "data": None}), 400 + memory = get_analysis_memory() history = memory.get_recent(market, symbol, days, limit) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'items': history, - 'total': len(history) - } - }) - + + return jsonify({"code": 1, "msg": "success", "data": {"items": history, "total": len(history)}}) + except Exception as e: logger.error(f"Get history failed: {e}") - return jsonify({ - 'code': 0, - 'msg': str(e), - 'data': None - }), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@fast_analysis_bp.route('/history/all', methods=['GET']) +@fast_analysis_bp.route("/history/all", methods=["GET"]) @login_required def get_all_history(): """ Get all analysis history with pagination. - + GET /api/fast-analysis/history/all?page=1&pagesize=20 """ try: - page = int(request.args.get('page', 1)) - pagesize = min(int(request.args.get('pagesize', 20)), 50) - + page = int(request.args.get("page", 1)) + pagesize = min(int(request.args.get("pagesize", 20)), 50) + # Get current user's ID to filter history - user_id = getattr(g, 'user_id', None) - + user_id = getattr(g, "user_id", None) + memory = get_analysis_memory() result = memory.get_all_history(user_id=user_id, page=page, page_size=pagesize) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'list': result['items'], - 'total': result['total'], - 'page': result['page'], - 'pagesize': result['page_size'] + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "list": result["items"], + "total": result["total"], + "page": result["page"], + "pagesize": result["page_size"], + }, } - }) - + ) + except Exception as e: logger.error(f"Get all history failed: {e}") - return jsonify({ - 'code': 0, - 'msg': str(e), - 'data': None - }), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@fast_analysis_bp.route('/history/', methods=['DELETE']) +@fast_analysis_bp.route("/history/", methods=["DELETE"]) @login_required def delete_history(memory_id: int): """ Delete a history record. - + DELETE /api/fast-analysis/history/123 """ try: # Get current user's ID to ensure they can only delete their own records - user_id = getattr(g, 'user_id', None) - + user_id = getattr(g, "user_id", None) + memory = get_analysis_memory() success = memory.delete_history(memory_id, user_id=user_id) - + if success: - return jsonify({ - 'code': 1, - 'msg': 'Deleted successfully', - 'data': None - }) + return jsonify({"code": 1, "msg": "Deleted successfully", "data": None}) else: - return jsonify({ - 'code': 0, - 'msg': 'Record not found or no permission', - 'data': None - }), 404 - + return jsonify({"code": 0, "msg": "Record not found or no permission", "data": None}), 404 + except Exception as e: logger.error(f"Delete history failed: {e}") - return jsonify({ - 'code': 0, - 'msg': str(e), - 'data': None - }), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@fast_analysis_bp.route('/feedback', methods=['POST']) +@fast_analysis_bp.route("/feedback", methods=["POST"]) @login_required def submit_feedback(): """ Submit user feedback on an analysis. - + POST /api/fast-analysis/feedback Body: { "memory_id": 123, @@ -579,119 +552,89 @@ def submit_feedback(): """ try: data = request.get_json() or {} - - memory_id = int(data.get('memory_id', 0)) - feedback = (data.get('feedback') or '').strip() - + + memory_id = int(data.get("memory_id", 0)) + feedback = (data.get("feedback") or "").strip() + if not memory_id or not feedback: - return jsonify({ - 'code': 0, - 'msg': 'memory_id and feedback are required', - 'data': None - }), 400 - - valid_feedback = ['helpful', 'not_helpful', 'accurate', 'inaccurate'] + return jsonify({"code": 0, "msg": "memory_id and feedback are required", "data": None}), 400 + + valid_feedback = ["helpful", "not_helpful", "accurate", "inaccurate"] if feedback not in valid_feedback: - return jsonify({ - 'code': 0, - 'msg': f'feedback must be one of: {valid_feedback}', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": f"feedback must be one of: {valid_feedback}", "data": None}), 400 + memory = get_analysis_memory() success = memory.record_feedback(memory_id, feedback) - - return jsonify({ - 'code': 1 if success else 0, - 'msg': 'success' if success else 'failed', - 'data': None - }) - + + return jsonify({"code": 1 if success else 0, "msg": "success" if success else "failed", "data": None}) + except Exception as e: logger.error(f"Submit feedback failed: {e}") - return jsonify({ - 'code': 0, - 'msg': str(e), - 'data': None - }), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@fast_analysis_bp.route('/performance', methods=['GET']) +@fast_analysis_bp.route("/performance", methods=["GET"]) @login_required def get_performance(): """ Get AI analysis performance statistics. - + GET /api/fast-analysis/performance?market=Crypto&symbol=BTC/USDT&days=30 """ try: - market = request.args.get('market', '').strip() or None - symbol = request.args.get('symbol', '').strip() or None - days = int(request.args.get('days', 30)) - + market = request.args.get("market", "").strip() or None + symbol = request.args.get("symbol", "").strip() or None + days = int(request.args.get("days", 30)) + memory = get_analysis_memory() stats = memory.get_performance_stats(market, symbol, days) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': stats - }) - + + return jsonify({"code": 1, "msg": "success", "data": stats}) + except Exception as e: logger.error(f"Get performance failed: {e}") - return jsonify({ - 'code': 0, - 'msg': str(e), - 'data': None - }), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@fast_analysis_bp.route('/similar-patterns', methods=['GET']) +@fast_analysis_bp.route("/similar-patterns", methods=["GET"]) @login_required def get_similar_patterns(): """ Get similar historical patterns for current market conditions. - + GET /api/fast-analysis/similar-patterns?market=Crypto&symbol=BTC/USDT """ try: - market = request.args.get('market', '').strip() - symbol = request.args.get('symbol', '').strip() - + market = request.args.get("market", "").strip() + symbol = request.args.get("symbol", "").strip() + if not market or not symbol: - return jsonify({ - 'code': 0, - 'msg': 'market and symbol are required', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": "market and symbol are required", "data": None}), 400 + # Get current indicators service = get_fast_analysis_service() data = service._collect_market_data(market, symbol) - indicators = data.get('indicators', {}) - + indicators = data.get("indicators", {}) + # Find similar patterns memory = get_analysis_memory() patterns = memory.get_similar_patterns(market, symbol, indicators) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'patterns': patterns, - 'current_indicators': { - 'rsi': indicators.get('rsi', {}).get('value'), - 'macd_signal': indicators.get('macd', {}).get('signal'), - 'trend': indicators.get('moving_averages', {}).get('trend'), - } + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "patterns": patterns, + "current_indicators": { + "rsi": indicators.get("rsi", {}).get("value"), + "macd_signal": indicators.get("macd", {}).get("signal"), + "trend": indicators.get("moving_averages", {}).get("trend"), + }, + }, } - }) - + ) + except Exception as e: logger.error(f"Get similar patterns failed: {e}") - return jsonify({ - 'code': 0, - 'msg': str(e), - 'data': None - }), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 diff --git a/backend_api_python/app/routes/global_market.py b/backend_api_python/app/routes/global_market.py index 0726d46..45f589f 100644 --- a/backend_api_python/app/routes/global_market.py +++ b/backend_api_python/app/routes/global_market.py @@ -22,16 +22,15 @@ Endpoints: from __future__ import annotations import time -import requests +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta from typing import Any, Dict, List, Optional -from concurrent.futures import ThreadPoolExecutor, as_completed -from flask import Blueprint, jsonify, request, g +import requests +from flask import Blueprint, jsonify, request -from app.utils.logger import get_logger from app.utils.auth import login_required -from app.utils.config_loader import load_addon_config +from app.utils.logger import get_logger logger = get_logger(__name__) @@ -44,16 +43,16 @@ _cache_ttl = 60 # Default 60 seconds cache # Cache time configuration (seconds) CACHE_TTL = { - "crypto_heatmap": 300, # 5 minutes - Cryptocurrencies change fast but heatmaps don’t need to be real-time - "forex_pairs": 120, # 2 minutes - Forex intraday fluctuations are small - "stock_indices": 120, # 2 minutes - index changes slowly - "market_overview": 120, # 2 minutes - overview data - "market_heatmap": 120, # 2 minutes - heat map - "commodities": 120, # 2 minutes - Commodities - "market_news": 180, # 3 minutes - News - "economic_calendar": 3600, # 1 hour - calendar event - "market_sentiment": 21600, # 6 hours - Macro sentiment changes slowly - "trading_opportunities": 3600, # 1 hour - updated every hour + "crypto_heatmap": 300, # 5 minutes - Cryptocurrencies change fast but heatmaps don’t need to be real-time + "forex_pairs": 120, # 2 minutes - Forex intraday fluctuations are small + "stock_indices": 120, # 2 minutes - index changes slowly + "market_overview": 120, # 2 minutes - overview data + "market_heatmap": 120, # 2 minutes - heat map + "commodities": 120, # 2 minutes - Commodities + "market_news": 180, # 3 minutes - News + "economic_calendar": 3600, # 1 hour - calendar event + "market_sentiment": 21600, # 6 hours - Macro sentiment changes slowly + "trading_opportunities": 3600, # 1 hour - updated every hour } @@ -70,11 +69,7 @@ def _get_cached(key: str, ttl: int = None) -> Optional[Any]: def _set_cached(key: str, data: Any, ttl: int = None): """Set cache entry.""" - _cache[key] = { - "ts": time.time(), - "data": data, - "ttl": ttl or CACHE_TTL.get(key, _cache_ttl) - } + _cache[key] = {"ts": time.time(), "data": data, "ttl": ttl or CACHE_TTL.get(key, _cache_ttl)} def _safe_float(v: Any, default: float = 0.0) -> float: @@ -86,41 +81,56 @@ def _safe_float(v: Any, default: float = 0.0) -> float: # ============ Data Fetchers ============ + def _fetch_crypto_prices_ccxt() -> List[Dict[str, Any]]: """Fetch crypto prices using CCXT (system's existing data source).""" try: from app.data_sources.crypto import CryptoDataSource - + crypto_source = CryptoDataSource() - + # Top crypto symbols to fetch symbols = [ - "BTC/USDT", "ETH/USDT", "BNB/USDT", "SOL/USDT", "XRP/USDT", - "ADA/USDT", "DOGE/USDT", "AVAX/USDT", "DOT/USDT", "MATIC/USDT", - "LINK/USDT", "LTC/USDT", "UNI/USDT", "ATOM/USDT", "XLM/USDT" + "BTC/USDT", + "ETH/USDT", + "BNB/USDT", + "SOL/USDT", + "XRP/USDT", + "ADA/USDT", + "DOGE/USDT", + "AVAX/USDT", + "DOT/USDT", + "MATIC/USDT", + "LINK/USDT", + "LTC/USDT", + "UNI/USDT", + "ATOM/USDT", + "XLM/USDT", ] - + result = [] for symbol in symbols: try: ticker = crypto_source.get_ticker(symbol) if ticker: base = symbol.split("/")[0] - result.append({ - "symbol": base, - "name": base, - "price": _safe_float(ticker.get("last") or ticker.get("close")), - "change_24h": _safe_float(ticker.get("percentage", 0)), - "change_7d": 0, # CCXT doesn't provide 7d change - "market_cap": 0, - "volume_24h": _safe_float(ticker.get("quoteVolume", 0)), - "image": "", - "category": "crypto" - }) + result.append( + { + "symbol": base, + "name": base, + "price": _safe_float(ticker.get("last") or ticker.get("close")), + "change_24h": _safe_float(ticker.get("percentage", 0)), + "change_7d": 0, # CCXT doesn't provide 7d change + "market_cap": 0, + "volume_24h": _safe_float(ticker.get("quoteVolume", 0)), + "image": "", + "category": "crypto", + } + ) except Exception as e: logger.debug(f"Failed to fetch {symbol}: {e}") continue - + return result except Exception as e: logger.error(f"Failed to fetch crypto prices via CCXT: {e}") @@ -131,7 +141,7 @@ def _fetch_crypto_prices_yfinance() -> List[Dict[str, Any]]: """Fetch crypto prices using yfinance as alternative.""" try: import yfinance as yf - + symbols = [ {"yf": "BTC-USD", "symbol": "BTC", "name": "Bitcoin"}, {"yf": "ETH-USD", "symbol": "ETH", "name": "Ethereum"}, @@ -146,10 +156,10 @@ def _fetch_crypto_prices_yfinance() -> List[Dict[str, Any]]: {"yf": "LINK-USD", "symbol": "LINK", "name": "Chainlink"}, {"yf": "LTC-USD", "symbol": "LTC", "name": "Litecoin"}, ] - + yf_symbols = [s["yf"] for s in symbols] tickers = yf.Tickers(" ".join(yf_symbols)) - + result = [] for crypto in symbols: try: @@ -160,32 +170,36 @@ def _fetch_crypto_prices_yfinance() -> List[Dict[str, Any]]: prev = hist["Close"].iloc[-2] curr = hist["Close"].iloc[-1] change = ((curr - prev) / prev) * 100 - result.append({ - "symbol": crypto["symbol"], - "name": crypto["name"], - "price": round(curr, 2), - "change_24h": round(change, 2), - "change_7d": 0, - "market_cap": 0, - "volume_24h": 0, - "image": "", - "category": "crypto" - }) + result.append( + { + "symbol": crypto["symbol"], + "name": crypto["name"], + "price": round(curr, 2), + "change_24h": round(change, 2), + "change_7d": 0, + "market_cap": 0, + "volume_24h": 0, + "image": "", + "category": "crypto", + } + ) elif len(hist) == 1: - result.append({ - "symbol": crypto["symbol"], - "name": crypto["name"], - "price": round(hist["Close"].iloc[-1], 2), - "change_24h": 0, - "change_7d": 0, - "market_cap": 0, - "volume_24h": 0, - "image": "", - "category": "crypto" - }) + result.append( + { + "symbol": crypto["symbol"], + "name": crypto["name"], + "price": round(hist["Close"].iloc[-1], 2), + "change_24h": 0, + "change_7d": 0, + "market_cap": 0, + "volume_24h": 0, + "image": "", + "category": "crypto", + } + ) except Exception as e: logger.debug(f"Failed to fetch {crypto['yf']}: {e}") - + return result except Exception as e: logger.error(f"Failed to fetch crypto via yfinance: {e}") @@ -199,13 +213,13 @@ def _fetch_crypto_prices() -> List[Dict[str, Any]]: if result and len(result) >= 5: logger.info(f"Fetched {len(result)} crypto prices via CCXT") return result - + # Try yfinance as second option result = _fetch_crypto_prices_yfinance() if result and len(result) >= 5: logger.info(f"Fetched {len(result)} crypto prices via yfinance") return result - + # Fallback to CoinGecko try: url = "https://api.coingecko.com/api/v3/coins/markets" @@ -215,38 +229,90 @@ def _fetch_crypto_prices() -> List[Dict[str, Any]]: "per_page": 30, "page": 1, "sparkline": False, - "price_change_percentage": "24h,7d" + "price_change_percentage": "24h,7d", } resp = requests.get(url, params=params, timeout=10) resp.raise_for_status() data = resp.json() - + result = [] for coin in data: - result.append({ - "symbol": coin.get("symbol", "").upper(), - "name": coin.get("name", ""), - "price": _safe_float(coin.get("current_price")), - "change_24h": _safe_float(coin.get("price_change_percentage_24h")), - "change_7d": _safe_float(coin.get("price_change_percentage_7d_in_currency")), - "market_cap": _safe_float(coin.get("market_cap")), - "volume_24h": _safe_float(coin.get("total_volume")), - "image": coin.get("image", ""), - "category": "crypto" - }) + result.append( + { + "symbol": coin.get("symbol", "").upper(), + "name": coin.get("name", ""), + "price": _safe_float(coin.get("current_price")), + "change_24h": _safe_float(coin.get("price_change_percentage_24h")), + "change_7d": _safe_float(coin.get("price_change_percentage_7d_in_currency")), + "market_cap": _safe_float(coin.get("market_cap")), + "volume_24h": _safe_float(coin.get("total_volume")), + "image": coin.get("image", ""), + "category": "crypto", + } + ) logger.info(f"Fetched {len(result)} crypto prices via CoinGecko") return result except Exception as e: logger.error(f"Failed to fetch crypto prices from CoinGecko: {e}") - + # Last resort: return placeholder data for display logger.warning("All crypto data sources failed, returning placeholder data") return [ - {"symbol": "BTC", "name": "Bitcoin", "price": 0, "change_24h": 0, "change_7d": 0, "market_cap": 0, "volume_24h": 0, "image": "", "category": "crypto"}, - {"symbol": "ETH", "name": "Ethereum", "price": 0, "change_24h": 0, "change_7d": 0, "market_cap": 0, "volume_24h": 0, "image": "", "category": "crypto"}, - {"symbol": "BNB", "name": "BNB", "price": 0, "change_24h": 0, "change_7d": 0, "market_cap": 0, "volume_24h": 0, "image": "", "category": "crypto"}, - {"symbol": "SOL", "name": "Solana", "price": 0, "change_24h": 0, "change_7d": 0, "market_cap": 0, "volume_24h": 0, "image": "", "category": "crypto"}, - {"symbol": "XRP", "name": "XRP", "price": 0, "change_24h": 0, "change_7d": 0, "market_cap": 0, "volume_24h": 0, "image": "", "category": "crypto"}, + { + "symbol": "BTC", + "name": "Bitcoin", + "price": 0, + "change_24h": 0, + "change_7d": 0, + "market_cap": 0, + "volume_24h": 0, + "image": "", + "category": "crypto", + }, + { + "symbol": "ETH", + "name": "Ethereum", + "price": 0, + "change_24h": 0, + "change_7d": 0, + "market_cap": 0, + "volume_24h": 0, + "image": "", + "category": "crypto", + }, + { + "symbol": "BNB", + "name": "BNB", + "price": 0, + "change_24h": 0, + "change_7d": 0, + "market_cap": 0, + "volume_24h": 0, + "image": "", + "category": "crypto", + }, + { + "symbol": "SOL", + "name": "Solana", + "price": 0, + "change_24h": 0, + "change_7d": 0, + "market_cap": 0, + "volume_24h": 0, + "image": "", + "category": "crypto", + }, + { + "symbol": "XRP", + "name": "XRP", + "price": 0, + "change_24h": 0, + "change_7d": 0, + "market_cap": 0, + "volume_24h": 0, + "image": "", + "category": "crypto", + }, ] @@ -254,36 +320,116 @@ def _fetch_stock_indices() -> List[Dict[str, Any]]: """Fetch major stock indices using yfinance.""" indices = [ # US Markets - Coordinates are staggered to avoid overlap - {"symbol": "^GSPC", "name_cn": "标普500", "name_en": "S&P 500", "region": "US", "flag": "🇺🇸", "lat": 40.7, "lng": -74.0}, - {"symbol": "^DJI", "name_cn": "道琼斯", "name_en": "Dow Jones", "region": "US", "flag": "🇺🇸", "lat": 38.5, "lng": -77.0}, - {"symbol": "^IXIC", "name_cn": "纳斯达克", "name_en": "NASDAQ", "region": "US", "flag": "🇺🇸", "lat": 37.5, "lng": -122.4}, + { + "symbol": "^GSPC", + "name_cn": "标普500", + "name_en": "S&P 500", + "region": "US", + "flag": "🇺🇸", + "lat": 40.7, + "lng": -74.0, + }, + { + "symbol": "^DJI", + "name_cn": "道琼斯", + "name_en": "Dow Jones", + "region": "US", + "flag": "🇺🇸", + "lat": 38.5, + "lng": -77.0, + }, + { + "symbol": "^IXIC", + "name_cn": "纳斯达克", + "name_en": "NASDAQ", + "region": "US", + "flag": "🇺🇸", + "lat": 37.5, + "lng": -122.4, + }, # Europe - {"symbol": "^GDAXI", "name_cn": "德国DAX", "name_en": "DAX", "region": "EU", "flag": "🇩🇪", "lat": 50.1109, "lng": 8.6821}, - {"symbol": "^FTSE", "name_cn": "英国富时100", "name_en": "FTSE 100", "region": "EU", "flag": "🇬🇧", "lat": 51.5074, "lng": -0.1278}, - {"symbol": "^FCHI", "name_cn": "法国CAC40", "name_en": "CAC 40", "region": "EU", "flag": "🇫🇷", "lat": 48.8566, "lng": 2.3522}, + { + "symbol": "^GDAXI", + "name_cn": "德国DAX", + "name_en": "DAX", + "region": "EU", + "flag": "🇩🇪", + "lat": 50.1109, + "lng": 8.6821, + }, + { + "symbol": "^FTSE", + "name_cn": "英国富时100", + "name_en": "FTSE 100", + "region": "EU", + "flag": "🇬🇧", + "lat": 51.5074, + "lng": -0.1278, + }, + { + "symbol": "^FCHI", + "name_cn": "法国CAC40", + "name_en": "CAC 40", + "region": "EU", + "flag": "🇫🇷", + "lat": 48.8566, + "lng": 2.3522, + }, # Japan - {"symbol": "^N225", "name_cn": "日经225", "name_en": "Nikkei 225", "region": "JP", "flag": "🇯🇵", "lat": 35.6762, "lng": 139.6503}, + { + "symbol": "^N225", + "name_cn": "日经225", + "name_en": "Nikkei 225", + "region": "JP", + "flag": "🇯🇵", + "lat": 35.6762, + "lng": 139.6503, + }, # Korea - {"symbol": "^KS11", "name_cn": "韩国KOSPI", "name_en": "KOSPI", "region": "KR", "flag": "🇰🇷", "lat": 37.5665, "lng": 126.9780}, + { + "symbol": "^KS11", + "name_cn": "韩国KOSPI", + "name_en": "KOSPI", + "region": "KR", + "flag": "🇰🇷", + "lat": 37.5665, + "lng": 126.9780, + }, # Australia - {"symbol": "^AXJO", "name_cn": "澳洲ASX200", "name_en": "ASX 200", "region": "AU", "flag": "🇦🇺", "lat": -33.8688, "lng": 151.2093}, + { + "symbol": "^AXJO", + "name_cn": "澳洲ASX200", + "name_en": "ASX 200", + "region": "AU", + "flag": "🇦🇺", + "lat": -33.8688, + "lng": 151.2093, + }, # India - {"symbol": "^BSESN", "name_cn": "印度SENSEX", "name_en": "SENSEX", "region": "IN", "flag": "🇮🇳", "lat": 19.0760, "lng": 72.8777}, + { + "symbol": "^BSESN", + "name_cn": "印度SENSEX", + "name_en": "SENSEX", + "region": "IN", + "flag": "🇮🇳", + "lat": 19.0760, + "lng": 72.8777, + }, ] - + try: import yfinance as yf - + symbols = [idx["symbol"] for idx in indices] tickers = yf.Tickers(" ".join(symbols)) - + result = [] for idx in indices: try: ticker = tickers.tickers.get(idx["symbol"]) if ticker: hist = ticker.history(period="2d") - + if len(hist) >= 2: prev_close = hist["Close"].iloc[-2] current = hist["Close"].iloc[-1] @@ -294,34 +440,38 @@ def _fetch_stock_indices() -> List[Dict[str, Any]]: else: current = 0 change = 0 - - result.append({ + + result.append( + { + "symbol": idx["symbol"], + "name_cn": idx["name_cn"], + "name_en": idx["name_en"], + "price": round(current, 2), + "change": round(change, 2), + "region": idx["region"], + "flag": idx["flag"], + "lat": idx["lat"], + "lng": idx["lng"], + "category": "index", + } + ) + except Exception as e: + logger.debug(f"Failed to fetch {idx['symbol']}: {e}") + result.append( + { "symbol": idx["symbol"], "name_cn": idx["name_cn"], "name_en": idx["name_en"], - "price": round(current, 2), - "change": round(change, 2), + "price": 0, + "change": 0, "region": idx["region"], "flag": idx["flag"], "lat": idx["lat"], "lng": idx["lng"], - "category": "index" - }) - except Exception as e: - logger.debug(f"Failed to fetch {idx['symbol']}: {e}") - result.append({ - "symbol": idx["symbol"], - "name_cn": idx["name_cn"], - "name_en": idx["name_en"], - "price": 0, - "change": 0, - "region": idx["region"], - "flag": idx["flag"], - "lat": idx["lat"], - "lng": idx["lng"], - "category": "index" - }) - + "category": "index", + } + ) + return result except Exception as e: logger.error(f"Failed to fetch stock indices: {e}") @@ -331,29 +481,85 @@ def _fetch_stock_indices() -> List[Dict[str, Any]]: def _fetch_forex_pairs() -> List[Dict[str, Any]]: """Fetch major forex pairs.""" pairs = [ - {"symbol": "EURUSD=X", "name": "EUR/USD", "name_cn": "欧元/美元", "name_en": "EUR/USD", "base": "EUR", "quote": "USD"}, - {"symbol": "GBPUSD=X", "name": "GBP/USD", "name_cn": "英镑/美元", "name_en": "GBP/USD", "base": "GBP", "quote": "USD"}, - {"symbol": "USDJPY=X", "name": "USD/JPY", "name_cn": "美元/日元", "name_en": "USD/JPY", "base": "USD", "quote": "JPY"}, - {"symbol": "USDCNH=X", "name": "USD/CNH", "name_cn": "美元/离岸人民币", "name_en": "USD/CNH", "base": "USD", "quote": "CNH"}, - {"symbol": "AUDUSD=X", "name": "AUD/USD", "name_cn": "澳元/美元", "name_en": "AUD/USD", "base": "AUD", "quote": "USD"}, - {"symbol": "USDCAD=X", "name": "USD/CAD", "name_cn": "美元/加元", "name_en": "USD/CAD", "base": "USD", "quote": "CAD"}, - {"symbol": "USDCHF=X", "name": "USD/CHF", "name_cn": "美元/瑞郎", "name_en": "USD/CHF", "base": "USD", "quote": "CHF"}, - {"symbol": "NZDUSD=X", "name": "NZD/USD", "name_cn": "纽元/美元", "name_en": "NZD/USD", "base": "NZD", "quote": "USD"}, + { + "symbol": "EURUSD=X", + "name": "EUR/USD", + "name_cn": "欧元/美元", + "name_en": "EUR/USD", + "base": "EUR", + "quote": "USD", + }, + { + "symbol": "GBPUSD=X", + "name": "GBP/USD", + "name_cn": "英镑/美元", + "name_en": "GBP/USD", + "base": "GBP", + "quote": "USD", + }, + { + "symbol": "USDJPY=X", + "name": "USD/JPY", + "name_cn": "美元/日元", + "name_en": "USD/JPY", + "base": "USD", + "quote": "JPY", + }, + { + "symbol": "USDCNH=X", + "name": "USD/CNH", + "name_cn": "美元/离岸人民币", + "name_en": "USD/CNH", + "base": "USD", + "quote": "CNH", + }, + { + "symbol": "AUDUSD=X", + "name": "AUD/USD", + "name_cn": "澳元/美元", + "name_en": "AUD/USD", + "base": "AUD", + "quote": "USD", + }, + { + "symbol": "USDCAD=X", + "name": "USD/CAD", + "name_cn": "美元/加元", + "name_en": "USD/CAD", + "base": "USD", + "quote": "CAD", + }, + { + "symbol": "USDCHF=X", + "name": "USD/CHF", + "name_cn": "美元/瑞郎", + "name_en": "USD/CHF", + "base": "USD", + "quote": "CHF", + }, + { + "symbol": "NZDUSD=X", + "name": "NZD/USD", + "name_cn": "纽元/美元", + "name_en": "NZD/USD", + "base": "NZD", + "quote": "USD", + }, ] - + try: import yfinance as yf - + symbols = [p["symbol"] for p in pairs] tickers = yf.Tickers(" ".join(symbols)) - + result = [] for pair in pairs: try: ticker = tickers.tickers.get(pair["symbol"]) if ticker: hist = ticker.history(period="2d") - + if len(hist) >= 2: prev_close = hist["Close"].iloc[-2] current = hist["Close"].iloc[-1] @@ -364,21 +570,23 @@ def _fetch_forex_pairs() -> List[Dict[str, Any]]: else: current = 0 change = 0 - - result.append({ - "symbol": pair["name"], - "name": pair["name"], - "name_cn": pair["name_cn"], - "name_en": pair["name_en"], - "price": round(current, 5), - "change": round(change, 2), - "base": pair["base"], - "quote": pair["quote"], - "category": "forex" - }) + + result.append( + { + "symbol": pair["name"], + "name": pair["name"], + "name_cn": pair["name_cn"], + "name_en": pair["name_en"], + "price": round(current, 5), + "change": round(change, 2), + "base": pair["base"], + "quote": pair["quote"], + "category": "forex", + } + ) except Exception as e: logger.debug(f"Failed to fetch {pair['symbol']}: {e}") - + return result except Exception as e: logger.error(f"Failed to fetch forex pairs: {e}") @@ -395,34 +603,36 @@ def _fetch_commodities() -> List[Dict[str, Any]]: {"symbol": "HG=F", "name_cn": "铜", "name_en": "Copper", "unit": "USD/lb"}, {"symbol": "NG=F", "name_cn": "天然气", "name_en": "Natural Gas", "unit": "USD/MMBtu"}, ] - + result = [] - + try: import yfinance as yf - + symbols = [c["symbol"] for c in commodities] tickers = yf.Tickers(" ".join(symbols)) - + for commodity in commodities: try: ticker = tickers.tickers.get(commodity["symbol"]) if ticker: hist = ticker.history(period="2d") - + if len(hist) >= 2: prev_close = hist["Close"].iloc[-2] current = hist["Close"].iloc[-1] change = ((current - prev_close) / prev_close) * 100 - result.append({ - "symbol": commodity["symbol"], - "name_cn": commodity["name_cn"], - "name_en": commodity["name_en"], - "price": round(current, 2), - "change": round(change, 2), - "unit": commodity["unit"], - "category": "commodity" - }) + result.append( + { + "symbol": commodity["symbol"], + "name_cn": commodity["name_cn"], + "name_en": commodity["name_en"], + "price": round(current, 2), + "change": round(change, 2), + "unit": commodity["unit"], + "category": "commodity", + } + ) elif len(hist) == 1: current = _safe_float(hist["Close"].iloc[-1], 0) change = 0.0 @@ -449,39 +659,43 @@ def _fetch_commodities() -> List[Dict[str, Any]]: change = (rmc / prev_close) * 100 except Exception: pass - result.append({ - "symbol": commodity["symbol"], - "name_cn": commodity["name_cn"], - "name_en": commodity["name_en"], - "price": round(current, 2), - "change": round(change, 2), - "unit": commodity["unit"], - "category": "commodity" - }) + result.append( + { + "symbol": commodity["symbol"], + "name_cn": commodity["name_cn"], + "name_en": commodity["name_en"], + "price": round(current, 2), + "change": round(change, 2), + "unit": commodity["unit"], + "category": "commodity", + } + ) except Exception as e: logger.debug(f"Failed to fetch {commodity['symbol']}: {e}") - + if result: logger.info(f"Fetched {len(result)} commodities via yfinance") return result - + except Exception as e: logger.error(f"Failed to fetch commodities: {e}") - + # Return placeholder data if all fetches failed if not result: logger.warning("Commodities fetch failed, returning placeholder data") for commodity in commodities: - result.append({ - "symbol": commodity["symbol"], - "name_cn": commodity["name_cn"], - "name_en": commodity["name_en"], - "price": 0, - "change": 0, - "unit": commodity["unit"], - "category": "commodity" - }) - + result.append( + { + "symbol": commodity["symbol"], + "name_cn": commodity["name_cn"], + "name_en": commodity["name_en"], + "price": 0, + "change": 0, + "unit": commodity["unit"], + "category": "commodity", + } + ) + return result @@ -493,7 +707,7 @@ def _fetch_fear_greed_index() -> Dict[str, Any]: resp = requests.get(url, timeout=15) resp.raise_for_status() data = resp.json() - + if data.get("data"): item = data["data"][0] value = int(item.get("value", 50)) @@ -503,7 +717,7 @@ def _fetch_fear_greed_index() -> Dict[str, Any]: "value": value, "classification": classification, "timestamp": int(item.get("timestamp", 0)), - "source": "alternative.me" + "source": "alternative.me", } else: logger.warning("Fear & Greed API returned empty data") @@ -513,7 +727,7 @@ def _fetch_fear_greed_index() -> Dict[str, Any]: logger.error(f"Fear & Greed Index request failed: {e}") except Exception as e: logger.error(f"Failed to fetch Fear & Greed Index: {e}") - + logger.warning("Returning default Fear & Greed value (50)") return {"value": 50, "classification": "Neutral", "timestamp": 0, "source": "N/A"} @@ -521,22 +735,27 @@ def _fetch_fear_greed_index() -> Dict[str, Any]: def _fetch_vix() -> Dict[str, Any]: """Fetch VIX (CBOE Volatility Index) with multiple fallbacks.""" # Default - a reasonable market neutral level - DEFAULT_VIX = {"value": 18, "change": 0, "level": "low", - "interpretation": "低波动 - 市场稳定", - "interpretation_en": "Low - Market Stable"} - + DEFAULT_VIX = { + "value": 18, + "change": 0, + "level": "low", + "interpretation": "低波动 - 市场稳定", + "interpretation_en": "Low - Market Stable", + } + # 1) Try yfinance try: import yfinance as yf + logger.debug("Fetching VIX from yfinance") ticker = yf.Ticker("^VIX") - + try: hist = ticker.history(period="5d") except Exception as hist_err: logger.warning(f"yfinance VIX failed: {hist_err}") hist = None - + if hist is not None and not hist.empty and len(hist) >= 1: current = float(hist["Close"].iloc[-1]) if current > 0: @@ -547,17 +766,18 @@ def _fetch_vix() -> Dict[str, Any]: raise ValueError("VIX value is 0") else: raise ValueError("VIX history empty") - + except Exception as e: logger.warning(f"yfinance VIX failed, trying akshare: {e}") - + # 2) Try Akshare (friendly for Chinese servers) try: import akshare as ak + vix_df = ak.index_vix() # VIX index if vix_df is not None and len(vix_df) > 0: - current = float(vix_df.iloc[-1]['close']) - prev_close = float(vix_df.iloc[-2]['close']) if len(vix_df) >= 2 else current + current = float(vix_df.iloc[-1]["close"]) + prev_close = float(vix_df.iloc[-2]["close"]) if len(vix_df) >= 2 else current change = ((current - prev_close) / prev_close) * 100 if prev_close else 0 logger.info(f"VIX from akshare: {current:.2f}") else: @@ -565,10 +785,10 @@ def _fetch_vix() -> Dict[str, Any]: except Exception as ak_err: logger.warning(f"Akshare VIX also failed: {ak_err}") return DEFAULT_VIX - + if current <= 0: return DEFAULT_VIX - + # VIX levels interpretation if current < 12: level = "very_low" @@ -590,38 +810,43 @@ def _fetch_vix() -> Dict[str, Any]: level = "very_high" interpretation_cn = "极高波动 - 市场恐慌" interpretation_en = "Very High - Market Panic" - + return { "value": round(current, 2), "change": round(change, 2), "level": level, "interpretation": interpretation_cn, - "interpretation_en": interpretation_en + "interpretation_en": interpretation_en, } def _fetch_dollar_index() -> Dict[str, Any]: """Fetch US Dollar Index (DXY) with multiple fallbacks.""" # Default - reasonably neutral level - DEFAULT_DXY = {"value": 104, "change": 0, "level": "moderate_strong", - "interpretation": "美元偏强 - 关注资金流向", - "interpretation_en": "Moderately Strong - Watch capital flows"} - + DEFAULT_DXY = { + "value": 104, + "change": 0, + "level": "moderate_strong", + "interpretation": "美元偏强 - 关注资金流向", + "interpretation_en": "Moderately Strong - Watch capital flows", + } + current = 0 change = 0 - + # 1) Try yfinance try: import yfinance as yf + logger.debug("Fetching DXY from yfinance") ticker = yf.Ticker("DX-Y.NYB") - + try: hist = ticker.history(period="5d") except Exception as hist_err: logger.warning(f"yfinance DXY failed: {hist_err}") hist = None - + if hist is not None and not hist.empty and len(hist) >= 1: current = float(hist["Close"].iloc[-1]) if current > 0: @@ -632,18 +857,19 @@ def _fetch_dollar_index() -> Dict[str, Any]: raise ValueError("DXY value is 0") else: raise ValueError("DXY history empty") - + except Exception as e: logger.warning(f"yfinance DXY failed, trying akshare: {e}") - + # 2) Try Akshare to get USD Index try: import akshare as ak + # Akshare Forex Data fx_df = ak.currency_boc_sina(symbol="美元") if fx_df is not None and len(fx_df) > 0: # Estimate DXY using Bank of China exchange rate (approximate value) - usd_cny = float(fx_df.iloc[-1]['中行汇买价']) / 100 + usd_cny = float(fx_df.iloc[-1]["中行汇买价"]) / 100 current = usd_cny * 14.5 # Approximate conversion change = 0 logger.info(f"DXY estimated from akshare: {current:.2f}") @@ -652,10 +878,10 @@ def _fetch_dollar_index() -> Dict[str, Any]: except Exception as ak_err: logger.warning(f"Akshare DXY also failed: {ak_err}") return DEFAULT_DXY - + if current <= 0: return DEFAULT_DXY - + # DXY interpretation if current > 105: level = "strong" @@ -677,14 +903,14 @@ def _fetch_dollar_index() -> Dict[str, Any]: level = "weak" interpretation_cn = "美元疲软 - 利多黄金/大宗商品" interpretation_en = "Weak USD - Bullish gold/commodities" - + logger.info(f"DXY fetched: {current:.2f} ({level})") return { "value": round(current, 2), "change": round(change, 2), "level": level, "interpretation": interpretation_cn, - "interpretation_en": interpretation_en + "interpretation_en": interpretation_en, } @@ -692,44 +918,47 @@ def _fetch_yield_curve() -> Dict[str, Any]: """Fetch Treasury Yield Curve (10Y - 2Y spread).""" try: import yfinance as yf - + logger.debug("Fetching Treasury Yield Curve") - + # 10-year Treasury yield tnx = yf.Ticker("^TNX") - + # Use try-except to wrap history calls try: tnx_hist = tnx.history(period="5d") except Exception as hist_err: logger.warning(f"TNX history fetch failed: {hist_err}") tnx_hist = None - + # security check if tnx_hist is None or tnx_hist.empty: logger.warning("TNX history is None or empty, returning default") return { - "yield_10y": 4.2, "yield_2y": 4.0, "spread": 0.2, "change": 0, - "level": "normal", "interpretation": "数据暂不可用", - "interpretation_en": "Data temporarily unavailable", "signal": "neutral" + "yield_10y": 4.2, + "yield_2y": 4.0, + "spread": 0.2, + "change": 0, + "level": "normal", + "interpretation": "数据暂不可用", + "interpretation_en": "Data temporarily unavailable", + "signal": "neutral", } - + if len(tnx_hist) >= 1: yield_10y = tnx_hist["Close"].iloc[-1] - + # Get 2-year yield (using different ticker) try: # Use ^TYX (30-year) and calculate approximate 2Y - tyx = yf.Ticker("^TYX") - tyx_hist = tyx.history(period="5d") - yield_30y = tyx_hist["Close"].iloc[-1] if len(tyx_hist) >= 1 else 0 - + tyx = yf.Ticker("^TYX") # noqa + # Approximate 2Y as lower bound (rough estimate) # In reality, we'd need proper 2Y data yield_2y = yield_10y * 0.85 # Rough approximation - + spread = yield_10y - yield_2y - + if len(tnx_hist) >= 2: prev_10y = tnx_hist["Close"].iloc[-2] prev_2y = prev_10y * 0.85 @@ -737,8 +966,9 @@ def _fetch_yield_curve() -> Dict[str, Any]: change = spread - prev_spread else: change = 0 - - except: + + except Exception as e: + logger.debug(f"Failed to calculate yield curve metrics: {e}") yield_2y = yield_10y * 0.85 spread = yield_10y - yield_2y change = 0 @@ -747,7 +977,7 @@ def _fetch_yield_curve() -> Dict[str, Any]: yield_2y = 0 spread = 0 change = 0 - + # Yield curve interpretation if spread < -0.5: level = "deeply_inverted" @@ -774,7 +1004,7 @@ def _fetch_yield_curve() -> Dict[str, Any]: interpretation_cn = "陡峭曲线 - 经济扩张预期" interpretation_en = "Steep - Economic expansion expected" signal = "bullish" - + logger.info(f"Yield Curve: 10Y={yield_10y:.2f}%, spread={spread:.2f}% ({level})") return { "yield_10y": round(yield_10y, 2), @@ -784,14 +1014,19 @@ def _fetch_yield_curve() -> Dict[str, Any]: "level": level, "signal": signal, "interpretation": interpretation_cn, - "interpretation_en": interpretation_en + "interpretation_en": interpretation_en, } except Exception as e: logger.error(f"Failed to fetch Yield Curve: {e}", exc_info=True) return { - "yield_10y": 0, "yield_2y": 0, "spread": 0, "change": 0, - "level": "unknown", "signal": "neutral", - "interpretation": "数据获取失败", "interpretation_en": "Data fetch failed" + "yield_10y": 0, + "yield_2y": 0, + "spread": 0, + "change": 0, + "level": "unknown", + "signal": "neutral", + "interpretation": "数据获取失败", + "interpretation_en": "Data fetch failed", } @@ -799,11 +1034,11 @@ def _fetch_vxn() -> Dict[str, Any]: """Fetch NASDAQ Volatility Index (VXN) - Tech sector fear gauge.""" try: import yfinance as yf - + logger.debug("Fetching VXN from yfinance") ticker = yf.Ticker("^VXN") hist = ticker.history(period="5d") - + if len(hist) >= 2: prev_close = hist["Close"].iloc[-2] current = hist["Close"].iloc[-1] @@ -814,7 +1049,7 @@ def _fetch_vxn() -> Dict[str, Any]: else: current = 0 change = 0 - + # VXN levels (typically higher than VIX) if current < 15: level = "very_low" @@ -836,29 +1071,35 @@ def _fetch_vxn() -> Dict[str, Any]: level = "very_high" interpretation_cn = "科技股极高波动 - 恐慌" interpretation_en = "Very High Tech Volatility - Panic" - + logger.info(f"VXN fetched: {current:.2f} ({level})") return { "value": round(current, 2), "change": round(change, 2), "level": level, "interpretation": interpretation_cn, - "interpretation_en": interpretation_en + "interpretation_en": interpretation_en, } except Exception as e: logger.error(f"Failed to fetch VXN: {e}", exc_info=True) - return {"value": 0, "change": 0, "level": "unknown", "interpretation": "数据获取失败", "interpretation_en": "Data fetch failed"} + return { + "value": 0, + "change": 0, + "level": "unknown", + "interpretation": "数据获取失败", + "interpretation_en": "Data fetch failed", + } def _fetch_gvz() -> Dict[str, Any]: """Fetch Gold Volatility Index (GVZ) - Safe haven sentiment.""" try: import yfinance as yf - + logger.debug("Fetching GVZ from yfinance") ticker = yf.Ticker("^GVZ") hist = ticker.history(period="5d") - + if len(hist) >= 2: prev_close = hist["Close"].iloc[-2] current = hist["Close"].iloc[-1] @@ -869,7 +1110,7 @@ def _fetch_gvz() -> Dict[str, Any]: else: current = 0 change = 0 - + # GVZ levels if current < 12: level = "very_low" @@ -891,18 +1132,24 @@ def _fetch_gvz() -> Dict[str, Any]: level = "very_high" interpretation_cn = "黄金极高波动 - 市场避险" interpretation_en = "Very High Gold Vol - Flight to safety" - + logger.info(f"GVZ fetched: {current:.2f} ({level})") return { "value": round(current, 2), "change": round(change, 2), "level": level, "interpretation": interpretation_cn, - "interpretation_en": interpretation_en + "interpretation_en": interpretation_en, } except Exception as e: logger.error(f"Failed to fetch GVZ: {e}", exc_info=True) - return {"value": 0, "change": 0, "level": "unknown", "interpretation": "数据获取失败", "interpretation_en": "Data fetch failed"} + return { + "value": 0, + "change": 0, + "level": "unknown", + "interpretation": "数据获取失败", + "interpretation_en": "Data fetch failed", + } def _fetch_put_call_ratio() -> Dict[str, Any]: @@ -912,33 +1159,37 @@ def _fetch_put_call_ratio() -> Dict[str, Any]: """ try: import yfinance as yf - + logger.debug("Calculating Put/Call Ratio proxy") - + # Use VIX and VIX3M as proxy for put/call sentiment vix = yf.Ticker("^VIX") vix3m = yf.Ticker("^VIX3M") - + vix_hist = vix.history(period="5d") vix3m_hist = vix3m.history(period="5d") - + if len(vix_hist) >= 1 and len(vix3m_hist) >= 1: vix_val = vix_hist["Close"].iloc[-1] vix3m_val = vix3m_hist["Close"].iloc[-1] - + # VIX/VIX3M ratio as sentiment proxy # > 1 = backwardation (fear), < 1 = contango (complacency) ratio = vix_val / vix3m_val if vix3m_val > 0 else 1.0 - + if len(vix_hist) >= 2 and len(vix3m_hist) >= 2: - prev_ratio = vix_hist["Close"].iloc[-2] / vix3m_hist["Close"].iloc[-2] if vix3m_hist["Close"].iloc[-2] > 0 else 1.0 + prev_ratio = ( + vix_hist["Close"].iloc[-2] / vix3m_hist["Close"].iloc[-2] + if vix3m_hist["Close"].iloc[-2] > 0 + else 1.0 + ) change = ((ratio - prev_ratio) / prev_ratio) * 100 else: change = 0 else: ratio = 1.0 change = 0 - + # Interpretation if ratio > 1.15: level = "high_fear" @@ -965,35 +1216,41 @@ def _fetch_put_call_ratio() -> Dict[str, Any]: interpretation_cn = "极度自满 - 警惕反转" interpretation_en = "Extreme Complacency - Watch for reversal" signal = "neutral" - + logger.info(f"VIX Term Structure: ratio={ratio:.3f} ({level})") return { "value": round(ratio, 3), - "vix": round(vix_val, 2) if 'vix_val' in dir() else 0, - "vix3m": round(vix3m_val, 2) if 'vix3m_val' in dir() else 0, + "vix": round(vix_val, 2) if "vix_val" in dir() else 0, + "vix3m": round(vix3m_val, 2) if "vix3m_val" in dir() else 0, "change": round(change, 2), "level": level, "signal": signal, "interpretation": interpretation_cn, - "interpretation_en": interpretation_en + "interpretation_en": interpretation_en, } except Exception as e: logger.error(f"Failed to calculate Put/Call proxy: {e}", exc_info=True) return { - "value": 1.0, "vix": 0, "vix3m": 0, "change": 0, - "level": "unknown", "signal": "neutral", - "interpretation": "数据获取失败", "interpretation_en": "Data fetch failed" + "value": 1.0, + "vix": 0, + "vix3m": 0, + "change": 0, + "level": "unknown", + "signal": "neutral", + "interpretation": "数据获取失败", + "interpretation_en": "Data fetch failed", } def _fetch_financial_news(lang: str = "all") -> Dict[str, List[Dict[str, Any]]]: """Fetch financial news using search service - separated by language.""" result = {"cn": [], "en": []} - + try: from app.services.search import SearchService + search = SearchService() - + # Chinese news queries cn_queries = [ "加密货币新闻", @@ -1003,7 +1260,7 @@ def _fetch_financial_news(lang: str = "all") -> Dict[str, List[Dict[str, Any]]]: "全球经济数据", "期货市场动态", ] - + # English news queries en_queries = [ "stock market news today", @@ -1013,43 +1270,47 @@ def _fetch_financial_news(lang: str = "all") -> Dict[str, List[Dict[str, Any]]]: "global economic outlook", "S&P 500 market update", ] - + # Fetch Chinese news if lang in ("all", "cn"): for query in cn_queries: try: results = search.search(query, num_results=5, date_restrict="d1") for r in results: - result["cn"].append({ - "title": r.get("title", ""), - "link": r.get("link", ""), - "snippet": r.get("snippet", ""), - "source": r.get("source", ""), - "published": r.get("published", ""), - "category": query, - "lang": "cn" - }) + result["cn"].append( + { + "title": r.get("title", ""), + "link": r.get("link", ""), + "snippet": r.get("snippet", ""), + "source": r.get("source", ""), + "published": r.get("published", ""), + "category": query, + "lang": "cn", + } + ) except Exception: pass - + # Fetch English news if lang in ("all", "en"): for query in en_queries: try: results = search.search(query, num_results=5, date_restrict="d1") for r in results: - result["en"].append({ - "title": r.get("title", ""), - "link": r.get("link", ""), - "snippet": r.get("snippet", ""), - "source": r.get("source", ""), - "published": r.get("published", ""), - "category": query, - "lang": "en" - }) + result["en"].append( + { + "title": r.get("title", ""), + "link": r.get("link", ""), + "snippet": r.get("snippet", ""), + "source": r.get("source", ""), + "published": r.get("published", ""), + "category": query, + "lang": "en", + } + ) except Exception: pass - + # Remove duplicates for lang_key in ["cn", "en"]: seen = set() @@ -1060,10 +1321,10 @@ def _fetch_financial_news(lang: str = "all") -> Dict[str, List[Dict[str, Any]]]: seen.add(link) unique.append(news) result[lang_key] = unique[:15] # Limit to 15 per language - + except Exception as e: logger.error(f"Failed to fetch financial news: {e}") - + return result @@ -1074,7 +1335,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]: """ today = datetime.now() events = [] - + # Comprehensive economic events with impact analysis sample_events = [ { @@ -1087,7 +1348,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]: "impact_if_above": "bullish", # Higher than expected bullish for dollar "impact_if_below": "bearish", "impact_desc": "高于预期利多美元/美股,低于预期利空", - "impact_desc_en": "Above forecast: bullish USD/stocks; Below: bearish" + "impact_desc_en": "Above forecast: bullish USD/stocks; Below: bearish", }, { "name": "美联储利率决议", @@ -1099,7 +1360,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]: "impact_if_above": "bearish", # Raising interest rates is bad for the stock market "impact_if_below": "bullish", "impact_desc": "加息利空股市/加密货币,降息利多", - "impact_desc_en": "Rate hike: bearish stocks/crypto; Cut: bullish" + "impact_desc_en": "Rate hike: bearish stocks/crypto; Cut: bullish", }, { "name": "美国CPI月率", @@ -1111,7 +1372,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]: "impact_if_above": "bearish", # High CPI is negative "impact_if_below": "bullish", "impact_desc": "CPI高于预期增加加息预期,利空股市", - "impact_desc_en": "Higher CPI increases rate hike expectations, bearish stocks" + "impact_desc_en": "Higher CPI increases rate hike expectations, bearish stocks", }, { "name": "欧洲央行利率决议", @@ -1123,7 +1384,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]: "impact_if_above": "bearish", "impact_if_below": "bullish", "impact_desc": "加息利空欧股,利多欧元", - "impact_desc_en": "Rate hike: bearish EU stocks, bullish EUR" + "impact_desc_en": "Rate hike: bearish EU stocks, bullish EUR", }, { "name": "日本央行利率决议", @@ -1135,7 +1396,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]: "impact_if_above": "bullish", # Japan's interest rate hikes are bullish for the yen "impact_if_below": "bearish", "impact_desc": "加息预期利多日元,利空日股", - "impact_desc_en": "Rate hike expectation: bullish JPY, bearish Nikkei" + "impact_desc_en": "Rate hike expectation: bullish JPY, bearish Nikkei", }, { "name": "美国初请失业金人数", @@ -1147,7 +1408,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]: "impact_if_above": "bearish", "impact_if_below": "bullish", "impact_desc": "失业人数上升利空美元,利多黄金", - "impact_desc_en": "Rising claims: bearish USD, bullish gold" + "impact_desc_en": "Rising claims: bearish USD, bullish gold", }, { "name": "英国央行利率决议", @@ -1159,7 +1420,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]: "impact_if_above": "bullish", "impact_if_below": "bearish", "impact_desc": "加息利多英镑,利空英股", - "impact_desc_en": "Rate hike: bullish GBP, bearish UK stocks" + "impact_desc_en": "Rate hike: bullish GBP, bearish UK stocks", }, { "name": "美国零售销售月率", @@ -1171,7 +1432,7 @@ def _get_economic_calendar() -> List[Dict[str, Any]]: "impact_if_above": "bullish", "impact_if_below": "bearish", "impact_desc": "零售数据强劲利多美元和美股", - "impact_desc_en": "Strong retail: bullish USD and stocks" + "impact_desc_en": "Strong retail: bullish USD and stocks", }, { "name": "OPEC月度报告", @@ -1183,46 +1444,44 @@ def _get_economic_calendar() -> List[Dict[str, Any]]: "impact_if_above": "bullish", "impact_if_below": "bearish", "impact_desc": "减产预期利多原油,增产预期利空", - "impact_desc_en": "Production cut: bullish oil; Increase: bearish" + "impact_desc_en": "Production cut: bullish oil; Increase: bearish", }, ] - + import random - + for i, evt in enumerate(sample_events): # Some events in the past (released), some in the future (upcoming) days_offset = i % 14 - 5 # Range from -5 to +8 days event_date = today + timedelta(days=days_offset) hour = (8 + (i * 3)) % 24 - + # Determine if event has been released (past events) - is_released = event_date.date() < today.date() or ( - event_date.date() == today.date() and hour < today.hour - ) - + is_released = event_date.date() < today.date() or (event_date.date() == today.date() and hour < today.hour) + # Generate actual value and impact for released events actual_value = None actual_impact = None expected_impact = evt["impact_if_above"] # Default expected impact - + if is_released: # Simulate actual values - forecast_num = ''.join(filter(lambda x: x.isdigit() or x == '.', evt["forecast"])) + forecast_num = "".join(filter(lambda x: x.isdigit() or x == ".", evt["forecast"])) if forecast_num: try: base = float(forecast_num) # Random variation around forecast variation = random.uniform(-0.15, 0.15) actual_num = base * (1 + variation) - + # Format like the forecast - if 'K' in evt["forecast"]: + if "K" in evt["forecast"]: actual_value = f"{actual_num:.0f}K" - elif '%' in evt["forecast"]: + elif "%" in evt["forecast"]: actual_value = f"{actual_num:.2f}%" else: actual_value = f"{actual_num:.2f}" - + # Determine actual impact based on actual vs forecast if actual_num > base: actual_impact = evt["impact_if_above"] @@ -1230,42 +1489,45 @@ def _get_economic_calendar() -> List[Dict[str, Any]]: actual_impact = evt["impact_if_below"] else: actual_impact = "neutral" - except: + except Exception as e: + logger.debug(f"Failed to calculate actual value for event {evt['name']}: {e}") actual_value = evt["forecast"] actual_impact = "neutral" else: actual_value = evt["forecast"] actual_impact = "neutral" - - events.append({ - "id": i + 1, - "name": evt["name"], - "name_en": evt["name_en"], - "country": evt["country"], - "date": event_date.strftime("%Y-%m-%d"), - "time": f"{hour:02d}:30", - "importance": evt["importance"], - "actual": actual_value, - "forecast": evt["forecast"], - "previous": evt["previous"], - "impact_if_above": evt["impact_if_above"], - "impact_if_below": evt["impact_if_below"], - "impact_desc": evt["impact_desc"], - "impact_desc_en": evt["impact_desc_en"], - "expected_impact": expected_impact, - "actual_impact": actual_impact, - "is_released": is_released - }) - + + events.append( + { + "id": i + 1, + "name": evt["name"], + "name_en": evt["name_en"], + "country": evt["country"], + "date": event_date.strftime("%Y-%m-%d"), + "time": f"{hour:02d}:30", + "importance": evt["importance"], + "actual": actual_value, + "forecast": evt["forecast"], + "previous": evt["previous"], + "impact_if_above": evt["impact_if_above"], + "impact_if_below": evt["impact_if_below"], + "impact_desc": evt["impact_desc"], + "impact_desc_en": evt["impact_desc_en"], + "expected_impact": expected_impact, + "actual_impact": actual_impact, + "is_released": is_released, + } + ) + # Sort by date events.sort(key=lambda x: (x["date"], x["time"])) - + return events def _generate_heatmap_data() -> Dict[str, Any]: """Generate heatmap data for crypto, stock sectors, and forex.""" - + # Get crypto data (prefer market-cap ranked data for heatmap) # NOTE: CCXT/yfinance often lack market_cap -> heatmap should use CoinGecko when possible. crypto_data = _get_cached("crypto_heatmap") @@ -1278,23 +1540,25 @@ def _generate_heatmap_data() -> Dict[str, Any]: "per_page": 30, "page": 1, "sparkline": False, - "price_change_percentage": "24h" + "price_change_percentage": "24h", } resp = requests.get(url, params=params, timeout=10) resp.raise_for_status() data = resp.json() or [] crypto_data = [] for coin in data: - crypto_data.append({ - "symbol": (coin.get("symbol") or "").upper(), - "name": coin.get("name", ""), - "price": _safe_float(coin.get("current_price")), - "change_24h": _safe_float(coin.get("price_change_percentage_24h")), - "market_cap": _safe_float(coin.get("market_cap")), - "volume_24h": _safe_float(coin.get("total_volume")), - "image": coin.get("image", ""), - "category": "crypto" - }) + crypto_data.append( + { + "symbol": (coin.get("symbol") or "").upper(), + "name": coin.get("name", ""), + "price": _safe_float(coin.get("current_price")), + "change_24h": _safe_float(coin.get("price_change_percentage_24h")), + "market_cap": _safe_float(coin.get("market_cap")), + "volume_24h": _safe_float(coin.get("total_volume")), + "image": coin.get("image", ""), + "category": "crypto", + } + ) logger.info(f"Fetched crypto heatmap data via CoinGecko: {len(crypto_data)} items") # Heatmap data doesn't need ultra-frequent refresh _set_cached("crypto_heatmap", crypto_data, 300) @@ -1304,84 +1568,141 @@ def _generate_heatmap_data() -> Dict[str, Any]: crypto_data = _get_cached("crypto_prices") or _fetch_crypto_prices() _set_cached("crypto_prices", crypto_data, 30) _set_cached("crypto_heatmap", crypto_data, 30) - + # Get forex data forex_data = _get_cached("forex_pairs") if not forex_data: forex_data = _fetch_forex_pairs() _set_cached("forex_pairs", forex_data, 30) - + heatmap = { "crypto": [], "sectors": [], "forex": [], "commodities": [], # Added commodity heat map - "indices": [] + "indices": [], } - + # Commodities heatmap (gold, silver, crude oil, etc.) commodities_data = _get_cached("commodities") if not commodities_data: commodities_data = _fetch_commodities() _set_cached("commodities", commodities_data) - - for comm in (commodities_data or []): - heatmap["commodities"].append({ - "name": comm.get("name_cn", comm.get("name_en", "")), - "name_cn": comm.get("name_cn", ""), - "name_en": comm.get("name_en", ""), - "value": comm.get("change", 0), - "price": comm.get("price", 0), - "unit": comm.get("unit", "") - }) - + + for comm in commodities_data or []: + heatmap["commodities"].append( + { + "name": comm.get("name_cn", comm.get("name_en", "")), + "name_cn": comm.get("name_cn", ""), + "name_en": comm.get("name_en", ""), + "value": comm.get("change", 0), + "price": comm.get("price", 0), + "unit": comm.get("unit", ""), + } + ) + # Crypto heatmap # Ensure mainstream coins by market cap appear first; also avoid blank symbols - crypto_sorted = sorted( - (crypto_data or []), - key=lambda x: _safe_float(x.get("market_cap", 0)), - reverse=True - ) + crypto_sorted = sorted((crypto_data or []), key=lambda x: _safe_float(x.get("market_cap", 0)), reverse=True) for coin in [c for c in crypto_sorted if c.get("symbol")][:25]: - heatmap["crypto"].append({ - "name": coin.get("symbol", ""), - "fullName": coin.get("name", ""), - "value": coin.get("change_24h", 0), - "marketCap": coin.get("market_cap", 0), - "volume": coin.get("volume_24h", 0), - "price": coin.get("price", 0) - }) - + heatmap["crypto"].append( + { + "name": coin.get("symbol", ""), + "fullName": coin.get("name", ""), + "value": coin.get("change_24h", 0), + "marketCap": coin.get("market_cap", 0), + "volume": coin.get("volume_24h", 0), + "price": coin.get("price", 0), + } + ) + # Forex heatmap for pair in forex_data: - heatmap["forex"].append({ - "name": pair.get("name", ""), - "name_cn": pair.get("name_cn", pair.get("name", "")), - "name_en": pair.get("name_en", pair.get("name", "")), - "value": pair.get("change", 0), - "price": pair.get("price", 0) - }) - + heatmap["forex"].append( + { + "name": pair.get("name", ""), + "name_cn": pair.get("name_cn", pair.get("name", "")), + "name_en": pair.get("name_en", pair.get("name", "")), + "value": pair.get("change", 0), + "price": pair.get("price", 0), + } + ) + # Stock sectors (using ETFs as proxy for real-time data) sectors = [ - {"name": "科技", "name_en": "Technology", "etf": "XLK", "value": 0, "stocks": ["AAPL", "MSFT", "GOOGL", "NVDA", "META"]}, - {"name": "金融", "name_en": "Financials", "etf": "XLF", "value": 0, "stocks": ["JPM", "BAC", "WFC", "GS", "MS"]}, - {"name": "医疗", "name_en": "Healthcare", "etf": "XLV", "value": 0, "stocks": ["JNJ", "PFE", "UNH", "MRK", "ABBV"]}, - {"name": "消费", "name_en": "Consumer", "etf": "XLY", "value": 0, "stocks": ["AMZN", "TSLA", "HD", "NKE", "MCD"]}, + { + "name": "科技", + "name_en": "Technology", + "etf": "XLK", + "value": 0, + "stocks": ["AAPL", "MSFT", "GOOGL", "NVDA", "META"], + }, + { + "name": "金融", + "name_en": "Financials", + "etf": "XLF", + "value": 0, + "stocks": ["JPM", "BAC", "WFC", "GS", "MS"], + }, + { + "name": "医疗", + "name_en": "Healthcare", + "etf": "XLV", + "value": 0, + "stocks": ["JNJ", "PFE", "UNH", "MRK", "ABBV"], + }, + { + "name": "消费", + "name_en": "Consumer", + "etf": "XLY", + "value": 0, + "stocks": ["AMZN", "TSLA", "HD", "NKE", "MCD"], + }, {"name": "能源", "name_en": "Energy", "etf": "XLE", "value": 0, "stocks": ["XOM", "CVX", "COP", "SLB", "EOG"]}, - {"name": "工业", "name_en": "Industrials", "etf": "XLI", "value": 0, "stocks": ["CAT", "BA", "GE", "HON", "UPS"]}, - {"name": "材料", "name_en": "Materials", "etf": "XLB", "value": 0, "stocks": ["LIN", "APD", "DD", "NEM", "FCX"]}, - {"name": "公用事业", "name_en": "Utilities", "etf": "XLU", "value": 0, "stocks": ["NEE", "DUK", "SO", "D", "AEP"]}, - {"name": "房地产", "name_en": "Real Estate", "etf": "XLRE", "value": 0, "stocks": ["AMT", "PLD", "CCI", "EQIX", "SPG"]}, - {"name": "通信", "name_en": "Communication", "etf": "XLC", "value": 0, "stocks": ["GOOGL", "META", "DIS", "NFLX", "VZ"]}, + { + "name": "工业", + "name_en": "Industrials", + "etf": "XLI", + "value": 0, + "stocks": ["CAT", "BA", "GE", "HON", "UPS"], + }, + { + "name": "材料", + "name_en": "Materials", + "etf": "XLB", + "value": 0, + "stocks": ["LIN", "APD", "DD", "NEM", "FCX"], + }, + { + "name": "公用事业", + "name_en": "Utilities", + "etf": "XLU", + "value": 0, + "stocks": ["NEE", "DUK", "SO", "D", "AEP"], + }, + { + "name": "房地产", + "name_en": "Real Estate", + "etf": "XLRE", + "value": 0, + "stocks": ["AMT", "PLD", "CCI", "EQIX", "SPG"], + }, + { + "name": "通信", + "name_en": "Communication", + "etf": "XLC", + "value": 0, + "stocks": ["GOOGL", "META", "DIS", "NFLX", "VZ"], + }, ] - + # Try to fetch real sector ETF data try: import yfinance as yf + etf_symbols = [s["etf"] for s in sectors] tickers = yf.Tickers(" ".join(etf_symbols)) - + for sector in sectors: try: ticker = tickers.tickers.get(sector["etf"]) @@ -1397,29 +1718,32 @@ def _generate_heatmap_data() -> Dict[str, Any]: pass except Exception as e: logger.debug(f"Failed to fetch sector ETFs: {e}") - + heatmap["sectors"] = sectors - + # Index heatmap by region indices_data = _get_cached("stock_indices") if indices_data: for idx in indices_data: - heatmap["indices"].append({ - "symbol": idx.get("symbol", ""), - "name": idx.get("name_cn", idx.get("name", "")), - "name_cn": idx.get("name_cn", ""), - "name_en": idx.get("name_en", ""), - "region": idx.get("region", ""), - "value": idx.get("change", 0), - "price": idx.get("price", 0), - "flag": idx.get("flag", "") - }) - + heatmap["indices"].append( + { + "symbol": idx.get("symbol", ""), + "name": idx.get("name_cn", idx.get("name", "")), + "name_cn": idx.get("name_cn", ""), + "name_en": idx.get("name_en", ""), + "region": idx.get("region", ""), + "value": idx.get("change", 0), + "price": idx.get("price", 0), + "flag": idx.get("flag", ""), + } + ) + return heatmap # ============ API Endpoints ============ + @global_market_bp.route("/overview", methods=["GET"]) @login_required def market_overview(): @@ -1431,30 +1755,26 @@ def market_overview(): # Check cache first cached = _get_cached("market_overview", 30) if cached: - logger.debug(f"Returning cached overview: indices={len(cached.get('indices', []))}, " - f"forex={len(cached.get('forex', []))}, crypto={len(cached.get('crypto', []))}, " - f"commodities={len(cached.get('commodities', []))}") + logger.debug( + f"Returning cached overview: indices={len(cached.get('indices', []))}, " + f"forex={len(cached.get('forex', []))}, crypto={len(cached.get('crypto', []))}, " + f"commodities={len(cached.get('commodities', []))}" + ) return jsonify({"code": 1, "msg": "success", "data": cached}) - + logger.info("Fetching fresh market overview data...") - + # Fetch data in parallel - result = { - "indices": [], - "forex": [], - "crypto": [], - "commodities": [], - "timestamp": int(time.time()) - } - + result = {"indices": [], "forex": [], "crypto": [], "commodities": [], "timestamp": int(time.time())} + with ThreadPoolExecutor(max_workers=4) as executor: futures = { executor.submit(_fetch_stock_indices): "indices", executor.submit(_fetch_forex_pairs): "forex", executor.submit(_fetch_crypto_prices): "crypto", - executor.submit(_fetch_commodities): "commodities" + executor.submit(_fetch_commodities): "commodities", } - + for future in as_completed(futures): key = futures[future] try: @@ -1466,22 +1786,24 @@ def market_overview(): except Exception as e: logger.error(f"Failed to fetch {key}: {e}", exc_info=True) result[key] = [] - + # Log summary - logger.info(f"Market overview complete: indices={len(result['indices'])}, " - f"forex={len(result['forex'])}, crypto={len(result['crypto'])}, " - f"commodities={len(result['commodities'])}") - + logger.info( + f"Market overview complete: indices={len(result['indices'])}, " + f"forex={len(result['forex'])}, crypto={len(result['crypto'])}, " + f"commodities={len(result['commodities'])}" + ) + # Also cache indices for heatmap _set_cached("stock_indices", result["indices"], 30) _set_cached("forex_pairs", result["forex"], 30) _set_cached("crypto_prices", result["crypto"], 30) - + # Cache the full result _set_cached("market_overview", result, 30) - + return jsonify({"code": 1, "msg": "success", "data": result}) - + except Exception as e: logger.error(f"market_overview failed: {e}", exc_info=True) return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @@ -1497,12 +1819,12 @@ def market_heatmap(): cached = _get_cached("market_heatmap", 30) if cached: return jsonify({"code": 1, "msg": "success", "data": cached}) - + data = _generate_heatmap_data() _set_cached("market_heatmap", data, 30) - + return jsonify({"code": 1, "msg": "success", "data": data}) - + except Exception as e: logger.error(f"market_heatmap failed: {e}", exc_info=True) return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @@ -1519,16 +1841,16 @@ def market_news(): try: lang = request.args.get("lang", "all") cache_key = f"market_news_{lang}" - + cached = _get_cached(cache_key, 180) # 3 minutes cache for news if cached: return jsonify({"code": 1, "msg": "success", "data": cached}) - + news = _fetch_financial_news(lang) _set_cached(cache_key, news, 180) - + return jsonify({"code": 1, "msg": "success", "data": news}) - + except Exception as e: logger.error(f"market_news failed: {e}", exc_info=True) return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @@ -1544,12 +1866,12 @@ def economic_calendar(): cached = _get_cached("economic_calendar", 3600) # 1 hour cache if cached: return jsonify({"code": 1, "msg": "success", "data": cached}) - + events = _get_economic_calendar() _set_cached("economic_calendar", events, 3600) - + return jsonify({"code": 1, "msg": "success", "data": events}) - + except Exception as e: logger.error(f"economic_calendar failed: {e}", exc_info=True) return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @@ -1569,9 +1891,9 @@ def market_sentiment(): if cached: logger.debug("Returning cached sentiment data (6h cache)") return jsonify({"code": 1, "msg": "success", "data": cached}) - + logger.info("Fetching fresh sentiment data (comprehensive)") - + # Fetch all indicators in parallel with ThreadPoolExecutor(max_workers=7) as executor: futures = { @@ -1583,7 +1905,7 @@ def market_sentiment(): executor.submit(_fetch_gvz): "gvz", executor.submit(_fetch_put_call_ratio): "vix_term", } - + results = {} for future in as_completed(futures): key = futures[future] @@ -1592,11 +1914,13 @@ def market_sentiment(): except Exception as e: logger.error(f"Failed to fetch {key}: {e}") results[key] = None - + # Log summary - logger.info(f"Sentiment data fetched: Fear&Greed={results.get('fear_greed', {}).get('value')}, " - f"VIX={results.get('vix', {}).get('value')}, DXY={results.get('dxy', {}).get('value')}") - + logger.info( + f"Sentiment data fetched: Fear&Greed={results.get('fear_greed', {}).get('value')}, " + f"VIX={results.get('vix', {}).get('value')}, DXY={results.get('dxy', {}).get('value')}" + ) + data = { "fear_greed": results.get("fear_greed") or {"value": 50, "classification": "Neutral"}, "vix": results.get("vix") or {"value": 0, "level": "unknown"}, @@ -1605,13 +1929,13 @@ def market_sentiment(): "vxn": results.get("vxn") or {"value": 0, "level": "unknown"}, "gvz": results.get("gvz") or {"value": 0, "level": "unknown"}, "vix_term": results.get("vix_term") or {"value": 1.0, "level": "unknown"}, - "timestamp": int(time.time()) + "timestamp": int(time.time()), } - + _set_cached("market_sentiment", data, 21600) # 6 hours cache - + return jsonify({"code": 1, "msg": "success", "data": data}) - + except Exception as e: logger.error(f"market_sentiment failed: {e}", exc_info=True) return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @@ -1659,12 +1983,14 @@ def _fetch_stock_opportunity_prices() -> List[Dict[str, Any]]: else: continue - result.append({ - "symbol": stock["symbol"], - "name": stock["name"], - "price": round(current, 2), - "change": round(change, 2) - }) + result.append( + { + "symbol": stock["symbol"], + "name": stock["name"], + "price": round(current, 2), + "change": round(change, 2), + } + ) except Exception as e: logger.debug(f"Failed to fetch stock {stock['symbol']}: {e}") @@ -1674,77 +2000,6 @@ def _fetch_stock_opportunity_prices() -> List[Dict[str, Any]]: return [] -def _fetch_local_stock_opportunity_prices(market: str, limit: int = 15) -> List[Dict[str, Any]]: - """ - Fetch CN/HK stock prices for opportunity scanning. - - For overseas servers we prefer the project's Tencent-based ticker path, which is - typically more reachable/stable than some Eastmoney/AkShare endpoints. - """ - m = str(market or "").strip() - if m not in ("CNStock", "HKStock"): - return [] - - fallback_symbols = { - "CNStock": [ - {"symbol": "600519", "name": "贵州茅台"}, - {"symbol": "000001", "name": "平安银行"}, - {"symbol": "300750", "name": "宁德时代"}, - {"symbol": "601318", "name": "中国平安"}, - {"symbol": "600036", "name": "招商银行"}, - {"symbol": "002594", "name": "比亚迪"}, - {"symbol": "600276", "name": "恒瑞医药"}, - {"symbol": "601899", "name": "紫金矿业"}, - ], - "HKStock": [ - {"symbol": "00700", "name": "腾讯控股"}, - {"symbol": "09988", "name": "阿里巴巴-W"}, - {"symbol": "03690", "name": "美团-W"}, - {"symbol": "01810", "name": "小米集团-W"}, - {"symbol": "01299", "name": "友邦保险"}, - {"symbol": "00939", "name": "建设银行"}, - {"symbol": "02318", "name": "中国平安"}, - {"symbol": "09618", "name": "京东集团-SW"}, - ], - } - - try: - from app.data.market_symbols_seed import get_hot_symbols - from app.data_sources import DataSourceFactory - from app.services.symbol_name import resolve_symbol_name - - symbols = get_hot_symbols(m, limit=max(int(limit or 15), 1)) or fallback_symbols.get(m, []) - source = DataSourceFactory.get_source(m) - - result = [] - for item in symbols[: max(int(limit or 15), 1)]: - try: - symbol = str(item.get("symbol") or "").strip() - if not symbol: - continue - ticker = source.get_ticker(symbol) or {} - last = _safe_float(ticker.get("last") or ticker.get("close") or ticker.get("price")) - if last <= 0: - continue - change_pct = ticker.get("changePercent") - if change_pct is None: - prev_close = _safe_float(ticker.get("previousClose")) - change_pct = ((last - prev_close) / prev_close * 100.0) if prev_close > 0 else 0.0 - result.append({ - "symbol": symbol, - "name": (item.get("name") or resolve_symbol_name(m, symbol) or symbol).strip(), - "price": round(last, 4), - "change": round(_safe_float(change_pct), 2), - "market": m, - }) - except Exception as e: - logger.debug(f"Failed to fetch {m} opportunity price {item.get('symbol')}: {e}") - return result - except Exception as e: - logger.error(f"Failed to fetch {m} opportunity prices: {e}") - return [] - - def _analyze_opportunities_crypto(opportunities: list): """Scan crypto market for trading opportunities.""" crypto_data = _get_cached("crypto_prices") @@ -1752,7 +2007,7 @@ def _analyze_opportunities_crypto(opportunities: list): crypto_data = _fetch_crypto_prices() if crypto_data: _set_cached("crypto_prices", crypto_data) - + if not crypto_data: logger.warning("_analyze_opportunities_crypto: No crypto data available") return @@ -1794,19 +2049,21 @@ def _analyze_opportunities_crypto(opportunities: list): impact = "bearish" if signal: - opportunities.append({ - "symbol": symbol, - "name": name, - "price": price, - "change_24h": change, - "change_7d": change_7d, - "signal": signal, - "strength": strength, - "reason": reason, - "impact": impact, - "market": "Crypto", - "timestamp": int(time.time()) - }) + opportunities.append( + { + "symbol": symbol, + "name": name, + "price": price, + "change_24h": change, + "change_7d": change_7d, + "signal": signal, + "strength": strength, + "reason": reason, + "impact": impact, + "market": "Crypto", + "timestamp": int(time.time()), + } + ) def _analyze_opportunities_stocks(opportunities: list): @@ -1816,14 +2073,14 @@ def _analyze_opportunities_stocks(opportunities: list): stock_data = _fetch_stock_opportunity_prices() if stock_data: _set_cached("stock_opportunity_prices", stock_data, 3600) - + if not stock_data: logger.warning("_analyze_opportunities_stocks: No stock data available") return logger.debug(f"_analyze_opportunities_stocks: Analyzing {len(stock_data)} stocks") - for stock in (stock_data or []): + for stock in stock_data or []: change = _safe_float(stock.get("change", 0)) symbol = stock.get("symbol", "") name = stock.get("name", "") @@ -1857,87 +2114,20 @@ def _analyze_opportunities_stocks(opportunities: list): impact = "bearish" if signal: - opportunities.append({ - "symbol": symbol, - "name": name, - "price": price, - "change_24h": change, - "signal": signal, - "strength": strength, - "reason": reason, - "impact": impact, - "market": "USStock", - "timestamp": int(time.time()) - }) - - -def _analyze_opportunities_local_stocks(opportunities: list, market: str): - """Scan CN/HK stocks for trading opportunities.""" - m = str(market or "").strip() - if m not in ("CNStock", "HKStock"): - return - - cache_key = "cn_stock_opportunity_prices" if m == "CNStock" else "hk_stock_opportunity_prices" - stock_data = _get_cached(cache_key) - if not stock_data: - stock_data = _fetch_local_stock_opportunity_prices(m) - if stock_data: - _set_cached(cache_key, stock_data, 3600) - - if not stock_data: - logger.warning(f"_analyze_opportunities_local_stocks: No {m} data available") - return - - logger.debug(f"_analyze_opportunities_local_stocks: Analyzing {len(stock_data)} {m} stocks") - strong_th = 7.0 if m == "CNStock" else 6.0 - medium_th = 3.0 if m == "CNStock" else 2.5 - market_cn = "A股" if m == "CNStock" else "港股" - - for stock in stock_data: - change = _safe_float(stock.get("change", 0)) - symbol = stock.get("symbol", "") - name = stock.get("name", "") - price = _safe_float(stock.get("price", 0)) - - signal = None - strength = "medium" - reason = "" - impact = "neutral" - - if change > strong_th: - signal = "overbought" - strength = "strong" - reason = f"{market_cn}日涨幅{change:.1f}%,短期涨幅较大,注意回调风险" - impact = "bearish" - elif change > medium_th: - signal = "bullish_momentum" - strength = "medium" - reason = f"{market_cn}日涨幅{change:.1f}%,上涨动能较强" - impact = "bullish" - elif change < -strong_th: - signal = "oversold" - strength = "strong" - reason = f"{market_cn}日跌幅{abs(change):.1f}%,可能超卖反弹" - impact = "bullish" - elif change < -medium_th: - signal = "bearish_momentum" - strength = "medium" - reason = f"{market_cn}日跌幅{abs(change):.1f}%,下跌趋势明显" - impact = "bearish" - - if signal: - opportunities.append({ - "symbol": symbol, - "name": name, - "price": price, - "change_24h": change, - "signal": signal, - "strength": strength, - "reason": reason, - "impact": impact, - "market": m, - "timestamp": int(time.time()) - }) + opportunities.append( + { + "symbol": symbol, + "name": name, + "price": price, + "change_24h": change, + "signal": signal, + "strength": strength, + "reason": reason, + "impact": impact, + "market": "USStock", + "timestamp": int(time.time()), + } + ) def _analyze_opportunities_forex(opportunities: list): @@ -1947,14 +2137,14 @@ def _analyze_opportunities_forex(opportunities: list): forex_data = _fetch_forex_pairs() if forex_data: _set_cached("forex_pairs", forex_data, 3600) - + if not forex_data: logger.warning("_analyze_opportunities_forex: No forex data available") return logger.debug(f"_analyze_opportunities_forex: Analyzing {len(forex_data)} forex pairs") - for pair in (forex_data or []): + for pair in forex_data or []: change = _safe_float(pair.get("change", 0)) symbol = pair.get("symbol", pair.get("name", "")) name = pair.get("name_cn", pair.get("name", "")) @@ -1988,18 +2178,20 @@ def _analyze_opportunities_forex(opportunities: list): impact = "bearish" if signal: - opportunities.append({ - "symbol": symbol, - "name": name, - "price": price, - "change_24h": change, - "signal": signal, - "strength": strength, - "reason": reason, - "impact": impact, - "market": "Forex", - "timestamp": int(time.time()) - }) + opportunities.append( + { + "symbol": symbol, + "name": name, + "price": price, + "change_24h": change, + "signal": signal, + "strength": strength, + "reason": reason, + "impact": impact, + "market": "Forex", + "timestamp": int(time.time()), + } + ) def _analyze_opportunities_polymarket(opportunities: list): @@ -2007,46 +2199,48 @@ def _analyze_opportunities_polymarket(opportunities: list): try: from app.data_sources.polymarket import PolymarketDataSource from app.services.polymarket_analyzer import PolymarketAnalyzer - + polymarket_source = PolymarketDataSource() analyzer = PolymarketAnalyzer() - + # Get popular markets markets = polymarket_source.get_trending_markets(limit=20) - + for market in markets: try: # AI analysis - analysis = analyzer.analyze_market(market['market_id']) - - if analysis.get('error'): + analysis = analyzer.analyze_market(market["market_id"]) + + if analysis.get("error"): continue - + # Only add high score chances - if analysis.get('opportunity_score', 0) > 75: - opportunities.append({ - "symbol": market['question'][:50], # Simplified display - "name": market['question'], - "price": market['current_probability'], - "change_24h": 0, # There is no concept of 24h rise and fall in the prediction market - "signal": "prediction_opportunity", - "strength": "strong" if analysis.get('opportunity_score', 0) > 85 else "medium", - "reason": f"AI预测概率{analysis.get('ai_predicted_probability', 0):.1f}%,市场概率{market['current_probability']:.1f}%,差异{analysis.get('divergence', 0):.1f}%", - "impact": "bullish" if analysis.get('recommendation') == "YES" else "bearish", - "market": "PredictionMarket", - "market_id": market['market_id'], - "ai_analysis": { - "predicted_probability": analysis.get('ai_predicted_probability', 0), - "recommendation": analysis.get('recommendation', 'HOLD'), - "confidence_score": analysis.get('confidence_score', 0), - "opportunity_score": analysis.get('opportunity_score', 0) - }, - "timestamp": int(time.time()) - }) + if analysis.get("opportunity_score", 0) > 75: + opportunities.append( + { + "symbol": market["question"][:50], # Simplified display + "name": market["question"], + "price": market["current_probability"], + "change_24h": 0, # There is no concept of 24h rise and fall in the prediction market + "signal": "prediction_opportunity", + "strength": "strong" if analysis.get("opportunity_score", 0) > 85 else "medium", + "reason": f"AI预测概率{analysis.get('ai_predicted_probability', 0):.1f}%,市场概率{market['current_probability']:.1f}%,差异{analysis.get('divergence', 0):.1f}%", + "impact": "bullish" if analysis.get("recommendation") == "YES" else "bearish", + "market": "PredictionMarket", + "market_id": market["market_id"], + "ai_analysis": { + "predicted_probability": analysis.get("ai_predicted_probability", 0), + "recommendation": analysis.get("recommendation", "HOLD"), + "confidence_score": analysis.get("confidence_score", 0), + "opportunity_score": analysis.get("opportunity_score", 0), + }, + "timestamp": int(time.time()), + } + ) except Exception as e: logger.debug(f"Failed to analyze polymarket {market.get('market_id')}: {e}") continue - + except Exception as e: logger.error(f"_analyze_opportunities_polymarket failed: {e}") @@ -2055,7 +2249,7 @@ def _analyze_opportunities_polymarket(opportunities: list): @login_required def trading_opportunities(): """ - Scan for trading opportunities across Crypto, US Stocks, CN/HK Stocks, and Forex. + Scan for trading opportunities across Crypto, US Stocks, and Forex. Note: Prediction Markets are excluded as they have their own dedicated page. Cached for 1 hour. Pass ?force=true to skip cache. """ @@ -2085,23 +2279,7 @@ def trading_opportunities(): except Exception as e: logger.error(f"Failed to analyze stock opportunities: {e}", exc_info=True) - # 3) CN Stocks - try: - _analyze_opportunities_local_stocks(opportunities, "CNStock") - cn_count = len([o for o in opportunities if o.get("market") == "CNStock"]) - logger.info(f"Trading opportunities: found {cn_count} CN stock opportunities") - except Exception as e: - logger.error(f"Failed to analyze CN stock opportunities: {e}", exc_info=True) - - # 4) HK Stocks - try: - _analyze_opportunities_local_stocks(opportunities, "HKStock") - hk_count = len([o for o in opportunities if o.get("market") == "HKStock"]) - logger.info(f"Trading opportunities: found {hk_count} HK stock opportunities") - except Exception as e: - logger.error(f"Failed to analyze HK stock opportunities: {e}", exc_info=True) - - # 5) Forex + # 3) Forex try: _analyze_opportunities_forex(opportunities) forex_count = len([o for o in opportunities if o.get("market") == "Forex"]) @@ -2119,8 +2297,6 @@ def trading_opportunities(): f"Trading opportunities: total {len(opportunities)} opportunities found " f"(Crypto: {len([o for o in opportunities if o.get('market') == 'Crypto'])}, " f"USStock: {len([o for o in opportunities if o.get('market') == 'USStock'])}, " - f"CNStock: {len([o for o in opportunities if o.get('market') == 'CNStock'])}, " - f"HKStock: {len([o for o in opportunities if o.get('market') == 'HKStock'])}, " f"Forex: {len([o for o in opportunities if o.get('market') == 'Forex'])})" ) diff --git a/backend_api_python/app/routes/health.py b/backend_api_python/app/routes/health.py index 5bc2e86..a74eb9c 100644 --- a/backend_api_python/app/routes/health.py +++ b/backend_api_python/app/routes/health.py @@ -1,33 +1,34 @@ """ Health check routing """ -from flask import Blueprint, jsonify + from datetime import datetime -health_bp = Blueprint('health', __name__) +from flask import Blueprint, jsonify + +health_bp = Blueprint("health", __name__) -@health_bp.route('/', methods=['GET']) +@health_bp.route("/", methods=["GET"]) def index(): """API Home Page""" - return jsonify({ - 'name': 'QuantDinger Python API', - 'version': '2.0.0', - 'status': 'running', - 'timestamp': datetime.now().isoformat() - }) + return jsonify( + { + "name": "QuantDinger Python API", + "version": "2.0.0", + "status": "running", + "timestamp": datetime.now().isoformat(), + } + ) -@health_bp.route('/health', methods=['GET']) +@health_bp.route("/health", methods=["GET"]) def health_check(): """health check""" - return jsonify({ - 'status': 'healthy', - 'timestamp': datetime.now().isoformat() - }) + return jsonify({"status": "healthy", "timestamp": datetime.now().isoformat()}) -@health_bp.route('/api/health', methods=['GET']) +@health_bp.route("/api/health", methods=["GET"]) def api_health_check(): """Compatible path: used for container health check/anti-generation probe and other scenarios.""" return health_check() diff --git a/backend_api_python/app/routes/ibkr.py b/backend_api_python/app/routes/ibkr.py index 7c9049b..7d80e4b 100644 --- a/backend_api_python/app/routes/ibkr.py +++ b/backend_api_python/app/routes/ibkr.py @@ -4,15 +4,15 @@ Interactive Brokers API Routes Standalone API endpoints for US stock trading. """ -from flask import Blueprint, request, jsonify +from flask import Blueprint, jsonify, request -from app.utils.logger import get_logger from app.services.ibkr_trading import IBKRClient, IBKRConfig from app.services.ibkr_trading.client import get_ibkr_client, reset_ibkr_client +from app.utils.logger import get_logger logger = get_logger(__name__) -ibkr_bp = Blueprint('ibkr', __name__) +ibkr_bp = Blueprint("ibkr", __name__) # Global client instance _client: IBKRClient = None @@ -28,32 +28,27 @@ def _get_client() -> IBKRClient: # ==================== Connection Management ==================== -@ibkr_bp.route('/status', methods=['GET']) + +@ibkr_bp.route("/status", methods=["GET"]) def get_status(): """ Get connection status. - + GET /api/ibkr/status """ try: client = _get_client() - return jsonify({ - "success": True, - "data": client.get_connection_status() - }) + return jsonify({"success": True, "data": client.get_connection_status()}) except Exception as e: logger.error(f"Get status failed: {e}") - return jsonify({ - "success": False, - "error": str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 -@ibkr_bp.route('/connect', methods=['POST']) +@ibkr_bp.route("/connect", methods=["POST"]) def connect(): """ Connect to TWS / IB Gateway. - + POST /api/ibkr/connect Body: { "host": "127.0.0.1", // Optional, default 127.0.0.1 @@ -64,172 +59,132 @@ def connect(): } """ global _client - + try: data = request.get_json() or {} - + # Build config config = IBKRConfig( - host=data.get('host', '127.0.0.1'), - port=int(data.get('port', 7497)), - client_id=int(data.get('clientId', 1)), - account=data.get('account', ''), - readonly=data.get('readonly', False), + host=data.get("host", "127.0.0.1"), + port=int(data.get("port", 7497)), + client_id=int(data.get("clientId", 1)), + account=data.get("account", ""), + readonly=data.get("readonly", False), ) - + # Disconnect existing connection if _client is not None and _client.connected: _client.disconnect() - + # Create new client and connect _client = IBKRClient(config) success = _client.connect() - + if success: - return jsonify({ - "success": True, - "message": "Connected successfully", - "data": _client.get_connection_status() - }) + return jsonify( + {"success": True, "message": "Connected successfully", "data": _client.get_connection_status()} + ) else: - return jsonify({ - "success": False, - "error": "Connection failed. Please check if TWS/Gateway is running." - }), 400 - - except ImportError as e: - return jsonify({ - "success": False, - "error": "ib_insync not installed. Run: pip install ib_insync" - }), 500 + return jsonify( + {"success": False, "error": "Connection failed. Please check if TWS/Gateway is running."} + ), 400 + + except ImportError: + return jsonify({"success": False, "error": "ib_insync not installed. Run: pip install ib_insync"}), 500 except Exception as e: logger.error(f"Connection failed: {e}") - return jsonify({ - "success": False, - "error": str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 -@ibkr_bp.route('/disconnect', methods=['POST']) +@ibkr_bp.route("/disconnect", methods=["POST"]) def disconnect(): """ Disconnect from IBKR. - + POST /api/ibkr/disconnect """ global _client - + try: if _client is not None: _client.disconnect() _client = None - + reset_ibkr_client() - - return jsonify({ - "success": True, - "message": "Disconnected" - }) + + return jsonify({"success": True, "message": "Disconnected"}) except Exception as e: logger.error(f"Disconnect failed: {e}") - return jsonify({ - "success": False, - "error": str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 # ==================== Account Queries ==================== -@ibkr_bp.route('/account', methods=['GET']) + +@ibkr_bp.route("/account", methods=["GET"]) def get_account(): """ Get account information. - + GET /api/ibkr/account """ try: client = _get_client() if not client.connected: - return jsonify({ - "success": False, - "error": "Not connected to IBKR" - }), 400 - - return jsonify({ - "success": True, - "data": client.get_account_summary() - }) + return jsonify({"success": False, "error": "Not connected to IBKR"}), 400 + + return jsonify({"success": True, "data": client.get_account_summary()}) except Exception as e: logger.error(f"Get account info failed: {e}") - return jsonify({ - "success": False, - "error": str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 -@ibkr_bp.route('/positions', methods=['GET']) +@ibkr_bp.route("/positions", methods=["GET"]) def get_positions(): """ Get positions. - + GET /api/ibkr/positions """ try: client = _get_client() if not client.connected: - return jsonify({ - "success": False, - "error": "Not connected to IBKR" - }), 400 - + return jsonify({"success": False, "error": "Not connected to IBKR"}), 400 + positions = client.get_positions() - return jsonify({ - "success": True, - "data": positions - }) + return jsonify({"success": True, "data": positions}) except Exception as e: logger.error(f"Get positions failed: {e}") - return jsonify({ - "success": False, - "error": str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 -@ibkr_bp.route('/orders', methods=['GET']) +@ibkr_bp.route("/orders", methods=["GET"]) def get_orders(): """ Get open orders. - + GET /api/ibkr/orders """ try: client = _get_client() if not client.connected: - return jsonify({ - "success": False, - "error": "Not connected to IBKR" - }), 400 - + return jsonify({"success": False, "error": "Not connected to IBKR"}), 400 + orders = client.get_open_orders() - return jsonify({ - "success": True, - "data": orders - }) + return jsonify({"success": True, "data": orders}) except Exception as e: logger.error(f"Get orders failed: {e}") - return jsonify({ - "success": False, - "error": str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 # ==================== Trading ==================== -@ibkr_bp.route('/order', methods=['POST']) + +@ibkr_bp.route("/order", methods=["POST"]) def place_order(): """ Place an order. - + POST /api/ibkr/order Body: { "symbol": "AAPL", // Required, symbol code @@ -243,140 +198,109 @@ def place_order(): try: client = _get_client() if not client.connected: - return jsonify({ - "success": False, - "error": "Not connected to IBKR" - }), 400 - + return jsonify({"success": False, "error": "Not connected to IBKR"}), 400 + data = request.get_json() or {} - + # Validate parameters - symbol = data.get('symbol') - side = data.get('side') - quantity = data.get('quantity') - + symbol = data.get("symbol") + side = data.get("side") + quantity = data.get("quantity") + if not symbol: return jsonify({"success": False, "error": "Missing symbol"}), 400 - if not side or side.lower() not in ('buy', 'sell'): + if not side or side.lower() not in ("buy", "sell"): return jsonify({"success": False, "error": "side must be buy or sell"}), 400 if not quantity or float(quantity) <= 0: return jsonify({"success": False, "error": "quantity must be > 0"}), 400 - - market_type = data.get('marketType', 'USStock') - order_type = data.get('orderType', 'market').lower() - + + market_type = data.get("marketType", "USStock") + order_type = data.get("orderType", "market").lower() + # Place order - if order_type == 'limit': - price = data.get('price') + if order_type == "limit": + price = data.get("price") if not price or float(price) <= 0: return jsonify({"success": False, "error": "Limit order requires price"}), 400 - + result = client.place_limit_order( - symbol=symbol, - side=side, - quantity=float(quantity), - price=float(price), - market_type=market_type + symbol=symbol, side=side, quantity=float(quantity), price=float(price), market_type=market_type ) else: result = client.place_market_order( - symbol=symbol, - side=side, - quantity=float(quantity), - market_type=market_type + symbol=symbol, side=side, quantity=float(quantity), market_type=market_type ) - + if result.success: - return jsonify({ - "success": True, - "message": result.message, - "data": { - "orderId": result.order_id, - "filled": result.filled, - "avgPrice": result.avg_price, - "status": result.status, - "raw": result.raw + return jsonify( + { + "success": True, + "message": result.message, + "data": { + "orderId": result.order_id, + "filled": result.filled, + "avgPrice": result.avg_price, + "status": result.status, + "raw": result.raw, + }, } - }) + ) else: - return jsonify({ - "success": False, - "error": result.message - }), 400 - + return jsonify({"success": False, "error": result.message}), 400 + except Exception as e: logger.error(f"Place order failed: {e}") - return jsonify({ - "success": False, - "error": str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 -@ibkr_bp.route('/order/', methods=['DELETE']) +@ibkr_bp.route("/order/", methods=["DELETE"]) def cancel_order(order_id: int): """ Cancel an order. - + DELETE /api/ibkr/order/ """ try: client = _get_client() if not client.connected: - return jsonify({ - "success": False, - "error": "Not connected to IBKR" - }), 400 - + return jsonify({"success": False, "error": "Not connected to IBKR"}), 400 + success = client.cancel_order(order_id) - + if success: - return jsonify({ - "success": True, - "message": f"Order {order_id} cancelled" - }) + return jsonify({"success": True, "message": f"Order {order_id} cancelled"}) else: - return jsonify({ - "success": False, - "error": f"Order {order_id} not found" - }), 404 - + return jsonify({"success": False, "error": f"Order {order_id} not found"}), 404 + except Exception as e: logger.error(f"Cancel order failed: {e}") - return jsonify({ - "success": False, - "error": str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 # ==================== Market Data ==================== -@ibkr_bp.route('/quote', methods=['GET']) + +@ibkr_bp.route("/quote", methods=["GET"]) def get_quote(): """ Get real-time quote. - + GET /api/ibkr/quote?symbol=AAPL&marketType=USStock """ try: client = _get_client() if not client.connected: - return jsonify({ - "success": False, - "error": "Not connected to IBKR" - }), 400 - - symbol = request.args.get('symbol') - market_type = request.args.get('marketType', 'USStock') - + return jsonify({"success": False, "error": "Not connected to IBKR"}), 400 + + symbol = request.args.get("symbol") + market_type = request.args.get("marketType", "USStock") + if not symbol: return jsonify({"success": False, "error": "Missing symbol"}), 400 - + quote = client.get_quote(symbol, market_type) return jsonify(quote) - + except Exception as e: logger.error(f"Get quote failed: {e}") - return jsonify({ - "success": False, - "error": str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 diff --git a/backend_api_python/app/routes/indicator.py b/backend_api_python/app/routes/indicator.py index d5b83e1..c96dc94 100644 --- a/backend_api_python/app/routes/indicator.py +++ b/backend_api_python/app/routes/indicator.py @@ -10,24 +10,23 @@ For local mode, we expose Python equivalents under `/api/indicator/*`. from __future__ import annotations +import builtins import json import os import re import time import traceback -from typing import Any, Dict, List -import builtins +from typing import Any, Dict -from flask import Blueprint, Response, jsonify, request, g -import pandas as pd import numpy as np +import pandas as pd +from flask import Blueprint, Response, g, jsonify, request +from app.services.indicator_params import IndicatorCaller +from app.utils.auth import login_required from app.utils.db import get_db_connection from app.utils.logger import get_logger -from app.utils.auth import login_required -from app.services.indicator_params import IndicatorCaller -from app.utils.safe_exec import validate_code_safety, safe_exec_code -import requests +from app.utils.safe_exec import safe_exec_code, validate_code_safety logger = get_logger(__name__) @@ -92,32 +91,34 @@ def _row_to_indicator(row: Dict[str, Any], user_id: int) -> Dict[str, Any]: def _generate_mock_df(length=200): """Generate mock K-line data for verification.""" from datetime import datetime, timedelta - + dates = [datetime.now() - timedelta(minutes=i) for i in range(length)] dates.reverse() - + # Random walk with trend returns = np.random.normal(0, 0.002, length) price_path = 10000 * np.exp(np.cumsum(returns)) - + close = price_path high = close * (1 + np.abs(np.random.normal(0, 0.001, length))) low = close * (1 - np.abs(np.random.normal(0, 0.001, length))) - open_p = close * (1 + np.random.normal(0, 0.001, length)) # Slight deviation from close + open_p = close * (1 + np.random.normal(0, 0.001, length)) # Slight deviation from close # Ensure High is highest and Low is lowest high = np.maximum(high, np.maximum(open_p, close)) low = np.minimum(low, np.minimum(open_p, close)) - + volume = np.abs(np.random.normal(100, 50, length)) * 1000 - - df = pd.DataFrame({ - 'time': [int(d.timestamp() * 1000) for d in dates], - 'open': open_p, - 'high': high, - 'low': low, - 'close': close, - 'volume': volume - }) + + df = pd.DataFrame( + { + "time": [int(d.timestamp() * 1000) for d in dates], + "open": open_p, + "high": high, + "low": low, + "close": close, + "volume": volume, + } + ) return df @@ -211,9 +212,9 @@ def save_indicator(): now = _now_ts() # For BIGINT fields (createtime, updatetime) # Check whether the user is an administrator (indicators published by the administrator automatically pass the review) - user_role = getattr(g, 'user_role', 'user') - is_admin = user_role == 'admin' - + user_role = getattr(g, "user_role", "user") + is_admin = user_role == "admin" + with get_db_connection() as db: cur = db.cursor() # Best-effort schema upgrade for VIP-free indicators @@ -226,13 +227,13 @@ def save_indicator(): if publish_to_community: cur.execute( "SELECT publish_to_community, review_status FROM qd_indicator_codes WHERE id = ? AND user_id = ?", - (indicator_id, user_id) + (indicator_id, user_id), ) existing = cur.fetchone() - was_published = existing and existing.get('publish_to_community') + was_published = existing and existing.get("publish_to_community") # If it has not been published before, publish it now and set the review status # Posted by the administrator, it passes directly, and ordinary users need to wait for review. - new_review_status = 'approved' if is_admin else 'pending' + new_review_status = "approved" if is_admin else "pending" if not was_published: cur.execute( """ @@ -244,8 +245,21 @@ def save_indicator(): updatetime = ?, updated_at = NOW() WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0) """, - (name, code, description, publish_to_community, pricing_type, price, preview_image, vip_free, - new_review_status, user_id if is_admin else None, now, indicator_id, user_id), + ( + name, + code, + description, + publish_to_community, + pricing_type, + price, + preview_image, + vip_free, + new_review_status, + user_id if is_admin else None, + now, + indicator_id, + user_id, + ), ) else: # Updates that have been released will remain in their original review status. @@ -258,7 +272,19 @@ def save_indicator(): updatetime = ?, updated_at = NOW() WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0) """, - (name, code, description, publish_to_community, pricing_type, price, preview_image, vip_free, now, indicator_id, user_id), + ( + name, + code, + description, + publish_to_community, + pricing_type, + price, + preview_image, + vip_free, + now, + indicator_id, + user_id, + ), ) else: # Unpublish and clear review status @@ -272,13 +298,24 @@ def save_indicator(): updatetime = ?, updated_at = NOW() WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0) """, - (name, code, description, publish_to_community, pricing_type, price, preview_image, now, indicator_id, user_id), + ( + name, + code, + description, + publish_to_community, + pricing_type, + price, + preview_image, + now, + indicator_id, + user_id, + ), ) else: # New indicators - those released by administrators are passed directly, ordinary users need to wait for review review_status = None if publish_to_community: - review_status = 'approved' if is_admin else 'pending' + review_status = "approved" if is_admin else "pending" cur.execute( """ INSERT INTO qd_indicator_codes @@ -287,7 +324,20 @@ def save_indicator(): createtime, updatetime, created_at, updated_at) VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) """, - (user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, vip_free, review_status, now, now), + ( + user_id, + name, + code, + description, + publish_to_community, + pricing_type, + price, + preview_image, + vip_free, + review_status, + now, + now, + ), ) indicator_id = int(cur.lastrowid or 0) db.commit() @@ -330,12 +380,12 @@ def delete_indicator(): def get_indicator_params(): """ Get parameter declaration of indicator - + Used by the front end to display a configurable parameter form when creating a policy. - + Query params: indicator_id: indicator ID - + Returns: params: [ { @@ -349,19 +399,19 @@ def get_indicator_params(): """ try: from app.services.indicator_params import get_indicator_params as get_params - + indicator_id = request.args.get("indicator_id") if not indicator_id: return jsonify({"code": 0, "msg": "indicator_id is required", "data": None}), 400 - + try: indicator_id = int(indicator_id) except ValueError: return jsonify({"code": 0, "msg": "indicator_id must be an integer", "data": None}), 400 - + params = get_params(indicator_id) return jsonify({"code": 1, "msg": "success", "data": params}) - + except Exception as e: logger.error(f"get_indicator_params failed: {str(e)}", exc_info=True) return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @@ -380,42 +430,47 @@ def verify_code(): try: data = request.get_json() or {} code = data.get("code") or "" - + if not code or not str(code).strip(): return jsonify({"code": 0, "msg": "Code is empty", "data": None}), 400 # 1. Generate mock data df = _generate_mock_df() - + # 2. Prepare execution environment (sandboxed) - exec_env = { - 'df': df.copy(), - 'pd': pd, - 'np': np, - 'output': None - } + exec_env = {"df": df.copy(), "pd": pd, "np": np, "output": None} # 2.1 Create restricted __import__ that only allows safe modules def safe_import(name, *args, **kwargs): """Only allow importing a small set of safe modules inside indicator scripts.""" - allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time'] - if name in allowed_modules or name.split('.')[0] in allowed_modules: + allowed_modules = ["numpy", "pandas", "math", "json", "datetime", "time"] + if name in allowed_modules or name.split(".")[0] in allowed_modules: return builtins.__import__(name, *args, **kwargs) raise ImportError(f"Import not allowed: {name}") safe_builtins = { k: getattr(builtins, k) for k in dir(builtins) - if not k.startswith('_') and k not in [ - 'eval', 'exec', 'compile', 'open', 'input', - 'help', 'exit', 'quit', - 'copyright', 'credits', 'license' + if not k.startswith("_") + and k + not in [ + "eval", + "exec", + "compile", + "open", + "input", + "help", + "exit", + "quit", + "copyright", + "credits", + "license", ] } - safe_builtins['__import__'] = safe_import + safe_builtins["__import__"] = safe_import exec_env_sandbox = exec_env.copy() - exec_env_sandbox['__builtins__'] = safe_builtins + exec_env_sandbox["__builtins__"] = safe_builtins # 2.2 Pre-import commonly used modules into the sandbox pre_import_code = """ @@ -426,95 +481,118 @@ import pandas as pd exec(pre_import_code, exec_env_sandbox) except Exception as e: tb = traceback.format_exc() - return jsonify({ - "code": 0, - "msg": f"Runtime Error during pre-import: {str(e)}", - "data": {"type": type(e).__name__, "details": tb} - }) + return jsonify( + { + "code": 0, + "msg": f"Runtime Error during pre-import: {str(e)}", + "data": {"type": type(e).__name__, "details": tb}, + } + ) # 3. Static safety check is_safe, error_msg = validate_code_safety(code) if not is_safe: logger.error(f"Indicator verifyCode security check failed: {error_msg}") - return jsonify({ - "code": 0, - "msg": f"Code contains unsafe operations: {error_msg}", - "data": {"type": "SecurityError", "details": error_msg} - }) + return jsonify( + { + "code": 0, + "msg": f"Code contains unsafe operations: {error_msg}", + "data": {"type": "SecurityError", "details": error_msg}, + } + ) # 4. Execute code with timeout in sandbox exec_result = safe_exec_code( code=code, exec_globals=exec_env_sandbox, exec_locals=exec_env_sandbox, - timeout=20 # indicator verification should be quick + timeout=20, # indicator verification should be quick ) - if not exec_result.get('success'): - error_detail = exec_result.get('error') or 'Unknown error' - return jsonify({ - "code": 0, - "msg": f"Runtime Error: {error_detail}", - "data": {"type": "RuntimeError", "details": error_detail} - }) - - # 5. Check output - output = exec_env_sandbox.get('output') - - if output is None: - return jsonify({ - "code": 0, - "msg": "Missing 'output' variable. Your code must define an 'output' dictionary.", - "data": {"type": "MissingOutput"} - }) - - if not isinstance(output, dict): - return jsonify({ - "code": 0, - "msg": f"'output' must be a dictionary, got {type(output).__name__}", - "data": {"type": "InvalidOutputType"} - }) - - # Check required fields - if 'plots' not in output and 'signals' not in output: - return jsonify({ - "code": 0, - "msg": "'output' dict should contain 'plots' or 'signals' list.", - "data": {"type": "InvalidOutputStructure"} - }) - - # Basic check for lengths - plots = output.get('plots', []) - signals = output.get('signals', []) - - for p in plots: - if 'data' not in p: - return jsonify({"code": 0, "msg": f"Plot '{p.get('name')}' missing 'data' field.", "data": {"type": "InvalidPlot"}}) - if len(p['data']) != len(df): - return jsonify({ - "code": 0, - "msg": f"Plot '{p.get('name')}' data length ({len(p['data'])}) does not match DataFrame length ({len(df)}).", - "data": {"type": "LengthMismatch"} - }) - - for s in signals: - if 'data' not in s: - return jsonify({"code": 0, "msg": f"Signal '{s.get('type')}' missing 'data' field.", "data": {"type": "InvalidSignal"}}) - if len(s['data']) != len(df): - return jsonify({ - "code": 0, - "msg": f"Signal '{s.get('type')}' data length ({len(s['data'])}) does not match DataFrame length ({len(df)}).", - "data": {"type": "LengthMismatch"} - }) + if not exec_result.get("success"): + error_detail = exec_result.get("error") or "Unknown error" + return jsonify( + { + "code": 0, + "msg": f"Runtime Error: {error_detail}", + "data": {"type": "RuntimeError", "details": error_detail}, + } + ) - return jsonify({ - "code": 1, - "msg": "Verification passed! Code executed successfully.", - "data": { - "plots_count": len(plots), - "signals_count": len(signals) + # 5. Check output + output = exec_env_sandbox.get("output") + + if output is None: + return jsonify( + { + "code": 0, + "msg": "Missing 'output' variable. Your code must define an 'output' dictionary.", + "data": {"type": "MissingOutput"}, + } + ) + + if not isinstance(output, dict): + return jsonify( + { + "code": 0, + "msg": f"'output' must be a dictionary, got {type(output).__name__}", + "data": {"type": "InvalidOutputType"}, + } + ) + + # Check required fields + if "plots" not in output and "signals" not in output: + return jsonify( + { + "code": 0, + "msg": "'output' dict should contain 'plots' or 'signals' list.", + "data": {"type": "InvalidOutputStructure"}, + } + ) + + # Basic check for lengths + plots = output.get("plots", []) + signals = output.get("signals", []) + + for p in plots: + if "data" not in p: + return jsonify( + {"code": 0, "msg": f"Plot '{p.get('name')}' missing 'data' field.", "data": {"type": "InvalidPlot"}} + ) + if len(p["data"]) != len(df): + return jsonify( + { + "code": 0, + "msg": f"Plot '{p.get('name')}' data length ({len(p['data'])}) does not match DataFrame length ({len(df)}).", + "data": {"type": "LengthMismatch"}, + } + ) + + for s in signals: + if "data" not in s: + return jsonify( + { + "code": 0, + "msg": f"Signal '{s.get('type')}' missing 'data' field.", + "data": {"type": "InvalidSignal"}, + } + ) + if len(s["data"]) != len(df): + return jsonify( + { + "code": 0, + "msg": f"Signal '{s.get('type')}' data length ({len(s['data'])}) does not match DataFrame length ({len(df)}).", + "data": {"type": "LengthMismatch"}, + } + ) + + return jsonify( + { + "code": 1, + "msg": "Verification passed! Code executed successfully.", + "data": {"plots_count": len(plots), "signals_count": len(signals)}, } - }) + ) except Exception as e: logger.error(f"verify_code failed: {str(e)}", exc_info=True) @@ -602,8 +680,8 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio def _template_code() -> str: # Fallback template that follows the project expectations. header = ( - f"my_indicator_name = \"Custom Indicator\"\n" - f"my_indicator_description = \"{prompt.replace('\\n', ' ')[:200]}\"\n\n" + f'my_indicator_name = "Custom Indicator"\n' + f'my_indicator_description = "{prompt.replace("\\n", " ")[:200]}"\n\n' ) body = ( "df = df.copy()\n\n" @@ -646,17 +724,19 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio 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_code_generation_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)}") - + + 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") @@ -674,7 +754,7 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio ) 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( @@ -684,9 +764,9 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio ], model=current_model, temperature=temperature, - use_json_mode=False # Code generation doesn't need JSON mode + use_json_mode=False, # Code generation doesn't need JSON mode ) - + # Clean up markdown code blocks if present content = content.strip() if content.startswith("```python"): @@ -695,18 +775,18 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio content = content[3:] if content.endswith("```"): content = content[:-3] - + return content.strip() or _template_code() # Capture user_id before generator runs (generator executes outside request context) user_id = g.user_id + def stream(): from app.services.billing_service import get_billing_service + billing = get_billing_service() ok, msg = billing.check_and_consume( - user_id=user_id, - feature='ai_code_gen', - reference_id=f"ai_code_gen_{user_id}_{int(time.time())}" + user_id=user_id, feature="ai_code_gen", reference_id=f"ai_code_gen_{user_id}_{int(time.time())}" ) if not ok: yield "data: " + json.dumps({"error": f"Insufficient credits: {msg}"}, ensure_ascii=False) + "\n\n" @@ -741,7 +821,7 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio def call_indicator(): """ Call another indicator (for use by the front-end Pyodide environment) - + POST /api/indicator/callIndicator Body: { "indicatorRef": int | str, # indicator ID or name @@ -749,7 +829,7 @@ def call_indicator(): "params": Dict, # Parameters passed to the called indicator (optional) "currentIndicatorId": int # Current indicator ID (used for circular dependency detection, optional) } - + Returns: { "code": 1, @@ -765,62 +845,43 @@ def call_indicator(): kline_data = data.get("klineData", []) params = data.get("params") or {} current_indicator_id = data.get("currentIndicatorId") - + if not indicator_ref: - return jsonify({ - "code": 0, - "msg": "indicatorRef is required", - "data": None - }), 400 - + return jsonify({"code": 0, "msg": "indicatorRef is required", "data": None}), 400 + if not kline_data or not isinstance(kline_data, list): - return jsonify({ - "code": 0, - "msg": "klineData must be a non-empty list", - "data": None - }), 400 - + return jsonify({"code": 0, "msg": "klineData must be a non-empty list", "data": None}), 400 + # Get user ID user_id = g.user_id - + # Create IndicatorCaller indicator_caller = IndicatorCaller(user_id, current_indicator_id) - + # Convert the K-line data passed in from the front end into a DataFrame df = pd.DataFrame(kline_data) - + # Make sure necessary columns exist - required_columns = ['open', 'high', 'low', 'close', 'volume'] + required_columns = ["open", "high", "low", "close", "volume"] for col in required_columns: if col not in df.columns: df[col] = 0.0 - + # Convert data type - df['open'] = df['open'].astype('float64') - df['high'] = df['high'].astype('float64') - df['low'] = df['low'].astype('float64') - df['close'] = df['close'].astype('float64') - df['volume'] = df['volume'].astype('float64') - + df["open"] = df["open"].astype("float64") + df["high"] = df["high"].astype("float64") + df["low"] = df["low"].astype("float64") + df["close"] = df["close"].astype("float64") + df["volume"] = df["volume"].astype("float64") + # call indicator result_df = indicator_caller.call_indicator(indicator_ref, df, params) - + # Convert the DataFrame to JSON format (a format that the front end can use) - result_dict = result_df.to_dict(orient='records') - - return jsonify({ - "code": 1, - "msg": "success", - "data": { - "df": result_dict, - "columns": list(result_df.columns) - } - }) - + result_dict = result_df.to_dict(orient="records") + + return jsonify({"code": 1, "msg": "success", "data": {"df": result_dict, "columns": list(result_df.columns)}}) + except Exception as e: logger.error(f"Error calling indicator: {e}", exc_info=True) - return jsonify({ - "code": 0, - "msg": str(e), - "data": None - }), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 diff --git a/backend_api_python/app/routes/kline.py b/backend_api_python/app/routes/kline.py index b40df2b..a0a5023 100644 --- a/backend_api_python/app/routes/kline.py +++ b/backend_api_python/app/routes/kline.py @@ -1,24 +1,25 @@ """ K-line data API routing """ -from flask import Blueprint, request, jsonify -from datetime import datetime + import traceback +from flask import Blueprint, jsonify, request + from app.services.kline import KlineService from app.utils.logger import get_logger logger = get_logger(__name__) -kline_bp = Blueprint('kline', __name__) +kline_bp = Blueprint("kline", __name__) kline_service = KlineService() -@kline_bp.route('/kline', methods=['GET']) +@kline_bp.route("/kline", methods=["GET"]) def get_kline(): """ Get K-line data - + parameter: market: market type (Crypto, USStock, Forex, Futures) symbol: trading pair/stock code @@ -28,96 +29,65 @@ def get_kline(): """ try: # To force GET, use request.args - market = request.args.get('market', 'USStock') - symbol = request.args.get('symbol', '') - timeframe = request.args.get('timeframe', '1D') - limit = int(request.args.get('limit', 300)) - before_time = request.args.get('before_time') or request.args.get('beforeTime') - + market = request.args.get("market", "USStock") + symbol = request.args.get("symbol", "") + timeframe = request.args.get("timeframe", "1D") + limit = int(request.args.get("limit", 300)) + before_time = request.args.get("before_time") or request.args.get("beforeTime") + if before_time: before_time = int(before_time) - + if not symbol: - return jsonify({ - 'code': 0, - 'msg': 'Missing symbol parameter', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": "Missing symbol parameter", "data": None}), 400 + logger.info(f"Requesting K-lines: {market}:{symbol}, timeframe={timeframe}, limit={limit}") - + klines = kline_service.get_kline( - market=market, - symbol=symbol, - timeframe=timeframe, - limit=limit, - before_time=before_time + market=market, symbol=symbol, timeframe=timeframe, limit=limit, before_time=before_time ) - + if not klines: # Give more detailed tips for specific situations - msg = 'No data found' - if market == 'Forex' and timeframe == '1m': - msg = 'Forex 1-minute data requires Tiingo paid subscription' - elif market == 'Forex' and timeframe in ('1W', '1M'): - msg = 'No weekly/monthly data available for this period' - return jsonify({ - 'code': 0, - 'msg': msg, - 'data': [], - 'hint': 'tiingo_subscription' if (market == 'Forex' and timeframe == '1m') else None - }) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': klines - }) - + msg = "No data found" + if market == "Forex" and timeframe == "1m": + msg = "Forex 1-minute data requires Tiingo paid subscription" + elif market == "Forex" and timeframe in ("1W", "1M"): + msg = "No weekly/monthly data available for this period" + return jsonify( + { + "code": 0, + "msg": msg, + "data": [], + "hint": "tiingo_subscription" if (market == "Forex" and timeframe == "1m") else None, + } + ) + + return jsonify({"code": 1, "msg": "success", "data": klines}) + except Exception as e: logger.error(f"Failed to fetch K-lines: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({ - 'code': 0, - 'msg': f'Failed to fetch kline data: {str(e)}', - 'data': None - }), 500 + return jsonify({"code": 0, "msg": f"Failed to fetch kline data: {str(e)}", "data": None}), 500 -@kline_bp.route('/price', methods=['GET']) +@kline_bp.route("/price", methods=["GET"]) def get_price(): """Get the latest price""" try: - market = request.args.get('market', 'USStock') - symbol = request.args.get('symbol', '') - + market = request.args.get("market", "USStock") + symbol = request.args.get("symbol", "") + if not symbol: - return jsonify({ - 'code': 0, - 'msg': 'Missing symbol parameter', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": "Missing symbol parameter", "data": None}), 400 + price_data = kline_service.get_latest_price(market, symbol) - + if not price_data: - return jsonify({ - 'code': 0, - 'msg': 'No price data found', - 'data': None - }) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': price_data - }) - + return jsonify({"code": 0, "msg": "No price data found", "data": None}) + + return jsonify({"code": 1, "msg": "success", "data": price_data}) + except Exception as e: logger.error(f"Failed to fetch price: {str(e)}") - return jsonify({ - 'code': 0, - 'msg': f'Failed to fetch price: {str(e)}', - 'data': None - }), 500 - + return jsonify({"code": 0, "msg": f"Failed to fetch price: {str(e)}", "data": None}), 500 diff --git a/backend_api_python/app/routes/market.py b/backend_api_python/app/routes/market.py index fcf8026..b02e63f 100644 --- a/backend_api_python/app/routes/market.py +++ b/backend_api_python/app/routes/market.py @@ -2,45 +2,55 @@ Market API routes (local-only). Provides watchlist, market metadata, symbol search, and pricing helpers for the frontend. """ -from flask import Blueprint, request, jsonify, g -import traceback + import json import time +import traceback from concurrent.futures import ThreadPoolExecutor, as_completed -from app.services.kline import KlineService -from app.utils.logger import get_logger -from app.utils.cache import CacheManager -from app.utils.db import get_db_connection -from app.utils.config_loader import load_addon_config -from app.utils.auth import login_required +from flask import Blueprint, g, jsonify, request + from app.data.market_symbols_seed import ( get_hot_symbols as seed_get_hot_symbols, - search_symbols as seed_search_symbols, - get_symbol_name as seed_get_symbol_name ) +from app.data.market_symbols_seed import ( + get_symbol_name as seed_get_symbol_name, +) +from app.data.market_symbols_seed import ( + search_symbols as seed_search_symbols, +) +from app.services.kline import KlineService from app.services.symbol_name import resolve_symbol_name +from app.utils.auth import login_required +from app.utils.cache import CacheManager +from app.utils.config_loader import load_addon_config +from app.utils.db import get_db_connection +from app.utils.logger import get_logger logger = get_logger(__name__) -market_bp = Blueprint('market', __name__) +market_bp = Blueprint("market", __name__) kline_service = KlineService() cache = CacheManager() # Thread pool for parallel price fetching executor = ThreadPoolExecutor(max_workers=10) + def _now_ts() -> int: return int(time.time()) + def _normalize_symbol(symbol: str) -> str: - return (symbol or '').strip().upper() + return (symbol or "").strip().upper() + def _ensure_watchlist_table(): # Table is created by db schema init; this is only a sanity hook. return True -@market_bp.route('/config', methods=['GET']) + +@market_bp.route("/config", methods=["GET"]) def get_public_config(): """ Public config for frontend (local mode). @@ -48,60 +58,57 @@ def get_public_config(): """ try: cfg = load_addon_config() - models = (cfg.get('ai', {}) or {}).get('models') + models = (cfg.get("ai", {}) or {}).get("models") if not isinstance(models, dict) or not models: # Fallback defaults (offline friendly) models = { # Keep some legacy defaults - 'openai/gpt-4o': 'GPT-4o', - + "openai/gpt-4o": "GPT-4o", # Unified frontend model list (OpenRouter-style ids) - 'x-ai/grok-code-fast-1': 'xAI: Grok Code Fast 1', - 'x-ai/grok-4-fast': 'xAI: Grok 4 Fast', - 'x-ai/grok-4.1-fast': 'xAI: Grok 4.1 Fast', - 'google/gemini-2.5-flash': 'Google: Gemini 2.5 Flash', - 'google/gemini-2.0-flash-001': 'Google: Gemini 2.0 Flash', - 'google/gemini-3-pro-preview': 'Google: Gemini 3 Pro Preview', - 'google/gemini-2.5-flash-lite': 'Google: Gemini 2.5 Flash Lite', - 'google/gemini-2.5-pro': 'Google: Gemini 2.5 Pro', - 'openai/gpt-4o-mini': 'OpenAI: GPT-4o-mini', - 'openai/gpt-5-mini': 'OpenAI: GPT-5 Mini', - 'openai/gpt-4.1-mini': 'OpenAI: GPT-4.1 Mini', - 'deepseek/deepseek-v3.2': 'DeepSeek: DeepSeek V3.2', - 'minimax/minimax-m2': 'MiniMax: MiniMax M2', - 'anthropic/claude-sonnet-4': 'Anthropic: Claude Sonnet 4', - 'anthropic/claude-sonnet-4.5': 'Anthropic: Claude Sonnet 4.5', - 'anthropic/claude-opus-4.5': 'Anthropic: Claude Opus 4.5', - 'anthropic/claude-haiku-4.5': 'Anthropic: Claude Haiku 4.5', - 'z-ai/glm-4.6': 'Z.AI: GLM 4.6', + "x-ai/grok-code-fast-1": "xAI: Grok Code Fast 1", + "x-ai/grok-4-fast": "xAI: Grok 4 Fast", + "x-ai/grok-4.1-fast": "xAI: Grok 4.1 Fast", + "google/gemini-2.5-flash": "Google: Gemini 2.5 Flash", + "google/gemini-2.0-flash-001": "Google: Gemini 2.0 Flash", + "google/gemini-3-pro-preview": "Google: Gemini 3 Pro Preview", + "google/gemini-2.5-flash-lite": "Google: Gemini 2.5 Flash Lite", + "google/gemini-2.5-pro": "Google: Gemini 2.5 Pro", + "openai/gpt-4o-mini": "OpenAI: GPT-4o-mini", + "openai/gpt-5-mini": "OpenAI: GPT-5 Mini", + "openai/gpt-4.1-mini": "OpenAI: GPT-4.1 Mini", + "deepseek/deepseek-v3.2": "DeepSeek: DeepSeek V3.2", + "minimax/minimax-m2": "MiniMax: MiniMax M2", + "anthropic/claude-sonnet-4": "Anthropic: Claude Sonnet 4", + "anthropic/claude-sonnet-4.5": "Anthropic: Claude Sonnet 4.5", + "anthropic/claude-opus-4.5": "Anthropic: Claude Opus 4.5", + "anthropic/claude-haiku-4.5": "Anthropic: Claude Haiku 4.5", + "z-ai/glm-4.6": "Z.AI: GLM 4.6", } - return jsonify({'code': 1, 'msg': 'success', 'data': {'models': models, 'qdt_cost': {}}}) + return jsonify({"code": 1, "msg": "success", "data": {"models": models, "qdt_cost": {}}}) except Exception as e: logger.error(f"get_public_config failed: {str(e)}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@market_bp.route('/types', methods=['GET']) + +@market_bp.route("/types", methods=["GET"]) def get_market_types(): """Return supported market types for the add-watchlist modal.""" - # Keep a stable UX order; add CN/HK stocks near US stocks. - desired_order = ['USStock', 'CNStock', 'HKStock', 'Crypto', 'Forex', 'Futures'] + # Keep a stable UX order for the supported market set. + desired_order = ["USStock", "Crypto", "Forex", "Futures"] order_rank = {v: i for i, v in enumerate(desired_order)} def _normalize_item(x): # Expected: {value: 'USStock', i18nKey: '...'} if isinstance(x, dict): - v = (x.get('value') or '').strip() + v = (x.get("value") or "").strip() if not v: return None - return { - 'value': v, - 'i18nKey': x.get('i18nKey') or f'dashboard.analysis.market.{v}' - } + return {"value": v, "i18nKey": x.get("i18nKey") or f"dashboard.analysis.market.{v}"} if isinstance(x, str): v = x.strip() if not v: return None - return {'value': v, 'i18nKey': f'dashboard.analysis.market.{v}'} + return {"value": v, "i18nKey": f"dashboard.analysis.market.{v}"} return None def _sort_items(items): @@ -111,79 +118,79 @@ def get_market_types(): norm = _normalize_item(it) if norm: out.append(norm) - out.sort(key=lambda it: (order_rank.get(it['value'], 10_000))) + out.sort(key=lambda it: order_rank.get(it["value"], 10_000)) return out cfg = load_addon_config() - data = (cfg.get('market', {}) or {}).get('types') + data = (cfg.get("market", {}) or {}).get("types") # Normalize & force desired order (even if config overrides the list order). if isinstance(data, list) and data: data = _sort_items(data) else: data = _sort_items(desired_order) - return jsonify({'code': 1, 'msg': 'success', 'data': data}) + return jsonify({"code": 1, "msg": "success", "data": data}) -@market_bp.route('/menuFooterConfig', methods=['GET']) +@market_bp.route("/menuFooterConfig", methods=["GET"]) def get_menu_footer_config(): """ Compatibility stub for old PHP `getMenuFooterConfig`. Frontend can also hardcode this locally; this endpoint remains for completeness. """ data = { - 'contact': { - 'support_url': 'https://github.com/', - 'feature_request_url': 'https://github.com/', - 'email': 'support@example.com', - 'live_chat_url': 'https://github.com/' + "contact": { + "support_url": "https://github.com/", + "feature_request_url": "https://github.com/", + "email": "support@example.com", + "live_chat_url": "https://github.com/", }, - 'social_accounts': [ - {'name': 'GitHub', 'icon': 'github', 'url': 'https://github.com/'}, - {'name': 'X', 'icon': 'x', 'url': 'https://x.com/'} + "social_accounts": [ + {"name": "GitHub", "icon": "github", "url": "https://github.com/"}, + {"name": "X", "icon": "x", "url": "https://x.com/"}, ], - 'legal': { - 'user_agreement': '', - 'privacy_policy': '' - }, - 'copyright': '© 2025-2026 QuantDinger' + "legal": {"user_agreement": "", "privacy_policy": ""}, + "copyright": "© 2025-2026 QuantDinger", } - return jsonify({'code': 1, 'msg': 'success', 'data': data}) + return jsonify({"code": 1, "msg": "success", "data": data}) -@market_bp.route('/symbols/search', methods=['GET']) + +@market_bp.route("/symbols/search", methods=["GET"]) def search_symbols(): """ Lightweight symbol search. In local-only mode we keep this simple; frontend allows manual input when no results. """ try: - market = (request.args.get('market') or '').strip() - keyword = (request.args.get('keyword') or '').strip().upper() - limit = int(request.args.get('limit') or 20) + market = (request.args.get("market") or "").strip() + keyword = (request.args.get("keyword") or "").strip().upper() + limit = int(request.args.get("limit") or 20) if not market or not keyword: - return jsonify({'code': 1, 'msg': 'success', 'data': []}) + return jsonify({"code": 1, "msg": "success", "data": []}) out = seed_search_symbols(market=market, keyword=keyword, limit=limit) - return jsonify({'code': 1, 'msg': 'success', 'data': out}) + return jsonify({"code": 1, "msg": "success", "data": out}) except Exception as e: logger.error(f"search_symbols failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 + return jsonify({"code": 0, "msg": str(e), "data": []}), 500 -@market_bp.route('/symbols/hot', methods=['GET']) + +@market_bp.route("/symbols/hot", methods=["GET"]) def get_hot_symbols(): """Return a small curated hot list per market (local-only).""" try: - market = (request.args.get('market') or '').strip() - limit = int(request.args.get('limit') or 10) + market = (request.args.get("market") or "").strip() + limit = int(request.args.get("limit") or 10) hot = seed_get_hot_symbols(market=market, limit=limit) - return jsonify({'code': 1, 'msg': 'success', 'data': hot}) + return jsonify({"code": 1, "msg": "success", "data": hot}) except Exception as e: logger.error(f"get_hot_symbols failed: {str(e)}") - return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 + return jsonify({"code": 0, "msg": str(e), "data": []}), 500 -@market_bp.route('/watchlist/get', methods=['GET']) + +@market_bp.route("/watchlist/get", methods=["GET"]) @login_required def get_watchlist(): """Get watchlist for the current user.""" @@ -193,8 +200,7 @@ def get_watchlist(): with get_db_connection() as db: cur = db.cursor() cur.execute( - "SELECT id, market, symbol, name FROM qd_watchlist WHERE user_id = ? ORDER BY id DESC", - (user_id,) + "SELECT id, market, symbol, name FROM qd_watchlist WHERE user_id = ? ORDER BY id DESC", (user_id,) ) rows = cur.fetchall() or [] @@ -202,42 +208,43 @@ def get_watchlist(): # This keeps UI consistent without requiring users to re-add items. for row in rows: try: - market = row.get('market') - symbol = row.get('symbol') - current_name = (row.get('name') or '').strip() + market = row.get("market") + symbol = row.get("symbol") + current_name = (row.get("name") or "").strip() if not market or not symbol: continue if current_name and current_name != symbol: continue resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol) if resolved and resolved != current_name: - row['name'] = resolved + row["name"] = resolved cur.execute( "UPDATE qd_watchlist SET name = ?, updated_at = NOW() WHERE user_id = ? AND market = ? AND symbol = ?", - (resolved, user_id, market, symbol) + (resolved, user_id, market, symbol), ) except Exception: continue db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': rows}) + return jsonify({"code": 1, "msg": "success", "data": rows}) except Exception as e: logger.error(f"get_watchlist failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 + return jsonify({"code": 0, "msg": str(e), "data": []}), 500 -@market_bp.route('/watchlist/add', methods=['POST']) + +@market_bp.route("/watchlist/add", methods=["POST"]) @login_required def add_watchlist(): """Add a symbol to watchlist for the current user.""" try: user_id = g.user_id data = request.get_json() or {} - market = (data.get('market') or '').strip() - symbol = _normalize_symbol(data.get('symbol')) - name_in = (data.get('name') or '').strip() + market = (data.get("market") or "").strip() + symbol = _normalize_symbol(data.get("symbol")) + name_in = (data.get("name") or "").strip() if not market or not symbol: - return jsonify({'code': 0, 'msg': 'Missing market or symbol', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Missing market or symbol", "data": None}), 400 # Prefer frontend-provided name (search results), otherwise resolve via seed/public sources. resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol) @@ -248,47 +255,45 @@ def add_watchlist(): # Insert or update (PostgreSQL UPSERT) cur.execute( """ - INSERT INTO qd_watchlist (user_id, market, symbol, name, created_at, updated_at) + INSERT INTO qd_watchlist (user_id, market, symbol, name, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW()) ON CONFLICT(user_id, market, symbol) DO UPDATE SET name = excluded.name, updated_at = NOW() """, - (user_id, market, symbol, name) + (user_id, market, symbol, name), ) db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': None}) + return jsonify({"code": 1, "msg": "success", "data": None}) except Exception as e: logger.error(f"add_watchlist failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@market_bp.route('/watchlist/remove', methods=['POST']) + +@market_bp.route("/watchlist/remove", methods=["POST"]) @login_required def remove_watchlist(): """Remove a symbol from watchlist for the current user.""" try: user_id = g.user_id data = request.get_json() or {} - symbol = _normalize_symbol(data.get('symbol')) + symbol = _normalize_symbol(data.get("symbol")) if not symbol: - return jsonify({'code': 0, 'msg': 'Missing symbol', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Missing symbol", "data": None}), 400 with get_db_connection() as db: cur = db.cursor() - cur.execute( - "DELETE FROM qd_watchlist WHERE user_id = ? AND symbol = ?", - (user_id, symbol) - ) + cur.execute("DELETE FROM qd_watchlist WHERE user_id = ? AND symbol = ?", (user_id, symbol)) db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': None}) + return jsonify({"code": 1, "msg": "success", "data": None}) except Exception as e: logger.error(f"remove_watchlist failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 def get_single_price(market: str, symbol: str) -> dict: @@ -297,62 +302,54 @@ def get_single_price(market: str, symbol: str) -> dict: # Use get_realtime_price to get the real-time price (30 seconds cached internally) # Compared with the original '1D' K-line logic, this can reflect changes in 24h markets such as Crypto in a more timely manner price_data = kline_service.get_realtime_price(market, symbol) - + return { - 'market': market, - 'symbol': symbol, - 'price': price_data.get('price', 0), - 'change': price_data.get('change', 0), - 'changePercent': price_data.get('changePercent', 0) + "market": market, + "symbol": symbol, + "price": price_data.get("price", 0), + "change": price_data.get("change", 0), + "changePercent": price_data.get("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 - } + return {"market": market, "symbol": symbol, "price": 0, "change": 0, "changePercent": 0} -@market_bp.route('/watchlist/prices', methods=['GET']) +@market_bp.route("/watchlist/prices", methods=["GET"]) def get_watchlist_prices(): """ Get the prices of self-selected stocks in batches - + Params (Query String): watchlist: JSON string of list of {market, symbol} objects e.g. ?watchlist=[{"market":"USStock","symbol":"AAPL"}] """ try: - watchlist_str = request.args.get('watchlist', '[]') + watchlist_str = request.args.get("watchlist", "[]") try: watchlist = json.loads(watchlist_str) except Exception: watchlist = [] - + if not watchlist or not isinstance(watchlist, list): - return jsonify({ - 'code': 0, - 'msg': 'Invalid watchlist format (expected JSON list in query param)', - 'data': [] - }), 400 - + return jsonify( + {"code": 0, "msg": "Invalid watchlist format (expected JSON list in query param)", "data": []} + ), 400 + # logger.info(f"Start getting {len(watchlist)} self-selected stock price data") - + results = [] - + # Fetch prices in parallel using thread pool futures = {} for item in watchlist: - market = item.get('market', '') - symbol = item.get('symbol', '') - + market = item.get("market", "") + symbol = item.get("symbol", "") + if market and symbol: future = executor.submit(get_single_price, market, symbol) futures[future] = (market, symbol) - + # Collect results (with timeout protection) completed_futures = set() try: @@ -364,94 +361,70 @@ def get_watchlist_prices(): except Exception as e: market, symbol = futures[future] logger.warning(f"Price fetch failed: {market}:{symbol} - {str(e)}") - results.append({ - 'market': market, - 'symbol': symbol, - 'price': 0, - 'change': 0, - 'changePercent': 0 - }) + results.append({"market": market, "symbol": symbol, "price": 0, "change": 0, "changePercent": 0}) except TimeoutError: # Add default results for unfinished tasks on timeout for future, (market, symbol) in futures.items(): if future not in completed_futures: logger.warning(f"Price fetch timed out: {market}:{symbol}") - results.append({ - 'market': market, - 'symbol': symbol, - 'price': 0, - 'change': 0, - 'changePercent': 0, - 'error': 'timeout' - }) - - success_count = sum(1 for r in results if r.get('price', 0) > 0) + results.append( + { + "market": market, + "symbol": symbol, + "price": 0, + "change": 0, + "changePercent": 0, + "error": "timeout", + } + ) + + success_count = sum(1 for r in results if r.get("price", 0) > 0) logger.info(f"Watchlist prices: {success_count}/{len(results)} successful") - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': results - }) - + + return jsonify({"code": 1, "msg": "success", "data": results}) + except Exception as e: logger.error(f"Batch watchlist price fetch failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({ - 'code': 0, - 'msg': f'Failed: {str(e)}', - 'data': [] - }), 500 + return jsonify({"code": 0, "msg": f"Failed: {str(e)}", "data": []}), 500 -@market_bp.route('/price', methods=['GET']) +@market_bp.route("/price", methods=["GET"]) def get_price(): """ Get the price of a single target - + parameter: market: market type symbol: transaction target """ try: - market = request.args.get('market', '') - symbol = request.args.get('symbol', '') - + market = request.args.get("market", "") + symbol = request.args.get("symbol", "") + if not market or not symbol: - return jsonify({ - 'code': 0, - 'msg': 'Missing market or symbol parameter(s)', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": "Missing market or symbol parameter(s)", "data": None}), 400 + result = get_single_price(market, symbol) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': result - }) - + + return jsonify({"code": 1, "msg": "success", "data": result}) + except Exception as e: logger.error(f"Failed to fetch price: {str(e)}") - return jsonify({ - 'code': 0, - 'msg': f'Failed: {str(e)}', - 'data': None - }), 500 + return jsonify({"code": 0, "msg": f"Failed: {str(e)}", "data": None}), 500 -@market_bp.route('/stock/name', methods=['POST']) +@market_bp.route("/stock/name", methods=["POST"]) def get_stock_name(): """ Get stock name - + Request body: { "market": "USStock", "symbol": "AAPL" } - + response: { "code": 1, @@ -464,102 +437,82 @@ def get_stock_name(): try: data = request.get_json() if not data: - return jsonify({ - 'code': 0, - 'msg': 'Request body is required', - 'data': None - }), 400 - - market = data.get('market', '') - symbol = data.get('symbol', '') - + return jsonify({"code": 0, "msg": "Request body is required", "data": None}), 400 + + market = data.get("market", "") + symbol = data.get("symbol", "") + if not market or not symbol: - return jsonify({ - 'code': 0, - 'msg': 'Missing market or symbol parameter(s)', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": "Missing market or symbol parameter(s)", "data": None}), 400 + # Try to get from cache (1 day cache) cache_key = f"stock_name:{market}:{symbol}" cached_name = cache.get(cache_key) - + if cached_name: logger.debug(f"Stock name cache hit: {market}:{symbol}") - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': {'name': cached_name} - }) - + return jsonify({"code": 1, "msg": "success", "data": {"name": cached_name}}) + # Get stock names based on different markets stock_name = symbol # Default use code - + try: - if market == 'USStock': + if market == "USStock": # For stocks, try to get basic information import yfinance as yf - + yf_symbol = symbol ticker = yf.Ticker(yf_symbol) info = ticker.info - + # Try to get the name - stock_name = info.get('longName') or info.get('shortName') or symbol - - elif market == 'Crypto': + stock_name = info.get("longName") or info.get("shortName") or symbol + + elif market == "Crypto": # Cryptocurrency, using trading pair format - if '/' in symbol: + if "/" in symbol: stock_name = symbol else: stock_name = f"{symbol}/USDT" - - elif market == 'Forex': + + elif market == "Forex": # Forex forex_names = { - 'XAUUSD': '黄金', - 'XAGUSD': '白银', - 'EURUSD': '欧元/美元', - 'GBPUSD': '英镑/美元', - 'USDJPY': '美元/日元', - 'AUDUSD': '澳元/美元', - 'USDCAD': '美元/加元', - 'USDCHF': '美元/瑞郎', + "XAUUSD": "黄金", + "XAGUSD": "白银", + "EURUSD": "欧元/美元", + "GBPUSD": "英镑/美元", + "USDJPY": "美元/日元", + "AUDUSD": "澳元/美元", + "USDCAD": "美元/加元", + "USDCHF": "美元/瑞郎", } stock_name = forex_names.get(symbol, symbol) - - elif market == 'Futures': + + elif market == "Futures": # futures futures_names = { - 'GC': '黄金期货', - 'SI': '白银期货', - 'CL': '原油期货', - 'NG': '天然气期货', - 'ZC': '玉米期货', - 'ZW': '小麦期货', - 'BTCUSDT': 'BTC永续合约', - 'ETHUSDT': 'ETH永续合约', + "GC": "黄金期货", + "SI": "白银期货", + "CL": "原油期货", + "NG": "天然气期货", + "ZC": "玉米期货", + "ZW": "小麦期货", + "BTCUSDT": "BTC永续合约", + "ETHUSDT": "ETH永续合约", } stock_name = futures_names.get(symbol, symbol) - + except Exception as e: logger.warning(f"Failed to fetch stock name; falling back to symbol: {market}:{symbol} - {str(e)}") stock_name = symbol - + # Cache for 1 day cache.set(cache_key, stock_name, 86400) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': {'name': stock_name} - }) - + + return jsonify({"code": 1, "msg": "success", "data": {"name": stock_name}}) + except Exception as e: logger.error(f"Failed to fetch stock name: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({ - 'code': 0, - 'msg': f'Failed: {str(e)}', - 'data': None - }), 500 + return jsonify({"code": 0, "msg": f"Failed: {str(e)}", "data": None}), 500 diff --git a/backend_api_python/app/routes/mt5.py b/backend_api_python/app/routes/mt5.py index 0026e4f..ea239ba 100644 --- a/backend_api_python/app/routes/mt5.py +++ b/backend_api_python/app/routes/mt5.py @@ -4,7 +4,7 @@ MetaTrader 5 Trading API Routes Provides REST API for MT5 trading operations. """ -from flask import Blueprint, request, jsonify +from flask import Blueprint, jsonify, request from app.utils.logger import get_logger @@ -23,7 +23,9 @@ def _ensure_mt5_imports(): global MT5Client, MT5Config if MT5Client is None or MT5Config is None: try: - from app.services.mt5_trading import MT5Client as _MT5Client, MT5Config as _MT5Config + from app.services.mt5_trading import MT5Client as _MT5Client + from app.services.mt5_trading import MT5Config as _MT5Config + MT5Client = _MT5Client MT5Config = _MT5Config except ImportError as e: @@ -45,6 +47,7 @@ def _get_client(): # ==================== Connection Management ==================== + @mt5_bp.route("/status", methods=["GET"]) def get_status(): """Get MT5 connection status.""" @@ -54,11 +57,9 @@ def get_status(): status = client.get_connection_status() return jsonify(status) except ImportError as e: - return jsonify({ - "connected": False, - "error": str(e), - "hint": "MetaTrader5 library is not installed or not on Windows" - }) + return jsonify( + {"connected": False, "error": str(e), "hint": "MetaTrader5 library is not installed or not on Windows"} + ) except Exception as e: logger.error(f"Get MT5 status failed: {e}") return jsonify({"connected": False, "error": str(e)}) @@ -68,7 +69,7 @@ def get_status(): def connect(): """ Connect to MT5 terminal. - + Request body: { "login": 12345678, // MT5 account number @@ -78,64 +79,53 @@ def connect(): } """ global _client - + try: _ensure_mt5_imports() - + data = request.get_json() or {} - + login = data.get("login") or data.get("mt5_login") password = data.get("password") or data.get("mt5_password") server = data.get("server") or data.get("mt5_server") terminal_path = data.get("terminal_path") or data.get("mt5_terminal_path") or "" - + if not login or not password or not server: - return jsonify({ - "success": False, - "error": "Missing required fields: login, password, server" - }), 400 - + return jsonify({"success": False, "error": "Missing required fields: login, password, server"}), 400 + config = MT5Config( login=int(login), password=str(password), server=str(server), terminal_path=str(terminal_path), ) - + # Create new client with config _client = MT5Client(config) - + if _client.connect(): account_info = _client.get_account_info() - return jsonify({ - "success": True, - "message": "Connected to MT5", - "account": account_info - }) + return jsonify({"success": True, "message": "Connected to MT5", "account": account_info}) else: - return jsonify({ - "success": False, - "error": "Failed to connect to MT5. Check credentials and ensure terminal is running." - }), 400 - + return jsonify( + { + "success": False, + "error": "Failed to connect to MT5. Check credentials and ensure terminal is running.", + } + ), 400 + except ImportError as e: - return jsonify({ - "success": False, - "error": str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 except Exception as e: logger.error(f"MT5 connect failed: {e}") - return jsonify({ - "success": False, - "error": str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 @mt5_bp.route("/disconnect", methods=["POST"]) def disconnect(): """Disconnect from MT5 terminal.""" global _client - + try: if _client is not None: _client.disconnect() @@ -148,6 +138,7 @@ def disconnect(): # ==================== Account Queries ==================== + @mt5_bp.route("/account", methods=["GET"]) def get_account(): """Get account information.""" @@ -155,7 +146,7 @@ def get_account(): client = _get_client() if not client.connected: return jsonify({"success": False, "error": "Not connected to MT5"}), 400 - + info = client.get_account_info() return jsonify(info) except Exception as e: @@ -170,7 +161,7 @@ def get_positions(): client = _get_client() if not client.connected: return jsonify({"success": False, "error": "Not connected to MT5"}), 400 - + symbol = request.args.get("symbol") positions = client.get_positions(symbol=symbol) return jsonify({"success": True, "positions": positions}) @@ -186,7 +177,7 @@ def get_orders(): client = _get_client() if not client.connected: return jsonify({"success": False, "error": "Not connected to MT5"}), 400 - + symbol = request.args.get("symbol") orders = client.get_orders(symbol=symbol) return jsonify({"success": True, "orders": orders}) @@ -202,7 +193,7 @@ def get_symbols(): client = _get_client() if not client.connected: return jsonify({"success": False, "error": "Not connected to MT5"}), 400 - + group = request.args.get("group", "*") symbols = client.get_symbols(group=group) return jsonify({"success": True, "symbols": symbols}) @@ -213,11 +204,12 @@ def get_symbols(): # ==================== Trading ==================== + @mt5_bp.route("/order", methods=["POST"]) def place_order(): """ Place an order. - + Request body: { "symbol": "EURUSD", @@ -231,28 +223,22 @@ def place_order(): client = _get_client() if not client.connected: return jsonify({"success": False, "error": "Not connected to MT5"}), 400 - + data = request.get_json() or {} - + symbol = data.get("symbol") side = data.get("side") volume = data.get("volume") or data.get("quantity") order_type = data.get("orderType", "market").lower() price = data.get("price") comment = data.get("comment", "QuantDinger") - + if not symbol or not side or not volume: - return jsonify({ - "success": False, - "error": "Missing required fields: symbol, side, volume" - }), 400 - + return jsonify({"success": False, "error": "Missing required fields: symbol, side, volume"}), 400 + if order_type == "limit": if not price: - return jsonify({ - "success": False, - "error": "Limit order requires price" - }), 400 + return jsonify({"success": False, "error": "Limit order requires price"}), 400 result = client.place_limit_order( symbol=symbol, side=side, @@ -267,23 +253,22 @@ def place_order(): volume=float(volume), comment=comment, ) - + if result.success: - return jsonify({ - "success": True, - "order_id": result.order_id, - "deal_id": result.deal_id, - "filled": result.filled, - "price": result.price, - "status": result.status, - "message": result.message, - }) + return jsonify( + { + "success": True, + "order_id": result.order_id, + "deal_id": result.deal_id, + "filled": result.filled, + "price": result.price, + "status": result.status, + "message": result.message, + } + ) else: - return jsonify({ - "success": False, - "error": result.message - }), 400 - + return jsonify({"success": False, "error": result.message}), 400 + except Exception as e: logger.error(f"MT5 place order failed: {e}") return jsonify({"success": False, "error": str(e)}), 500 @@ -293,7 +278,7 @@ def place_order(): def close_position(): """ Close a position. - + Request body: { "ticket": 123456789, // Position ticket @@ -304,38 +289,34 @@ def close_position(): client = _get_client() if not client.connected: return jsonify({"success": False, "error": "Not connected to MT5"}), 400 - + data = request.get_json() or {} - + ticket = data.get("ticket") volume = data.get("volume") - + if not ticket: - return jsonify({ - "success": False, - "error": "Missing required field: ticket" - }), 400 - + return jsonify({"success": False, "error": "Missing required field: ticket"}), 400 + result = client.close_position( ticket=int(ticket), volume=float(volume) if volume else None, ) - + if result.success: - return jsonify({ - "success": True, - "order_id": result.order_id, - "deal_id": result.deal_id, - "filled": result.filled, - "price": result.price, - "message": result.message, - }) + return jsonify( + { + "success": True, + "order_id": result.order_id, + "deal_id": result.deal_id, + "filled": result.filled, + "price": result.price, + "message": result.message, + } + ) else: - return jsonify({ - "success": False, - "error": result.message - }), 400 - + return jsonify({"success": False, "error": result.message}), 400 + except Exception as e: logger.error(f"MT5 close position failed: {e}") return jsonify({"success": False, "error": str(e)}), 500 @@ -348,12 +329,12 @@ def cancel_order(ticket: int): client = _get_client() if not client.connected: return jsonify({"success": False, "error": "Not connected to MT5"}), 400 - + if client.cancel_order(ticket): return jsonify({"success": True, "message": f"Order {ticket} cancelled"}) else: return jsonify({"success": False, "error": "Failed to cancel order"}), 400 - + except Exception as e: logger.error(f"MT5 cancel order failed: {e}") return jsonify({"success": False, "error": str(e)}), 500 @@ -361,11 +342,12 @@ def cancel_order(ticket: int): # ==================== Market Data ==================== + @mt5_bp.route("/quote", methods=["GET"]) def get_quote(): """ Get real-time quote. - + Query params: - symbol: Trading symbol (e.g., EURUSD) """ @@ -373,14 +355,14 @@ def get_quote(): client = _get_client() if not client.connected: return jsonify({"success": False, "error": "Not connected to MT5"}), 400 - + symbol = request.args.get("symbol") if not symbol: return jsonify({"success": False, "error": "Missing symbol parameter"}), 400 - + quote = client.get_quote(symbol) return jsonify(quote) - + except Exception as e: logger.error(f"MT5 get quote failed: {e}") return jsonify({"success": False, "error": str(e)}), 500 diff --git a/backend_api_python/app/routes/polymarket.py b/backend_api_python/app/routes/polymarket.py index a767a0c..cf992b8 100644 --- a/backend_api_python/app/routes/polymarket.py +++ b/backend_api_python/app/routes/polymarket.py @@ -2,18 +2,20 @@ Polymarket prediction market API routing Provide on-demand analysis interface (read-only, no transactions involved) """ -from flask import Blueprint, jsonify, request, g -from app.utils.auth import login_required -from app.utils.logger import get_logger -from app.utils.db import get_db_connection -from app.data_sources.polymarket import PolymarketDataSource -import re import json +import re + +from flask import Blueprint, g, jsonify, request + +from app.data_sources.polymarket import PolymarketDataSource +from app.utils.auth import login_required +from app.utils.db import get_db_connection +from app.utils.logger import get_logger logger = get_logger(__name__) -polymarket_bp = Blueprint('polymarket', __name__) +polymarket_bp = Blueprint("polymarket", __name__) # Initialize service polymarket_source = PolymarketDataSource() @@ -24,13 +26,13 @@ polymarket_source = PolymarketDataSource() def analyze_polymarket(): """ Analyze Polymarket prediction market (user enters link or title) - + POST /api/polymarket/analyze Body: { "input": "https://polymarket.com/event/xxx" or "market title", "language": "zh-CN" (optional) } - + process: 1. Parse market_id or slug from input 2. Get market data from API @@ -39,40 +41,33 @@ def analyze_polymarket(): 5. Return analysis results """ try: + from decimal import Decimal + from app.services.billing_service import BillingService from app.services.polymarket_analyzer import PolymarketAnalyzer - from decimal import Decimal - - user_id = getattr(g, 'user_id', None) + + user_id = getattr(g, "user_id", None) if not user_id: - return jsonify({ - "code": 0, - "msg": "User not authenticated", - "data": None - }), 401 - + return jsonify({"code": 0, "msg": "User not authenticated", "data": None}), 401 + data = request.get_json() or {} - input_text = (data.get('input') or '').strip() - language = data.get('language', 'zh-CN') - + input_text = (data.get("input") or "").strip() + language = data.get("language", "zh-CN") + if not input_text: - return jsonify({ - "code": 0, - "msg": "Input is required (Polymarket URL or market title)", - "data": None - }), 400 - + return jsonify({"code": 0, "msg": "Input is required (Polymarket URL or market title)", "data": None}), 400 + # 1. Parse market_id or slug market_id = None slug = None - + # Try to extract from URL url_patterns = [ - r'polymarket\.com/event/([^/?]+)', - r'polymarket\.com/markets/(\d+)', - r'polymarket\.com/market/(\d+)', + r"polymarket\.com/event/([^/?]+)", + r"polymarket\.com/markets/(\d+)", + r"polymarket\.com/market/(\d+)", ] - + for pattern in url_patterns: match = re.search(pattern, input_text) if match: @@ -83,7 +78,7 @@ def analyze_polymarket(): else: slug = extracted break - + # If not extracted from the URL, try searching the market if not market_id and not slug: # Try searching by title @@ -91,16 +86,18 @@ def analyze_polymarket(): search_results = polymarket_source.search_markets(input_text, limit=5) if search_results: # Use first search result - market_id = search_results[0].get('market_id') + market_id = search_results[0].get("market_id") logger.info(f"Found market via search: {market_id}") - + if not market_id and not slug: - return jsonify({ - "code": 0, - "msg": "Could not parse market ID or slug from input. Please provide a valid Polymarket URL or market title.", - "data": None - }), 400 - + return jsonify( + { + "code": 0, + "msg": "Could not parse market ID or slug from input. Please provide a valid Polymarket URL or market title.", + "data": None, + } + ), 400 + # 2. Obtain market data if market_id: market = polymarket_source.get_market_details(market_id) @@ -109,123 +106,103 @@ def analyze_polymarket(): search_results = polymarket_source.search_markets(slug, limit=10) market = None for result in search_results: - if result.get('slug') == slug or slug in (result.get('question') or ''): + if result.get("slug") == slug or slug in (result.get("question") or ""): market = result - market_id = result.get('market_id') + market_id = result.get("market_id") break - + if not market and search_results: # Use first search result market = search_results[0] - market_id = market.get('market_id') - + market_id = market.get("market_id") + if not market: - return jsonify({ - "code": 0, - "msg": "Market not found. Please check the URL or title.", - "data": None - }), 404 - + return jsonify({"code": 0, "msg": "Market not found. Please check the URL or title.", "data": None}), 404 + if not market_id: - market_id = market.get('market_id') - + market_id = market.get("market_id") + if not market_id: - return jsonify({ - "code": 0, - "msg": "Invalid market data", - "data": None - }), 400 - + return jsonify({"code": 0, "msg": "Invalid market data", "data": None}), 400 + # 3. Check billing billing = BillingService() cost = 0 - + if billing.is_billing_enabled(): - cost = billing.get_feature_cost('polymarket_deep_analysis') - + cost = billing.get_feature_cost("polymarket_deep_analysis") + if cost > 0: user_credits = billing.get_user_credits(user_id) if user_credits < Decimal(str(cost)): - return jsonify({ - "code": 0, - "msg": "Insufficient credits", - "data": { - "required": cost, - "current": float(user_credits), - "shortage": float(Decimal(str(cost)) - user_credits) + return jsonify( + { + "code": 0, + "msg": "Insufficient credits", + "data": { + "required": cost, + "current": float(user_credits), + "shortage": float(Decimal(str(cost)) - user_credits), + }, } - }), 400 - + ), 400 + # Deduct points (use check_and_consume method, it will automatically get the cost from the configuration) success, error_msg = billing.check_and_consume( - user_id=user_id, - feature='polymarket_deep_analysis', - reference_id=f"polymarket_{market_id}" + user_id=user_id, feature="polymarket_deep_analysis", reference_id=f"polymarket_{market_id}" ) - + if not success: # Check whether it is an error due to insufficient points - if error_msg.startswith('insufficient_credits'): - parts = error_msg.split(':') + if error_msg.startswith("insufficient_credits"): + parts = error_msg.split(":") if len(parts) >= 3: current_credits = parts[1] required_credits = parts[2] - return jsonify({ - "code": 0, - "msg": "Insufficient credits", - "data": { - "required": float(required_credits), - "current": float(current_credits), - "shortage": float(Decimal(required_credits) - Decimal(current_credits)) + return jsonify( + { + "code": 0, + "msg": "Insufficient credits", + "data": { + "required": float(required_credits), + "current": float(current_credits), + "shortage": float(Decimal(required_credits) - Decimal(current_credits)), + }, } - }), 400 - return jsonify({ - "code": 0, - "msg": f"Failed to deduct credits: {error_msg}", - "data": None - }), 500 - + ), 400 + return jsonify({"code": 0, "msg": f"Failed to deduct credits: {error_msg}", "data": None}), 500 + # 4. Perform AI analysis (pass language and model parameters) analyzer = PolymarketAnalyzer() - model = request.get_json().get('model') # Optional: Get model parameters from request + model = request.get_json().get("model") # Optional: Get model parameters from request analysis_result = analyzer.analyze_market( - market_id, - user_id=user_id, - use_cache=False, - language=language, - model=model + market_id, user_id=user_id, use_cache=False, language=language, model=model ) - - if analysis_result.get('error'): - return jsonify({ - "code": 0, - "msg": analysis_result.get('error', 'Analysis failed'), - "data": None - }), 500 - + + if analysis_result.get("error"): + return jsonify({"code": 0, "msg": analysis_result.get("error", "Analysis failed"), "data": None}), 500 + # 5. Get remaining points remaining_credits = 0 if billing.is_billing_enabled(): remaining_credits = float(billing.get_user_credits(user_id)) - - return jsonify({ - "code": 1, - "msg": "success", - "data": { - "market": market, - "analysis": analysis_result, - "credits_charged": cost, - "remaining_credits": remaining_credits + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "market": market, + "analysis": analysis_result, + "credits_charged": cost, + "remaining_credits": remaining_credits, + }, } - }) - + ) + except Exception as e: logger.error(f"Polymarket analyze API failed: {e}", exc_info=True) - return jsonify({ - "code": 0, - "msg": str(e), - "data": None - }), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @polymarket_bp.route("/history", methods=["GET"]) @@ -233,30 +210,34 @@ def analyze_polymarket(): def get_polymarket_history(): """ Get user's Polymarket analysis history. - + GET /api/polymarket/history?page=1&page_size=20 """ try: user_id = g.user_id - page = request.args.get('page', 1, type=int) - page_size = min(request.args.get('page_size', 20, type=int), 100) + page = request.args.get("page", 1, type=int) + page_size = min(request.args.get("page_size", 20, type=int), 100) offset = (page - 1) * page_size - + with get_db_connection() as db: cur = db.cursor() - + # Get total - cur.execute(""" + cur.execute( + """ SELECT COUNT(*) AS total FROM qd_analysis_tasks WHERE user_id = %s AND market = 'Polymarket' - """, (user_id,)) + """, + (user_id,), + ) total_row = cur.fetchone() - total = total_row['total'] if total_row else 0 - + total = total_row["total"] if total_row else 0 + # Get history - cur.execute(""" - SELECT + cur.execute( + """ + SELECT t.id, t.symbol AS market_id, t.model, @@ -269,60 +250,65 @@ def get_polymarket_history(): WHERE t.user_id = %s AND t.market = 'Polymarket' ORDER BY t.created_at DESC LIMIT %s OFFSET %s - """, (user_id, page_size, offset)) + """, + (user_id, page_size, offset), + ) rows = cur.fetchall() or [] cur.close() - + # Parse results items = [] for row in rows: - result_json = row.get('result_json', '{}') + result_json = row.get("result_json", "{}") try: result_data = json.loads(result_json) if result_json else {} - except: + except Exception as e: + logger.debug(f"Failed to parse result JSON for task {row.get('id')}: {e}") result_data = {} - - market_data = result_data.get('market', {}) - analysis_data = result_data.get('analysis', {}) - - created_at = row.get('created_at') - completed_at = row.get('completed_at') - if created_at and hasattr(created_at, 'isoformat'): + + market_data = result_data.get("market", {}) + analysis_data = result_data.get("analysis", {}) + + created_at = row.get("created_at") + completed_at = row.get("completed_at") + if created_at and hasattr(created_at, "isoformat"): created_at = created_at.isoformat() - if completed_at and hasattr(completed_at, 'isoformat'): + if completed_at and hasattr(completed_at, "isoformat"): completed_at = completed_at.isoformat() - - items.append({ - 'id': row.get('id'), - 'market_id': row.get('market_id'), - 'market_title': market_data.get('question') or market_data.get('title') or f"Market {row.get('market_id')}", - 'market_url': market_data.get('polymarket_url'), - 'ai_predicted_probability': analysis_data.get('ai_predicted_probability'), - 'market_probability': analysis_data.get('market_probability'), - 'recommendation': analysis_data.get('recommendation'), - 'opportunity_score': analysis_data.get('opportunity_score'), - 'confidence_score': analysis_data.get('confidence_score'), - 'status': row.get('status'), - 'created_at': created_at, - 'completed_at': completed_at - }) - - return jsonify({ - "code": 1, - "msg": "success", - "data": { - "items": items, - "total": total, - "page": page, - "page_size": page_size, - "total_pages": (total + page_size - 1) // page_size + + items.append( + { + "id": row.get("id"), + "market_id": row.get("market_id"), + "market_title": market_data.get("question") + or market_data.get("title") + or f"Market {row.get('market_id')}", + "market_url": market_data.get("polymarket_url"), + "ai_predicted_probability": analysis_data.get("ai_predicted_probability"), + "market_probability": analysis_data.get("market_probability"), + "recommendation": analysis_data.get("recommendation"), + "opportunity_score": analysis_data.get("opportunity_score"), + "confidence_score": analysis_data.get("confidence_score"), + "status": row.get("status"), + "created_at": created_at, + "completed_at": completed_at, + } + ) + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size, + }, } - }) - + ) + except Exception as e: logger.error(f"Get Polymarket history failed: {e}", exc_info=True) - return jsonify({ - "code": 0, - "msg": str(e), - "data": None - }), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 diff --git a/backend_api_python/app/routes/portfolio.py b/backend_api_python/app/routes/portfolio.py index 93014d3..8275747 100644 --- a/backend_api_python/app/routes/portfolio.py +++ b/backend_api_python/app/routes/portfolio.py @@ -2,25 +2,27 @@ Portfolio API routes (local-only). Manages manual positions (user's existing holdings) and AI monitoring tasks. """ -from flask import Blueprint, request, jsonify, g -from datetime import date, datetime, timezone -import json -import traceback -import time -import threading -from concurrent.futures import ThreadPoolExecutor, as_completed +import json +import threading +import time +import traceback +from concurrent.futures import ThreadPoolExecutor +from datetime import date, datetime, timezone + +from flask import Blueprint, g, jsonify, request + +from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name from app.services.kline import KlineService -from app.utils.logger import get_logger +from app.services.symbol_name import resolve_symbol_name +from app.utils.auth import login_required from app.utils.cache import CacheManager from app.utils.db import get_db_connection -from app.utils.auth import login_required -from app.services.symbol_name import resolve_symbol_name -from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name +from app.utils.logger import get_logger logger = get_logger(__name__) -portfolio_bp = Blueprint('portfolio', __name__) +portfolio_bp = Blueprint("portfolio", __name__) kline_service = KlineService() cache = CacheManager() @@ -62,7 +64,7 @@ def _serialize_monitor_ts(value): def _normalize_symbol(symbol: str) -> str: - return (symbol or '').strip().upper() + return (symbol or "").strip().upper() def _safe_json_loads(value, default=None): @@ -84,13 +86,13 @@ def _safe_json_loads(value, default=None): def _get_single_price(market: str, symbol: str, force_refresh: bool = False) -> dict: """ Get price data for a single symbol. - + Priority is given to using the real-time quotation API (ticker), and downgrading to minute/daily K-line data. This allows for more real-time prices during the trading session, rather than just showing daily closing prices. - + Built-in rate limit: requests for the same market must be at least REQUEST_INTERVAL seconds apart, Avoid triggering API limits (like yfinance, Tiingo, Finnhub, etc.). - + Args: force_refresh: whether to force refresh (skip cache) """ @@ -103,41 +105,35 @@ def _get_single_price(market: str, symbol: str, force_refresh: bool = False) -> if wait_time > 0: time.sleep(wait_time) _last_request_time[market] = time.time() - + # Get real-time prices using the new get_realtime_price method price_data = kline_service.get_realtime_price(market, symbol, force_refresh=force_refresh) - + return { - 'market': market, - 'symbol': symbol, - 'price': price_data.get('price', 0), - 'change': price_data.get('change', 0), - 'changePercent': price_data.get('changePercent', 0), - 'source': price_data.get('source', 'unknown') # Record data sources for easy debugging + "market": market, + "symbol": symbol, + "price": price_data.get("price", 0), + "change": price_data.get("change", 0), + "changePercent": price_data.get("changePercent", 0), + "source": price_data.get("source", "unknown"), # Record data sources for easy debugging } except Exception as e: logger.error(f"Failed to fetch price {market}:{symbol} - {str(e)}") - return { - 'market': market, - 'symbol': symbol, - 'price': 0, - 'change': 0, - 'changePercent': 0, - 'source': 'error' - } + return {"market": market, "symbol": symbol, "price": 0, "change": 0, "changePercent": 0, "source": "error"} # ==================== Position CRUD ==================== -@portfolio_bp.route('/positions', methods=['GET']) + +@portfolio_bp.route("/positions", methods=["GET"]) @login_required def get_positions(): """Get all manual positions with current prices for the current user.""" try: user_id = g.user_id # Check if force refresh (skip cache) - force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes') - + force_refresh = request.args.get("refresh", "").lower() in ("1", "true", "yes") + with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -147,44 +143,44 @@ def get_positions(): WHERE user_id = ? ORDER BY id DESC """, - (user_id,) + (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'), + "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 + "current_price": 0, + "price_change": 0, + "price_change_percent": 0, + "market_value": 0, + "cost_value": 0, + "pnl": 0, + "pnl_percent": 0, } positions.append(pos) - + # Submit price fetch task (with force_refresh support) - market = row.get('market') - symbol = row.get('symbol') + market = row.get("market") + symbol = row.get("symbol") if market and symbol: key = f"{market}:{symbol}" if key not in price_futures: @@ -204,169 +200,166 @@ def get_positions(): 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}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": []}), 500 -@portfolio_bp.route('/positions', methods=['POST']) +@portfolio_bp.route("/positions", methods=["POST"]) @login_required def add_position(): """Add a new manual position for the current user.""" try: user_id = g.user_id data = request.get_json() or {} - market = (data.get('market') or '').strip() - symbol = _normalize_symbol(data.get('symbol')) - name_in = (data.get('name') or '').strip() - side = (data.get('side') or 'long').strip().lower() - quantity = float(data.get('quantity') or 0) - entry_price = float(data.get('entry_price') or 0) - entry_time = data.get('entry_time') or _now_ts() - notes = (data.get('notes') or '').strip() - tags = data.get('tags') or [] - group_name = (data.get('group_name') or '').strip() - + 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 - + 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 - + 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' - + return jsonify({"code": 0, "msg": "Entry price must be positive", "data": None}), 400 + + if side not in ("long", "short"): + side = "long" + # Resolve display name resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol) name = name_in or resolved or symbol - + tags_json = json.dumps(tags if isinstance(tags, list) else [], ensure_ascii=False) - + with get_db_connection() as db: cur = db.cursor() # Delete any existing positions for this symbol (regardless of side), # ensuring only one position per symbol per user per group. cur.execute( "DELETE FROM qd_manual_positions WHERE user_id = ? AND market = ? AND symbol = ? AND group_name = ?", - (user_id, market, symbol, group_name) + (user_id, market, symbol, group_name), ) cur.execute( """ - INSERT INTO qd_manual_positions + INSERT INTO qd_manual_positions (user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) """, - (user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name) + (user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name), ) position_id = cur.lastrowid db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': {'id': position_id}}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@portfolio_bp.route('/positions/', methods=['PUT']) +@portfolio_bp.route("/positions/", methods=["PUT"]) @login_required def update_position(position_id): """Update an existing position for the current user.""" try: user_id = g.user_id data = request.get_json() or {} - + updates = [] params = [] - - if 'name' in data: - updates.append('name = ?') - params.append((data.get('name') or '').strip()) - - if 'quantity' in data: - quantity = float(data.get('quantity') or 0) + + if "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 = ?') + 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" 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 = ?') + 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 = ?') + + 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 "group_name" in data: + updates.append("group_name = ?") + params.append((data.get("group_name") or "").strip()) + if not updates: - return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400 - - updates.append('updated_at = NOW()') + return jsonify({"code": 0, "msg": "No fields to update", "data": None}), 400 + + updates.append("updated_at = NOW()") params.append(position_id) params.append(user_id) - + with get_db_connection() as db: cur = db.cursor() - cur.execute( - f"UPDATE qd_manual_positions SET {', '.join(updates)} WHERE id = ? AND user_id = ?", - params - ) + 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}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@portfolio_bp.route('/positions/', methods=['DELETE']) +@portfolio_bp.route("/positions/", methods=["DELETE"]) @login_required def delete_position(position_id): """Delete a position for the current user.""" @@ -374,29 +367,26 @@ def delete_position(position_id): user_id = g.user_id with get_db_connection() as db: cur = db.cursor() - cur.execute( - "DELETE FROM qd_manual_positions WHERE id = ? AND user_id = ?", - (position_id, user_id) - ) + cur.execute("DELETE FROM qd_manual_positions WHERE id = ? AND user_id = ?", (position_id, user_id)) db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': None}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@portfolio_bp.route('/summary', methods=['GET']) +@portfolio_bp.route("/summary", methods=["GET"]) @login_required def get_portfolio_summary(): """Get portfolio summary with total value, PnL, and market distribution for the current user.""" try: user_id = g.user_id # Check if force refresh - force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes') - + force_refresh = request.args.get("refresh", "").lower() in ("1", "true", "yes") + with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -405,30 +395,32 @@ def get_portfolio_summary(): FROM qd_manual_positions WHERE user_id = ? """, - (user_id,) + (user_id,), ) rows = cur.fetchall() or [] cur.close() if not rows: - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'total_cost': 0, - 'total_market_value': 0, - 'total_pnl': 0, - 'total_pnl_percent': 0, - 'position_count': 0, - 'market_distribution': [] + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "total_cost": 0, + "total_market_value": 0, + "total_pnl": 0, + "total_pnl_percent": 0, + "position_count": 0, + "market_distribution": [], + }, } - }) + ) # Fetch prices in parallel (with force_refresh support) price_futures = {} for row in rows: - market = row.get('market') - symbol = row.get('symbol') + market = row.get("market") + symbol = row.get("symbol") key = f"{market}:{symbol}" if key not in price_futures: future = executor.submit(_get_single_price, market, symbol, force_refresh) @@ -447,70 +439,69 @@ def get_portfolio_summary(): 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) - + 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) - + current_price = float(price_data.get("price") or 0) + cost = entry_price * quantity market_val = current_price * quantity - - if side == 'long': + + 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) + market_distribution.append({"market": market, "value": round(value, 2), "percent": percent}) - 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 + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # ==================== Monitor CRUD ==================== -@portfolio_bp.route('/monitors', methods=['GET']) + +@portfolio_bp.route("/monitors", methods=["GET"]) @login_required def get_monitors(): """Get all position monitors for the current user.""" @@ -520,79 +511,91 @@ def get_monitors(): cur = db.cursor() cur.execute( """ - SELECT id, name, position_ids, monitor_type, config, notification_config, + SELECT id, name, position_ids, monitor_type, config, notification_config, is_active, last_run_at, next_run_at, last_result, run_count, created_at, updated_at FROM qd_position_monitors WHERE user_id = ? ORDER BY id DESC """, - (user_id,) + (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': _serialize_monitor_ts(row.get('last_run_at')), - 'next_run_at': _serialize_monitor_ts(row.get('next_run_at')), - 'last_result': _safe_json_loads(row.get('last_result'), {}), - 'run_count': row.get('run_count') or 0, - 'created_at': _serialize_monitor_ts(row.get('created_at')), - 'updated_at': _serialize_monitor_ts(row.get('updated_at')) - }) + 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": _serialize_monitor_ts(row.get("last_run_at")), + "next_run_at": _serialize_monitor_ts(row.get("next_run_at")), + "last_result": _safe_json_loads(row.get("last_result"), {}), + "run_count": row.get("run_count") or 0, + "created_at": _serialize_monitor_ts(row.get("created_at")), + "updated_at": _serialize_monitor_ts(row.get("updated_at")), + } + ) - return jsonify({'code': 1, 'msg': 'success', 'data': monitors}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": []}), 500 -@portfolio_bp.route('/monitors', methods=['POST']) +@portfolio_bp.route("/monitors", methods=["POST"]) @login_required def add_monitor(): """Add a new position monitor for the current user.""" try: user_id = g.user_id data = request.get_json() or {} - name = (data.get('name') or '').strip() - position_ids = data.get('position_ids') or [] - monitor_type = (data.get('monitor_type') or 'ai').strip() - config = data.get('config') or {} - notification_config = data.get('notification_config') or {} - is_active = bool(data.get('is_active', True)) - + 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' - + 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 (frontend sends run_interval_minutes) - interval_minutes = int(config.get('run_interval_minutes') or config.get('interval_minutes') or 60) - + interval_minutes = int(config.get("run_interval_minutes") or config.get("interval_minutes") or 60) + position_ids_json = json.dumps(position_ids if isinstance(position_ids, list) else [], ensure_ascii=False) config_json = json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False) - notification_config_json = json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False) - + 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 + INSERT INTO qd_position_monitors (user_id, name, position_ids, monitor_type, config, notification_config, is_active, next_run_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NOW() + INTERVAL '%s minutes', NOW(), NOW()) """, - (user_id, name, position_ids_json, monitor_type, config_json, notification_config_json, - 1 if is_active else 0, interval_minutes) + ( + user_id, + name, + position_ids_json, + monitor_type, + config_json, + notification_config_json, + 1 if is_active else 0, + interval_minutes, + ), ) monitor_id = cur.lastrowid db.commit() @@ -617,83 +620,82 @@ def add_monitor(): except Exception as ex: logger.error(f"Failed to schedule initial monitor run #{monitor_id}: {ex}") - return jsonify({'code': 1, 'msg': 'success', 'data': {'id': monitor_id}}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@portfolio_bp.route('/monitors/', methods=['PUT']) +@portfolio_bp.route("/monitors/", methods=["PUT"]) @login_required def update_monitor(monitor_id): """Update an existing monitor for the current user.""" try: user_id = g.user_id data = request.get_json() or {} - + updates = [] params = [] - - if 'name' in data: - updates.append('name = ?') - params.append((data.get('name') or '').strip()) - - if 'position_ids' in data: - position_ids = data.get('position_ids') or [] - updates.append('position_ids = ?') + + 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 "monitor_type" in data: + updates.append("monitor_type = ?") + params.append((data.get("monitor_type") or "ai").strip()) + next_run_interval = None # Will store interval for special handling - if 'config' in data: - config = data.get('config') or {} - updates.append('config = ?') + 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 - next_run_interval = int(config.get('run_interval_minutes') or config.get('interval_minutes') or 60) - - if 'notification_config' in data: - notification_config = data.get('notification_config') or {} - updates.append('notification_config = ?') - params.append(json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False)) - - if 'is_active' in data: - updates.append('is_active = ?') - params.append(1 if data.get('is_active') else 0) - + next_run_interval = int(config.get("run_interval_minutes") or config.get("interval_minutes") or 60) + + if "notification_config" in data: + notification_config = data.get("notification_config") or {} + updates.append("notification_config = ?") + params.append( + json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False) + ) + + if "is_active" in data: + updates.append("is_active = ?") + params.append(1 if data.get("is_active") else 0) + if not updates: - return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "No fields to update", "data": None}), 400 + # Add next_run_at update if interval was changed if next_run_interval is not None: updates.append(f"next_run_at = NOW() + INTERVAL '{next_run_interval} minutes'") - - updates.append('updated_at = NOW()') + + updates.append("updated_at = NOW()") params.append(monitor_id) params.append(user_id) - + with get_db_connection() as db: cur = db.cursor() - cur.execute( - f"UPDATE qd_position_monitors SET {', '.join(updates)} WHERE id = ? AND user_id = ?", - params - ) + 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}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@portfolio_bp.route('/monitors/', methods=['DELETE']) +@portfolio_bp.route("/monitors/", methods=["DELETE"]) @login_required def delete_monitor(monitor_id): """Delete a monitor for the current user.""" @@ -701,86 +703,82 @@ def delete_monitor(monitor_id): user_id = g.user_id with get_db_connection() as db: cur = db.cursor() - cur.execute( - "DELETE FROM qd_position_monitors WHERE id = ? AND user_id = ?", - (monitor_id, user_id) - ) + cur.execute("DELETE FROM qd_position_monitors WHERE id = ? AND user_id = ?", (monitor_id, user_id)) db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': None}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@portfolio_bp.route('/monitors//run', methods=['POST']) +@portfolio_bp.route("/monitors//run", methods=["POST"]) @login_required def run_monitor_now(monitor_id): """Manually trigger a monitor to run immediately. - + Supports two modes: - async=true (default): Returns immediately, runs in background, notifies via notification system - async=false: Waits for completion and returns result (may timeout for large portfolios) """ try: from app.services.portfolio_monitor import run_single_monitor - + user_id = g.user_id - + # Get parameters from request body data = request.get_json(force=True, silent=True) or {} - language = data.get('language') - async_mode = data.get('async', True) # Default to async mode - + language = data.get("language") + async_mode = data.get("async", True) # Default to async mode + # Fallback to Accept-Language header for language if not language: - accept_lang = request.headers.get('Accept-Language', '') - if 'zh' in accept_lang.lower(): - language = 'zh-CN' + accept_lang = request.headers.get("Accept-Language", "") + if "zh" in accept_lang.lower(): + language = "zh-CN" else: - language = 'en-US' - + language = "en-US" + if async_mode: # Async mode: Start background thread and return immediately import threading - + def run_in_background(mid, lang, uid): try: run_single_monitor(mid, override_language=lang, user_id=uid) except Exception as e: logger.error(f"Background monitor run failed: {e}") - - thread = threading.Thread( - target=run_in_background, - args=(monitor_id, language, user_id), - daemon=True - ) + + thread = threading.Thread(target=run_in_background, args=(monitor_id, language, user_id), daemon=True) thread.start() - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'status': 'running', - 'message': 'Monitor is running in background. Results will be sent via notification.' + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "status": "running", + "message": "Monitor is running in background. Results will be sent via notification.", + }, } - }) + ) else: # Sync mode: Wait for completion (may timeout) result = run_single_monitor(monitor_id, override_language=language, user_id=user_id) - return jsonify({'code': 1, 'msg': 'success', 'data': result}) - + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # ==================== Alerts CRUD ==================== -@portfolio_bp.route('/alerts', methods=['GET']) + +@portfolio_bp.route("/alerts", methods=["GET"]) @login_required def get_alerts(): """Get all position alerts for the current user.""" @@ -799,199 +797,218 @@ def get_alerts(): WHERE a.user_id = ? ORDER BY a.id DESC """, - (user_id,) + (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' - }) + 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}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": []}), 500 -@portfolio_bp.route('/alerts', methods=['POST']) +@portfolio_bp.route("/alerts", methods=["POST"]) @login_required def add_alert(): """Add a new position alert for the current user.""" try: user_id = g.user_id data = request.get_json() or {} - position_id = data.get('position_id') # Can be None for symbol-level alerts - market = (data.get('market') or '').strip() - symbol = _normalize_symbol(data.get('symbol')) - alert_type = (data.get('alert_type') or 'price_above').strip() - threshold = float(data.get('threshold') or 0) - notification_config = data.get('notification_config') or {} - is_active = bool(data.get('is_active', True)) - repeat_interval = int(data.get('repeat_interval') or 0) - notes = (data.get('notes') or '').strip() - + 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') + 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 - + return jsonify({"code": 0, "msg": f"Invalid alert_type. Must be one of: {valid_types}", "data": None}), 400 + # If position_id provided, get market/symbol from position if position_id: with get_db_connection() as db: cur = db.cursor() cur.execute( "SELECT market, symbol FROM qd_manual_positions WHERE id = ? AND user_id = ?", - (position_id, user_id) + (position_id, user_id), ) pos = cur.fetchone() cur.close() if pos: - market = pos.get('market') or market - symbol = pos.get('symbol') or symbol - + market = pos.get("market") or market + symbol = pos.get("symbol") or symbol + if not market or not symbol: - return jsonify({'code': 0, 'msg': 'Market and symbol are required', 'data': None}), 400 - - if threshold <= 0 and alert_type.startswith('price_'): - return jsonify({'code': 0, 'msg': 'Threshold must be positive for price alerts', 'data': None}), 400 - - notification_config_json = json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False) - + return jsonify({"code": 0, "msg": "Market and symbol are required", "data": None}), 400 + + if threshold <= 0 and alert_type.startswith("price_"): + return jsonify({"code": 0, "msg": "Threshold must be positive for price alerts", "data": None}), 400 + + notification_config_json = json.dumps( + notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False + ) + with get_db_connection() as db: cur = db.cursor() - + # Check if alert already exists for this position (unique constraint) existing_alert_id = None if position_id: cur.execute( - "SELECT id FROM qd_position_alerts WHERE position_id = ? AND user_id = ?", - (position_id, user_id) + "SELECT id FROM qd_position_alerts WHERE position_id = ? AND user_id = ?", (position_id, user_id) ) existing = cur.fetchone() if existing: - existing_alert_id = existing.get('id') - + 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 = ?, + UPDATE qd_position_alerts + SET alert_type = ?, threshold = ?, notification_config = ?, is_active = ?, is_triggered = 0, repeat_interval = ?, notes = ?, updated_at = NOW() WHERE id = ? """, - (alert_type, threshold, notification_config_json, - 1 if is_active else 0, repeat_interval, notes, existing_alert_id) + ( + alert_type, + threshold, + notification_config_json, + 1 if is_active else 0, + repeat_interval, + notes, + existing_alert_id, + ), ) alert_id = existing_alert_id else: # Create new alert cur.execute( """ - INSERT INTO qd_position_alerts - (user_id, position_id, market, symbol, alert_type, threshold, notification_config, + INSERT INTO qd_position_alerts + (user_id, position_id, market, symbol, alert_type, threshold, notification_config, is_active, repeat_interval, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) """, - (user_id, position_id, market, symbol, alert_type, threshold, notification_config_json, - 1 if is_active else 0, repeat_interval, notes) + ( + user_id, + position_id, + market, + symbol, + alert_type, + threshold, + notification_config_json, + 1 if is_active else 0, + repeat_interval, + notes, + ), ) alert_id = cur.lastrowid - + db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': {'id': alert_id}}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@portfolio_bp.route('/alerts/', methods=['PUT']) +@portfolio_bp.route("/alerts/", methods=["PUT"]) @login_required def update_alert(alert_id): """Update an existing alert for the current user.""" try: user_id = g.user_id data = request.get_json() or {} - + updates = [] params = [] - - if 'alert_type' in data: - updates.append('alert_type = ?') - params.append((data.get('alert_type') or 'price_above').strip()) - - if 'threshold' in data: - updates.append('threshold = ?') - params.append(float(data.get('threshold') or 0)) - - if 'notification_config' in data: - notification_config = data.get('notification_config') or {} - updates.append('notification_config = ?') - params.append(json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False)) - - if 'is_active' in data: - updates.append('is_active = ?') - params.append(1 if data.get('is_active') else 0) + + 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 = ?') + 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 "repeat_interval" in data: + updates.append("repeat_interval = ?") + params.append(int(data.get("repeat_interval") or 0)) + + if "notes" in data: + updates.append("notes = ?") + params.append((data.get("notes") or "").strip()) + if not updates: - return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400 - - updates.append('updated_at = NOW()') + return jsonify({"code": 0, "msg": "No fields to update", "data": None}), 400 + + updates.append("updated_at = NOW()") params.append(alert_id) params.append(user_id) - + with get_db_connection() as db: cur = db.cursor() - cur.execute( - f"UPDATE qd_position_alerts SET {', '.join(updates)} WHERE id = ? AND user_id = ?", - params - ) + 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}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@portfolio_bp.route('/alerts/', methods=['DELETE']) +@portfolio_bp.route("/alerts/", methods=["DELETE"]) @login_required def delete_alert(alert_id): """Delete an alert for the current user.""" @@ -999,23 +1016,21 @@ def delete_alert(alert_id): user_id = g.user_id with get_db_connection() as db: cur = db.cursor() - cur.execute( - "DELETE FROM qd_position_alerts WHERE id = ? AND user_id = ?", - (alert_id, user_id) - ) + cur.execute("DELETE FROM qd_position_alerts WHERE id = ? AND user_id = ?", (alert_id, user_id)) db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': None}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # ==================== Groups ==================== -@portfolio_bp.route('/groups', methods=['GET']) + +@portfolio_bp.route("/groups", methods=["GET"]) @login_required def get_groups(): """Get list of all groups with position counts for the current user.""" @@ -1031,66 +1046,56 @@ def get_groups(): GROUP BY group_name ORDER BY group_name """, - (user_id,) + (user_id,), ) rows = cur.fetchall() or [] - + # Also get count of ungrouped cur.execute( "SELECT COUNT(*) as count FROM qd_manual_positions WHERE user_id = ? AND (group_name IS NULL OR group_name = '')", - (user_id,) + (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 - }) - + 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 - } - }) + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@portfolio_bp.route('/groups/rename', methods=['POST']) +@portfolio_bp.route("/groups/rename", methods=["POST"]) @login_required def rename_group(): """Rename a group for the current user.""" try: user_id = g.user_id data = request.get_json() or {} - old_name = (data.get('old_name') or '').strip() - new_name = (data.get('new_name') or '').strip() - + 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 - + return jsonify({"code": 0, "msg": "old_name is required", "data": None}), 400 + with get_db_connection() as db: cur = db.cursor() cur.execute( "UPDATE qd_manual_positions SET group_name = ?, updated_at = NOW() WHERE user_id = ? AND group_name = ?", - (new_name, user_id, old_name) + (new_name, user_id, old_name), ) db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': None}) + 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 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 diff --git a/backend_api_python/app/routes/quick_trade.py b/backend_api_python/app/routes/quick_trade.py index ca019b2..0bd8cc0 100644 --- a/backend_api_python/app/routes/quick_trade.py +++ b/backend_api_python/app/routes/quick_trade.py @@ -15,6 +15,7 @@ Endpoints: from __future__ import annotations import json +import re as _re import time import traceback import uuid @@ -22,40 +23,64 @@ from typing import Any, Dict from flask import Blueprint, g, jsonify, request -from app.utils.db import get_db_connection -from app.utils.logger import get_logger from app.utils.auth import login_required from app.utils.credential_crypto import decrypt_credential_blob +from app.utils.db import get_db_connection +from app.utils.logger import get_logger logger = get_logger(__name__) -import re as _re _FRIENDLY_ERROR_PATTERNS = [ # Insufficient balance / margin - (_re.compile(r"INSUFFICIENT[_ ]?AVAILABLE|insufficient.{0,20}(balance|margin|fund)|margin.{0,30}while available|not enough|资金不足", _re.IGNORECASE), - "quickTrade.errorHints.insufficientBalance"), + ( + _re.compile( + r"INSUFFICIENT[_ ]?AVAILABLE|insufficient.{0,20}(balance|margin|fund)|margin.{0,30}while available|not enough|资金不足", + _re.IGNORECASE, + ), + "quickTrade.errorHints.insufficientBalance", + ), # Invalid size / quantity - (_re.compile(r"invalid.{0,10}size|invalid.{0,10}(qty|quantity|amount|volume)|Order size.{0,20}(too small|below|minimum)|MIN_NOTIONAL", _re.IGNORECASE), - "quickTrade.errorHints.invalidSize"), + ( + _re.compile( + r"invalid.{0,10}size|invalid.{0,10}(qty|quantity|amount|volume)|Order size.{0,20}(too small|below|minimum)|MIN_NOTIONAL", + _re.IGNORECASE, + ), + "quickTrade.errorHints.invalidSize", + ), # Invalid price - (_re.compile(r"invalid.{0,10}price|price.{0,20}(deviate|deviation|exceed|out of range)", _re.IGNORECASE), - "quickTrade.errorHints.invalidPrice"), + ( + _re.compile(r"invalid.{0,10}price|price.{0,20}(deviate|deviation|exceed|out of range)", _re.IGNORECASE), + "quickTrade.errorHints.invalidPrice", + ), # Rate limit - (_re.compile(r"rate.?limit|too many request|429|REQUEST_FREQUENCY", _re.IGNORECASE), - "quickTrade.errorHints.rateLimit"), + ( + _re.compile(r"rate.?limit|too many request|429|REQUEST_FREQUENCY", _re.IGNORECASE), + "quickTrade.errorHints.rateLimit", + ), # API key / permission - (_re.compile(r"(invalid|wrong|expired).{0,10}(api.?key|key|signature|sign)|NOT_LOGIN|UNAUTHORIZED|permission.{0,10}denied|IP.{0,20}(not|whitelist|restrict)", _re.IGNORECASE), - "quickTrade.errorHints.authError"), + ( + _re.compile( + r"(invalid|wrong|expired).{0,10}(api.?key|key|signature|sign)|NOT_LOGIN|UNAUTHORIZED|permission.{0,10}denied|IP.{0,20}(not|whitelist|restrict)", + _re.IGNORECASE, + ), + "quickTrade.errorHints.authError", + ), # Position / reduce-only conflict - (_re.compile(r"reduce.?only|position.{0,20}(not exist|not found|side)|POSITION_NOT_EXIST", _re.IGNORECASE), - "quickTrade.errorHints.positionConflict"), + ( + _re.compile(r"reduce.?only|position.{0,20}(not exist|not found|side)|POSITION_NOT_EXIST", _re.IGNORECASE), + "quickTrade.errorHints.positionConflict", + ), # Network / timeout - (_re.compile(r"timeout|timed? ?out|connect|ECONNREFUSED|SSL|ConnectionError|RemoteDisconnected", _re.IGNORECASE), - "quickTrade.errorHints.networkError"), + ( + _re.compile(r"timeout|timed? ?out|connect|ECONNREFUSED|SSL|ConnectionError|RemoteDisconnected", _re.IGNORECASE), + "quickTrade.errorHints.networkError", + ), # Exchange maintenance - (_re.compile(r"maintenance|unavailable|system.{0,10}(busy|error|upgrade)|suspend|暂停", _re.IGNORECASE), - "quickTrade.errorHints.exchangeMaintenance"), + ( + _re.compile(r"maintenance|unavailable|system.{0,10}(busy|error|upgrade)|suspend|暂停", _re.IGNORECASE), + "quickTrade.errorHints.exchangeMaintenance", + ), ] @@ -67,11 +92,13 @@ def _parse_trade_error_hint(error_str: str) -> str: return hint_key return "" -quick_trade_bp = Blueprint('quick_trade', __name__) + +quick_trade_bp = Blueprint("quick_trade", __name__) # ────────── helpers ────────── + def _symbols_match_quick_trade(user_symbol: str, position_symbol: str) -> bool: """Match UI symbol (e.g. ETH/USDT) with exchange-native ids (e.g. ETH_USDT, ETH-USDT-SWAP).""" @@ -92,31 +119,33 @@ def _symbols_match_quick_trade(user_symbol: str, position_symbol: str) -> bool: return (len(a) >= 6 and a in b) or (len(b) >= 6 and b in a) -def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_type: str, limit_price: float = 0.0) -> float: +def _convert_usdt_to_base_qty( + client, symbol: str, usdt_amount: float, market_type: str, limit_price: float = 0.0 +) -> float: """ Convert USDT amount to base asset quantity for all exchanges. - + This is a unified function that works for all exchanges. For spot: converts USDT -> base qty (e.g., 100 USDT -> 0.033 ETH) For swap: converts USDT -> base qty (e.g., 100 USDT -> 0.033 ETH), which will then be converted to contracts - + Args: client: Exchange client instance symbol: Trading pair (e.g., "ETH/USDT") usdt_amount: USDT amount to convert market_type: "spot" or "swap" limit_price: For limit orders, use this price if provided (optional) - + Returns: Base asset quantity """ if usdt_amount <= 0: return usdt_amount - + try: # Try to get current price from exchange current_price = 0.0 - + # For limit orders, use the provided price if limit_price > 0: current_price = limit_price @@ -127,17 +156,27 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty try: ticker = client.get_ticker(symbol=symbol) if isinstance(ticker, dict): - current_price = float(ticker.get("last") or ticker.get("lastPx") or ticker.get("close") or ticker.get("price") or 0) + current_price = float( + ticker.get("last") + or ticker.get("lastPx") + or ticker.get("close") + or ticker.get("price") + or 0 + ) except Exception: current_price = 0.0 # OKX from app.services.live_trading.okx import OkxClient + if current_price <= 0 and isinstance(client, OkxClient): try: from app.services.live_trading.symbols import to_okx_spot_inst_id, to_okx_swap_inst_id + inst_id = to_okx_spot_inst_id(symbol) if market_type == "spot" else to_okx_swap_inst_id(symbol) - logger.debug(f"OKX: Getting ticker for inst_id={inst_id}, symbol={symbol}, market_type={market_type}") + logger.debug( + f"OKX: Getting ticker for inst_id={inst_id}, symbol={symbol}, market_type={market_type}" + ) ticker = client.get_ticker(inst_id=inst_id) if ticker: current_price = float(ticker.get("last") or ticker.get("lastPx") or 0) @@ -150,21 +189,24 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty except Exception as e: logger.error(f"OKX: Failed to get ticker: {e}") raise - + # Binance - try to get price from public API from app.services.live_trading.binance import BinanceFuturesClient from app.services.live_trading.binance_spot import BinanceSpotClient + if current_price <= 0 and isinstance(client, (BinanceFuturesClient, BinanceSpotClient)): try: # Binance public ticker endpoint base_url = getattr(client, "base_url", "") if "binance" in base_url.lower(): import requests + if isinstance(client, BinanceFuturesClient): ticker_url = f"{base_url}/fapi/v1/ticker/price" else: ticker_url = f"{base_url}/api/v3/ticker/price" from app.services.live_trading.symbols import to_binance_futures_symbol + # Binance spot and futures use the same symbol format sym = to_binance_futures_symbol(symbol) resp = requests.get(ticker_url, params={"symbol": sym}, timeout=5) @@ -177,9 +219,11 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty # Bybit v5 — same host as trading API; tickers/orderbook are public from app.services.live_trading.bybit import BybitClient + if current_price <= 0 and isinstance(client, BybitClient): try: import requests + from app.services.live_trading.symbols import to_bybit_symbol bu = (getattr(client, "base_url", "") or "").rstrip("/") @@ -198,10 +242,7 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty t0 = lst[0] current_price = float( str( - t0.get("lastPrice") - or t0.get("markPrice") - or t0.get("indexPrice") - or 0 + t0.get("lastPrice") or t0.get("markPrice") or t0.get("indexPrice") or 0 ).replace(",", "") or 0 ) @@ -224,22 +265,26 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty current_price = bp or ap except Exception: pass - + # Other exchanges - can be added as needed # For exchanges without price API, we'll use a fallback - + if current_price > 0: base_qty = usdt_amount / current_price - logger.info(f"Converted USDT amount {usdt_amount} to base qty {base_qty:.8f} using price {current_price} for {symbol}") + logger.info( + f"Converted USDT amount {usdt_amount} to base qty {base_qty:.8f} using price {current_price} for {symbol}" + ) return base_qty else: # Can't get price - this is critical for quick trade # Quick trade always expects USDT input, so we must convert - logger.error(f"CRITICAL: Could not get price for {symbol} on {type(client).__name__} to convert USDT amount {usdt_amount}") - logger.error(f"This will cause order to fail. Please check exchange API connectivity or symbol format.") + logger.error( + f"CRITICAL: Could not get price for {symbol} on {type(client).__name__} to convert USDT amount {usdt_amount}" + ) + logger.error("This will cause order to fail. Please check exchange API connectivity or symbol format.") # Still return original amount as fallback, but log error return usdt_amount - + except Exception as e: logger.warning(f"Failed to convert USDT amount to base qty: {e}, using original amount") return usdt_amount @@ -289,6 +334,7 @@ def _build_exchange_config(credential_id: int, user_id: int, overrides: Dict[str def _create_client(exchange_config: Dict[str, Any], market_type: str = "swap"): """Create exchange client from config.""" from app.services.live_trading.factory import create_client + return create_client(exchange_config, market_type=market_type) @@ -328,10 +374,25 @@ def _record_quick_trade( RETURNING id """, ( - user_id, credential_id, exchange_id, symbol, side, order_type, - amount, price, leverage, market_type, tp_price, sl_price, - status, exchange_order_id, filled, avg_price, - error_msg, source, json.dumps(raw_result or {}), + user_id, + credential_id, + exchange_id, + symbol, + side, + order_type, + amount, + price, + leverage, + market_type, + tp_price, + sl_price, + status, + exchange_order_id, + filled, + avg_price, + error_msg, + source, + json.dumps(raw_result or {}), ), ) row = cur.fetchone() @@ -345,7 +406,8 @@ def _record_quick_trade( # ────────── endpoints ────────── -@quick_trade_bp.route('/place-order', methods=['POST']) + +@quick_trade_bp.route("/place-order", methods=["POST"]) @login_required def place_order(): """ @@ -425,6 +487,7 @@ def place_order(): if market_type != "spot" and margin_mode in ("cross", "isolated"): try: from app.services.live_trading.binance import BinanceFuturesClient + if isinstance(client, BinanceFuturesClient): client.set_margin_type(symbol=symbol, margin_mode=margin_mode) except Exception as me: @@ -435,28 +498,30 @@ def place_order(): # For limit orders, use the provided price; for market orders, fetch current price limit_price_for_conversion = price if order_type == "limit" and price > 0 else 0.0 base_qty = _convert_usdt_to_base_qty(client, symbol, usdt_amount, market_type, limit_price_for_conversion) - + # Validate conversion: if base_qty equals usdt_amount, conversion likely failed # For swap markets, base_qty should be much smaller than usdt_amount (e.g., 100 USDT -> 0.033 ETH) if market_type != "spot" and base_qty == usdt_amount and usdt_amount >= 1: logger.error(f"USDT conversion may have failed: base_qty ({base_qty}) equals usdt_amount ({usdt_amount})") - logger.error(f"This suggests the price fetch failed. Order may fail due to insufficient margin.") + logger.error("This suggests the price fetch failed. Order may fail due to insufficient margin.") # ---- set leverage (futures only) ---- if market_type != "spot" and leverage > 1: try: if hasattr(client, "set_leverage"): - from app.services.live_trading.okx import OkxClient from app.services.live_trading.gate import GateUsdtFuturesClient - + from app.services.live_trading.okx import OkxClient + # OKX requires inst_id instead of symbol if isinstance(client, OkxClient): from app.services.live_trading.symbols import to_okx_swap_inst_id + inst_id = to_okx_swap_inst_id(symbol) client.set_leverage(inst_id=inst_id, lever=leverage) # Gate requires contract (currency_pair) instead of symbol elif isinstance(client, GateUsdtFuturesClient): from app.services.live_trading.symbols import to_gate_currency_pair + contract = to_gate_currency_pair(symbol) if not client.set_leverage(contract=contract, leverage=leverage): logger.warning( @@ -488,14 +553,14 @@ def place_order(): # Use execution.py's place_order_from_signal for market orders to ensure consistency # Convert side to signal_type: buy -> open_long, sell -> open_short (for swap) or close_long (for spot) from app.services.live_trading.execution import place_order_from_signal - + if market_type == "spot": # Spot: buy = open_long, sell = close_long (assuming we're closing a position) signal_type = "open_long" if side == "buy" else "close_long" else: # Swap: buy = open_long, sell = open_short signal_type = "open_long" if side == "buy" else "open_short" - + result = place_order_from_signal( client=client, signal_type=signal_type, @@ -543,17 +608,19 @@ def place_order(): raw_result=raw, ) - return jsonify({ - "code": 1, - "msg": "Order placed successfully", - "data": { - "trade_id": trade_id, - "exchange_order_id": exchange_order_id, - "filled": filled, - "avg_price": avg_fill, - "status": "filled" if filled > 0 else "submitted", - }, - }) + return jsonify( + { + "code": 1, + "msg": "Order placed successfully", + "data": { + "trade_id": trade_id, + "exchange_order_id": exchange_order_id, + "filled": filled, + "avg_price": avg_fill, + "status": "filled" if filled > 0 else "submitted", + }, + } + ) except Exception as e: logger.error(f"quick trade failed: {e}") @@ -597,9 +664,9 @@ def _market_order_kwargs(client, symbol, amount, side, market_type, client_order """Build kwargs compatible with any exchange client's place_market_order.""" from app.services.live_trading.binance import BinanceFuturesClient from app.services.live_trading.binance_spot import BinanceSpotClient - from app.services.live_trading.okx import OkxClient from app.services.live_trading.bitget import BitgetMixClient from app.services.live_trading.bybit import BybitClient + from app.services.live_trading.okx import OkxClient if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)): return {"quantity": amount, "client_order_id": client_order_id} @@ -624,9 +691,9 @@ def _limit_order_kwargs(client, symbol, amount, price, side, market_type, client """Build kwargs compatible with any exchange client's place_limit_order.""" from app.services.live_trading.binance import BinanceFuturesClient from app.services.live_trading.binance_spot import BinanceSpotClient - from app.services.live_trading.okx import OkxClient from app.services.live_trading.bybit import BybitClient from app.services.live_trading.deepcoin import DeepcoinClient + from app.services.live_trading.okx import OkxClient if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)): return {"quantity": amount, "price": price, "client_order_id": client_order_id} @@ -645,7 +712,7 @@ def _limit_order_kwargs(client, symbol, amount, price, side, market_type, client return {"size": amount, "price": price, "client_order_id": client_order_id} -@quick_trade_bp.route('/balance', methods=['GET']) +@quick_trade_bp.route("/balance", methods=["GET"]) @login_required def get_balance(): """ @@ -678,7 +745,9 @@ def get_balance(): from app.services.live_trading.bitget import BitgetMixClient if isinstance(client, BitgetMixClient): - pt = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") + pt = str( + exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES" + ) raw = client.get_accounts(product_type=pt) else: raw = client.get_accounts() @@ -822,13 +891,19 @@ def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, An coins = acc.get("coin", []) if isinstance(acc, dict) else [] for c in coins: if str(c.get("coin") or "").upper() == "USDT": - result["available"] = float(c.get("availableToWithdraw") or c.get("walletBalance") or 0) + result["available"] = float( + c.get("availableToWithdraw") or c.get("walletBalance") or 0 + ) result["total"] = float(c.get("walletBalance") or 0) return result # HTX spot if isinstance(data, dict) and isinstance(data.get("list"), list): for item in data.get("list") or []: - if str(item.get("currency") or "").upper() == "USDT" and str(item.get("type") or "").lower() in ("trade", "available", ""): + if str(item.get("currency") or "").upper() == "USDT" and str(item.get("type") or "").lower() in ( + "trade", + "available", + "", + ): avail = float(item.get("balance") or 0) result["available"] = avail total = 0.0 @@ -924,9 +999,12 @@ def _fetch_exchange_positions_raw( raw = client.get_positions() items = raw if isinstance(raw, list) else [] c = to_gate_currency_pair(symbol) - logger.info("Gate positions: total=%d, target=%s, contracts=%s", - len(items), c, - [(str(p.get("contract")), p.get("size")) for p in items if isinstance(p, dict) and p.get("size")][:10]) + logger.info( + "Gate positions: total=%d, target=%s, contracts=%s", + len(items), + c, + [(str(p.get("contract")), p.get("size")) for p in items if isinstance(p, dict) and p.get("size")][:10], + ) filtered = [p for p in items if isinstance(p, dict) and str(p.get("contract") or "").strip() == c] out = [] for p in filtered: @@ -940,8 +1018,12 @@ def _fetch_exchange_positions_raw( if base_amt > 0: q["positionAmt"] = base_amt out.append(q) - logger.info("Gate filtered positions for %s: %d items, sizes=%s", c, len(out), - [(p.get("size"), p.get("positionAmt")) for p in out]) + logger.info( + "Gate filtered positions for %s: %d items, sizes=%s", + c, + len(out), + [(p.get("size"), p.get("positionAmt")) for p in out], + ) return out if isinstance(client, KucoinFuturesClient): @@ -986,8 +1068,12 @@ def _fetch_exchange_positions_raw( except Exception: pass out_items.append(q) - logger.info("HTX positions for %s: %d items, sizes=%s", symbol, len(out_items), - [(p.get("contract_code"), p.get("volume"), p.get("positionAmt")) for p in out_items]) + logger.info( + "HTX positions for %s: %d items, sizes=%s", + symbol, + len(out_items), + [(p.get("contract_code"), p.get("volume"), p.get("positionAmt")) for p in out_items], + ) return {"data": out_items} if isinstance(client, DeepcoinClient): @@ -1005,7 +1091,7 @@ def _fetch_exchange_positions_raw( return None -@quick_trade_bp.route('/position', methods=['GET']) +@quick_trade_bp.route("/position", methods=["GET"]) @login_required def get_position(): """ @@ -1027,9 +1113,7 @@ def get_position(): positions = [] try: - raw = _fetch_exchange_positions_raw( - client, exchange_config, symbol=symbol, market_type=market_type - ) + raw = _fetch_exchange_positions_raw(client, exchange_config, symbol=symbol, market_type=market_type) positions = _parse_positions(raw) except Exception as pe: logger.warning(f"Position fetch failed: {pe}") @@ -1067,11 +1151,7 @@ def _parse_positions(raw: Any) -> list: if not isinstance(item, dict): continue sym_raw = str( - item.get("symbol") - or item.get("instId") - or item.get("contract") - or item.get("contract_code") - or "" + item.get("symbol") or item.get("instId") or item.get("contract") or item.get("contract_code") or "" ).strip() display_symbol = sym_raw if sym_raw and "/" not in sym_raw: @@ -1102,7 +1182,7 @@ def _parse_positions(raw: Any) -> list: ) if abs(size) < 1e-10: continue - + # Binance hedge: positionSide LONG/SHORT with positive positionAmt; one-way: BOTH + signed amt side = "long" psu = str(item.get("positionSide", "")).strip().upper() @@ -1130,45 +1210,53 @@ def _parse_positions(raw: Any) -> list: side = "long" elif dir_side in ("sell", "short"): side = "short" - - result.append({ - "symbol": display_symbol, - "side": side, - "size": abs(size), - "entry_price": float( - item.get("entryPrice") - or item.get("entry_price") - or item.get("openPriceAvg") - or item.get("avgEntryPrice") - or item.get("avgPrice") - or item.get("avgCost") - or item.get("avgPx") - or item.get("cost_open") - or item.get("trade_avg_price") - or 0 - ), - "unrealized_pnl": float( - item.get("unRealizedProfit") - or item.get("unrealizedProfit") - or item.get("unrealizedPnl") - or item.get("unrealised_pnl") - or item.get("upl") - or item.get("unrealisedPnl") - or item.get("profit_unreal") - or item.get("pnl") - or 0 - ), - "leverage": float(item.get("leverage") or item.get("lever") or item.get("lever_rate") or item.get("cross_leverage_limit") or 1), - "mark_price": float( - item.get("markPrice") - or item.get("mark_price") - or item.get("markPx") - or item.get("last_price") - or item.get("last") - or item.get("indexPrice") - or 0 - ), - }) + + result.append( + { + "symbol": display_symbol, + "side": side, + "size": abs(size), + "entry_price": float( + item.get("entryPrice") + or item.get("entry_price") + or item.get("openPriceAvg") + or item.get("avgEntryPrice") + or item.get("avgPrice") + or item.get("avgCost") + or item.get("avgPx") + or item.get("cost_open") + or item.get("trade_avg_price") + or 0 + ), + "unrealized_pnl": float( + item.get("unRealizedProfit") + or item.get("unrealizedProfit") + or item.get("unrealizedPnl") + or item.get("unrealised_pnl") + or item.get("upl") + or item.get("unrealisedPnl") + or item.get("profit_unreal") + or item.get("pnl") + or 0 + ), + "leverage": float( + item.get("leverage") + or item.get("lever") + or item.get("lever_rate") + or item.get("cross_leverage_limit") + or 1 + ), + "mark_price": float( + item.get("markPrice") + or item.get("mark_price") + or item.get("markPx") + or item.get("last_price") + or item.get("last") + or item.get("indexPrice") + or 0 + ), + } + ) except Exception as e: logger.warning(f"_parse_positions error: {e}") return result @@ -1216,12 +1304,12 @@ def _quick_trade_net_base_qty( return max(0.0, float(net)) -@quick_trade_bp.route('/close-position', methods=['POST']) +@quick_trade_bp.route("/close-position", methods=["POST"]) @login_required def close_position(): """ Close an existing position. - + Body JSON: credential_id (int) — saved exchange credential ID symbol (str) — e.g. "BTC/USDT" @@ -1234,7 +1322,7 @@ def close_position(): try: user_id = g.user_id body = request.get_json(force=True, silent=True) or {} - + credential_id = int(body.get("credential_id") or 0) symbol = str(body.get("symbol") or "").strip() market_type = str(body.get("market_type") or "swap").strip().lower() @@ -1245,36 +1333,38 @@ def close_position(): close_scope = "system_tracked" else: close_scope = "full" - + # ---- validation ---- if not credential_id: return jsonify({"code": 0, "msg": "Missing credential_id"}), 400 if not symbol: return jsonify({"code": 0, "msg": "Missing symbol"}), 400 - + if market_type in ("futures", "future", "perp", "perpetual"): market_type = "swap" - + # ---- build exchange client ---- - exchange_config = _build_exchange_config(credential_id, user_id, { - "market_type": market_type, - }) + exchange_config = _build_exchange_config( + credential_id, + user_id, + { + "market_type": market_type, + }, + ) exchange_id = (exchange_config.get("exchange_id") or "").strip().lower() if not exchange_id: return jsonify({"code": 0, "msg": "Invalid credential: missing exchange_id"}), 400 - + client = _create_client(exchange_config, market_type=market_type) - + # ---- get current position ---- positions = [] try: - raw = _fetch_exchange_positions_raw( - client, exchange_config, symbol=symbol, market_type=market_type - ) + raw = _fetch_exchange_positions_raw(client, exchange_config, symbol=symbol, market_type=market_type) positions = _parse_positions(raw) except Exception as pe: logger.warning(f"Position fetch failed: {pe}") - + if not positions: return jsonify({"code": 0, "msg": f"No position found for {symbol}"}), 404 @@ -1312,10 +1402,10 @@ def close_position(): position_side = str(position.get("side") or "").strip().lower() position_size = float(position.get("size") or 0) - + if position_size <= 0: return jsonify({"code": 0, "msg": "Position size is zero or invalid"}), 400 - + if close_scope == "system_tracked" and market_type != "swap": return jsonify({"code": 0, "msg": "system_tracked close_scope is only supported for swap/perp"}), 400 @@ -1351,7 +1441,7 @@ def close_position(): actual_close_size = position_size if actual_close_size <= 0: return jsonify({"code": 0, "msg": "Close size is zero"}), 400 - + # ---- determine signal type based on position side ---- if market_type == "spot": # Spot only supports long positions @@ -1366,15 +1456,15 @@ def close_position(): signal_type = "close_short" else: return jsonify({"code": 0, "msg": f"Unknown position side: {position_side}"}), 400 - + # ---- place close order ---- from app.services.live_trading.execution import place_order_from_signal - + # Generate client_order_id timestamp_suffix = str(int(time.time()))[-6:] uuid_suffix = uuid.uuid4().hex[:8] client_order_id = f"qtc{timestamp_suffix}{uuid_suffix}" # 'c' for close - + result = place_order_from_signal( client=client, signal_type=signal_type, @@ -1384,13 +1474,13 @@ def close_position(): exchange_config=exchange_config, client_order_id=client_order_id, ) - + # ---- extract result ---- exchange_order_id = str(getattr(result, "exchange_order_id", "") or "") filled = float(getattr(result, "filled", 0) or 0) avg_fill = float(getattr(result, "avg_price", 0) or 0) raw = getattr(result, "raw", {}) or {} - + # ---- calculate USDT amount for recording ---- # Convert base asset quantity to USDT amount for consistent recording # amount (USDT) = base_qty * price @@ -1402,7 +1492,7 @@ def close_position(): fallback_price = mark_price if mark_price > 0 else entry_price if fallback_price > 0: usdt_amount = actual_close_size * fallback_price - + # ---- record trade ---- trade_id = _record_quick_trade( user_id=user_id, @@ -1425,23 +1515,25 @@ def close_position(): source=source, raw_result=raw, ) - - return jsonify({ - "code": 1, - "msg": "Position closed successfully", - "data": { - "trade_id": trade_id, - "exchange_order_id": exchange_order_id, - "filled": filled, - "avg_price": avg_fill, - "closed_size": actual_close_size, - "position_side": position_side, - "close_scope": close_scope, - "tracked_net_base": tracked_net if close_scope == "system_tracked" else None, - "status": "filled" if filled > 0 else "submitted", - }, - }) - + + return jsonify( + { + "code": 1, + "msg": "Position closed successfully", + "data": { + "trade_id": trade_id, + "exchange_order_id": exchange_order_id, + "filled": filled, + "avg_price": avg_fill, + "closed_size": actual_close_size, + "position_side": position_side, + "close_scope": close_scope, + "tracked_net_base": tracked_net if close_scope == "system_tracked" else None, + "status": "filled" if filled > 0 else "submitted", + }, + } + ) + except Exception as e: logger.error(f"close_position failed: {e}") logger.error(traceback.format_exc()) @@ -1453,7 +1545,7 @@ def close_position(): return jsonify(resp), 500 -@quick_trade_bp.route('/history', methods=['GET']) +@quick_trade_bp.route("/history", methods=["GET"]) @login_required def get_history(): """ @@ -1486,26 +1578,28 @@ def get_history(): trades = [] for r in rows: - trades.append({ - "id": r.get("id"), - "exchange_id": r.get("exchange_id") or "", - "symbol": r.get("symbol") or "", - "side": r.get("side") or "", - "order_type": r.get("order_type") or "market", - "amount": float(r.get("amount") or 0), - "price": float(r.get("price") or 0), - "leverage": int(r.get("leverage") or 1), - "market_type": r.get("market_type") or "swap", - "tp_price": float(r.get("tp_price") or 0), - "sl_price": float(r.get("sl_price") or 0), - "status": r.get("status") or "", - "exchange_order_id": r.get("exchange_order_id") or "", - "filled_amount": float(r.get("filled_amount") or 0), - "avg_fill_price": float(r.get("avg_fill_price") or 0), - "error_msg": r.get("error_msg") or "", - "source": r.get("source") or "", - "created_at": str(r.get("created_at") or ""), - }) + trades.append( + { + "id": r.get("id"), + "exchange_id": r.get("exchange_id") or "", + "symbol": r.get("symbol") or "", + "side": r.get("side") or "", + "order_type": r.get("order_type") or "market", + "amount": float(r.get("amount") or 0), + "price": float(r.get("price") or 0), + "leverage": int(r.get("leverage") or 1), + "market_type": r.get("market_type") or "swap", + "tp_price": float(r.get("tp_price") or 0), + "sl_price": float(r.get("sl_price") or 0), + "status": r.get("status") or "", + "exchange_order_id": r.get("exchange_order_id") or "", + "filled_amount": float(r.get("filled_amount") or 0), + "avg_fill_price": float(r.get("avg_fill_price") or 0), + "error_msg": r.get("error_msg") or "", + "source": r.get("source") or "", + "created_at": str(r.get("created_at") or ""), + } + ) return jsonify({"code": 1, "msg": "success", "data": {"trades": trades}}) except Exception as e: diff --git a/backend_api_python/app/routes/settings.py b/backend_api_python/app/routes/settings.py index 3592732..b6d3131 100644 --- a/backend_api_python/app/routes/settings.py +++ b/backend_api_python/app/routes/settings.py @@ -3,21 +3,23 @@ Settings API - Reading and saving .env configurations Admin-only endpoints for system configuration management. """ -import os -import re + import importlib -from flask import Blueprint, request, jsonify -from app.utils.logger import get_logger -from app.utils.config_loader import clear_config_cache -from app.utils.auth import login_required, admin_required +import os + from dotenv import load_dotenv +from flask import Blueprint, jsonify, request + +from app.utils.auth import admin_required, login_required +from app.utils.config_loader import clear_config_cache +from app.utils.logger import get_logger logger = get_logger(__name__) -settings_bp = Blueprint('settings', __name__) +settings_bp = Blueprint("settings", __name__) # .env file path -ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env') +ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") def _reload_runtime_env() -> None: @@ -29,8 +31,8 @@ def _reload_runtime_env() -> None: root_dir = os.path.dirname(backend_dir) # Load root first, then backend .env to keep backend file higher priority - load_dotenv(os.path.join(root_dir, '.env'), override=True) - load_dotenv(os.path.join(backend_dir, '.env'), override=True) + load_dotenv(os.path.join(root_dir, ".env"), override=True) + load_dotenv(os.path.join(backend_dir, ".env"), override=True) def _refresh_runtime_services() -> None: @@ -40,24 +42,24 @@ def _refresh_runtime_services() -> None: """ # Prefer dedicated reset function where available. try: - search_mod = importlib.import_module('app.services.search') - if hasattr(search_mod, 'reset_search_service'): + search_mod = importlib.import_module("app.services.search") + if hasattr(search_mod, "reset_search_service"): search_mod.reset_search_service() except Exception as e: logger.warning(f"reset_search_service skipped: {e}") # Generic singleton fields used across services. singleton_fields = [ - ('app.services.fast_analysis', '_fast_analysis_service'), - ('app.services.billing_service', '_billing_service'), - ('app.services.security_service', '_security_service'), - ('app.services.oauth_service', '_oauth_service'), - ('app.services.user_service', '_user_service'), - ('app.services.email_service', '_email_service'), - ('app.services.community_service', '_community_service'), - ('app.services.usdt_payment_service', '_svc'), - ('app.services.usdt_payment_service', '_worker'), - ('app.services.analysis_memory', '_memory_instance'), + ("app.services.fast_analysis", "_fast_analysis_service"), + ("app.services.billing_service", "_billing_service"), + ("app.services.security_service", "_security_service"), + ("app.services.oauth_service", "_oauth_service"), + ("app.services.user_service", "_user_service"), + ("app.services.email_service", "_email_service"), + ("app.services.community_service", "_community_service"), + ("app.services.usdt_payment_service", "_svc"), + ("app.services.usdt_payment_service", "_worker"), + ("app.services.analysis_memory", "_memory_instance"), ] for module_name, field_name in singleton_fields: @@ -68,6 +70,7 @@ def _refresh_runtime_services() -> None: except Exception as e: logger.warning(f"Singleton reset skipped: {module_name}.{field_name}: {e}") + # Configuration item definition (grouping) - divided by functional modules, each configuration item contains a description # --------------------------------------------------------------- # Streamlining principle: @@ -76,775 +79,754 @@ def _refresh_runtime_services() -> None: # - Only keep the function switches and API Keys that users really need to configure # --------------------------------------------------------------- CONFIG_SCHEMA = { - # ==================== 1. Security certification ==================== - 'auth': { - 'title': 'Security & Authentication', - 'icon': 'lock', - 'order': 1, - 'items': [ + "auth": { + "title": "Security & Authentication", + "icon": "lock", + "order": 1, + "items": [ { - 'key': 'SECRET_KEY', - 'label': 'Secret Key', - 'type': 'password', - 'default': 'quantdinger-secret-key-change-me', - 'description': 'JWT signing secret key. MUST change in production for security' + "key": "SECRET_KEY", + "label": "Secret Key", + "type": "password", + "default": "quantdinger-secret-key-change-me", + "description": "JWT signing secret key. MUST change in production for security", }, { - 'key': 'ADMIN_USER', - 'label': 'Admin Username', - 'type': 'text', - 'default': 'quantdinger', - 'description': 'Administrator login username' + "key": "ADMIN_USER", + "label": "Admin Username", + "type": "text", + "default": "quantdinger", + "description": "Administrator login username", }, { - 'key': 'ADMIN_PASSWORD', - 'label': 'Admin Password', - 'type': 'password', - 'default': '123456', - 'description': 'Administrator login password. MUST change in production' + "key": "ADMIN_PASSWORD", + "label": "Admin Password", + "type": "password", + "default": "123456", + "description": "Administrator login password. MUST change in production", }, { - 'key': 'ADMIN_EMAIL', - 'label': 'Admin Email', - 'type': 'text', - 'default': 'admin@example.com', - 'description': 'Administrator email for password reset and notifications' + "key": "ADMIN_EMAIL", + "label": "Admin Email", + "type": "text", + "default": "admin@example.com", + "description": "Administrator email for password reset and notifications", }, - ] + ], }, - # ==================== 2. AI/LLM configuration ==================== - 'ai': { - 'title': 'AI / LLM & Search', - 'icon': 'robot', - 'order': 2, - 'items': [ + "ai": { + "title": "AI / LLM & Search", + "icon": "robot", + "order": 2, + "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'}, + "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' + "description": "Select your preferred LLM provider", }, { - 'key': 'AI_CODE_GEN_MODEL', - 'label': 'Code Generation Model', - 'type': 'text', - 'default': '', - 'required': False, - 'description': 'Optional model override for AI code generation. If empty, uses provider default model' + "key": "AI_CODE_GEN_MODEL", + "label": "Code Generation Model", + "type": "text", + "default": "", + "required": False, + "description": "Optional model override for AI code generation. If empty, uses provider default model", }, # OpenRouter { - 'key': 'OPENROUTER_API_KEY', - 'label': 'OpenRouter API Key', - 'type': 'password', - 'required': False, - 'link': 'https://openrouter.ai/keys', - 'link_text': 'settings.link.getApiKey', - 'description': 'OpenRouter API key. Supports 100+ models via single API', - 'group': 'openrouter' + "key": "OPENROUTER_API_KEY", + "label": "OpenRouter API Key", + "type": "password", + "required": False, + "link": "https://openrouter.ai/keys", + "link_text": "settings.link.getApiKey", + "description": "OpenRouter API key. Supports 100+ models via single API", + "group": "openrouter", }, { - 'key': 'OPENROUTER_MODEL', - 'label': 'OpenRouter Model', - 'type': 'text', - 'default': 'openai/gpt-4o', - 'link': 'https://openrouter.ai/models', - 'link_text': 'settings.link.viewModels', - 'description': 'Model ID, e.g. openai/gpt-4o, anthropic/claude-3.5-sonnet', - 'group': 'openrouter' + "key": "OPENROUTER_MODEL", + "label": "OpenRouter Model", + "type": "text", + "default": "openai/gpt-4o", + "link": "https://openrouter.ai/models", + "link_text": "settings.link.viewModels", + "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_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_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' + "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_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' + "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_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_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' + "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_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_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' + "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' + "key": "OPENROUTER_TEMPERATURE", + "label": "Temperature", + "type": "number", + "default": "0.7", + "description": "Model creativity (0-1). Lower = more deterministic", }, { - 'key': 'AI_ANALYSIS_CONSENSUS_TIMEFRAMES', - 'label': 'Consensus Timeframes', - 'type': 'text', - 'default': '1D,4H', - 'required': False, - 'description': 'Multi-timeframe consensus for fast AI analysis. Comma-separated, e.g. "1D,4H"' + "key": "AI_ANALYSIS_CONSENSUS_TIMEFRAMES", + "label": "Consensus Timeframes", + "type": "text", + "default": "1D,4H", + "required": False, + "description": 'Multi-timeframe consensus for fast AI analysis. Comma-separated, e.g. "1D,4H"', }, { - 'key': 'SEARCH_PROVIDER', - 'label': 'Search Provider', - 'type': 'select', - 'options': ['tavily', 'google', 'bing', 'none'], - 'default': 'google', - 'description': 'News / web search provider used by AI analysis. Configure both LLM and search to get full AI analysis results' + "key": "SEARCH_PROVIDER", + "label": "Search Provider", + "type": "select", + "options": ["tavily", "google", "bing", "none"], + "default": "google", + "description": "News / web search provider used by AI analysis. Configure both LLM and search to get full AI analysis results", }, { - 'key': 'SEARCH_MAX_RESULTS', - 'label': 'Search Max Results', - 'type': 'number', - 'default': '10', - 'description': 'Maximum number of search/news results returned per AI analysis request' + "key": "SEARCH_MAX_RESULTS", + "label": "Search Max Results", + "type": "number", + "default": "10", + "description": "Maximum number of search/news results returned per AI analysis request", }, { - 'key': 'TAVILY_API_KEYS', - 'label': 'Tavily API Keys', - 'type': 'password', - 'required': False, - 'link': 'https://tavily.com/', - 'link_text': 'settings.link.getApiKey', - 'description': 'Tavily search API keys (comma-separated). Recommended lightweight search source for AI analysis' + "key": "TAVILY_API_KEYS", + "label": "Tavily API Keys", + "type": "password", + "required": False, + "link": "https://tavily.com/", + "link_text": "settings.link.getApiKey", + "description": "Tavily search API keys (comma-separated). Recommended lightweight search source for AI analysis", }, { - 'key': 'SEARCH_GOOGLE_API_KEY', - 'label': 'Google Search API Key', - 'type': 'password', - 'required': False, - 'link': 'https://console.cloud.google.com/apis/credentials', - 'link_text': 'settings.link.getApiKey', - 'description': 'Google Custom Search JSON API key' + "key": "SEARCH_GOOGLE_API_KEY", + "label": "Google Search API Key", + "type": "password", + "required": False, + "link": "https://console.cloud.google.com/apis/credentials", + "link_text": "settings.link.getApiKey", + "description": "Google Custom Search JSON API key", }, { - 'key': 'SEARCH_GOOGLE_CX', - 'label': 'Google Search Engine ID (CX)', - 'type': 'text', - 'required': False, - 'link': 'https://programmablesearchengine.google.com/', - 'link_text': 'settings.link.getApiKey', - 'description': 'Google Programmable Search Engine ID' + "key": "SEARCH_GOOGLE_CX", + "label": "Google Search Engine ID (CX)", + "type": "text", + "required": False, + "link": "https://programmablesearchengine.google.com/", + "link_text": "settings.link.getApiKey", + "description": "Google Programmable Search Engine ID", }, { - 'key': 'SEARCH_BING_API_KEY', - 'label': 'Bing Search API Key', - 'type': 'password', - 'required': False, - 'link': 'https://portal.azure.com/', - 'link_text': 'settings.link.getApiKey', - 'description': 'Microsoft Bing Web Search API key' + "key": "SEARCH_BING_API_KEY", + "label": "Bing Search API Key", + "type": "password", + "required": False, + "link": "https://portal.azure.com/", + "link_text": "settings.link.getApiKey", + "description": "Microsoft Bing Web Search API key", }, { - 'key': 'SERPAPI_KEYS', - 'label': 'SerpAPI Keys', - 'type': 'password', - 'required': False, - 'link': 'https://serpapi.com/', - 'link_text': 'settings.link.getApiKey', - 'description': 'SerpAPI keys (comma-separated)' + "key": "SERPAPI_KEYS", + "label": "SerpAPI Keys", + "type": "password", + "required": False, + "link": "https://serpapi.com/", + "link_text": "settings.link.getApiKey", + "description": "SerpAPI keys (comma-separated)", }, - ] + ], }, - # ==================== 3. Real offer ==================== - 'trading': { - 'title': 'Live Trading', - 'icon': 'stock', - 'order': 3, - 'items': [ + "trading": { + "title": "Live Trading", + "icon": "stock", + "order": 3, + "items": [ { - 'key': 'ORDER_MODE', - 'label': 'Order Execution Mode', - 'type': 'select', - 'options': ['market', 'maker'], - 'default': 'market', - 'description': 'market: Market order (instant fill, recommended), maker: Limit order first (lower fees but may not fill)' + "key": "ORDER_MODE", + "label": "Order Execution Mode", + "type": "select", + "options": ["market", "maker"], + "default": "market", + "description": "market: Market order (instant fill, recommended), maker: Limit order first (lower fees but may not fill)", }, { - 'key': 'MAKER_WAIT_SEC', - 'label': 'Limit Order Wait (sec)', - 'type': 'number', - 'default': '10', - 'description': 'Wait time for limit order fill before switching to market order' + "key": "MAKER_WAIT_SEC", + "label": "Limit Order Wait (sec)", + "type": "number", + "default": "10", + "description": "Wait time for limit order fill before switching to market order", }, - ] + ], }, - # ==================== 4. Data source configuration ==================== - 'data_source': { - 'title': 'Data Sources', - 'icon': 'database', - 'order': 4, - 'items': [ + "data_source": { + "title": "Data Sources", + "icon": "database", + "order": 4, + "items": [ { - 'key': 'CCXT_DEFAULT_EXCHANGE', - 'label': 'Default Crypto Exchange', - 'type': 'text', - 'default': 'coinbase', - 'link': 'https://github.com/ccxt/ccxt#supported-cryptocurrency-exchange-markets', - 'link_text': 'settings.link.supportedExchanges', - 'description': 'Default exchange for crypto data (binance, coinbase, okx, etc.)' + "key": "CCXT_DEFAULT_EXCHANGE", + "label": "Default Crypto Exchange", + "type": "text", + "default": "coinbase", + "link": "https://github.com/ccxt/ccxt#supported-cryptocurrency-exchange-markets", + "link_text": "settings.link.supportedExchanges", + "description": "Default exchange for crypto data (binance, coinbase, okx, etc.)", }, { - 'key': 'FINNHUB_API_KEY', - 'label': 'Finnhub API Key', - 'type': 'password', - 'required': False, - 'link': 'https://finnhub.io/register', - 'link_text': 'settings.link.freeRegister', - 'description': 'Finnhub API key for US stock data (free tier available)' + "key": "FINNHUB_API_KEY", + "label": "Finnhub API Key", + "type": "password", + "required": False, + "link": "https://finnhub.io/register", + "link_text": "settings.link.freeRegister", + "description": "Finnhub API key for US stock data (free tier available)", }, { - 'key': 'TIINGO_API_KEY', - 'label': 'Tiingo API Key', - 'type': 'password', - 'required': False, - 'link': 'https://www.tiingo.com/account/api/token', - 'link_text': 'settings.link.getToken', - 'description': 'Tiingo API key for Forex/Metals data' + "key": "TIINGO_API_KEY", + "label": "Tiingo API Key", + "type": "password", + "required": False, + "link": "https://www.tiingo.com/account/api/token", + "link_text": "settings.link.getToken", + "description": "Tiingo API key for Forex/Metals data", }, - { - 'key': 'TWELVE_DATA_API_KEY', - 'label': 'Twelve Data API Key', - 'type': 'password', - 'required': False, - 'link': 'https://twelvedata.com/apikey', - 'link_text': 'settings.link.getApiKey', - 'description': 'Twelve Data API key for CN/HK stock K-lines (free 800 credits/day)' - }, - ] + ], }, - # ==================== 5. Email configuration ==================== - 'email': { - 'title': 'Email (SMTP)', - 'icon': 'mail', - 'order': 5, - 'items': [ + "email": { + "title": "Email (SMTP)", + "icon": "mail", + "order": 5, + "items": [ { - 'key': 'SMTP_HOST', - 'label': 'SMTP Server', - 'type': 'text', - 'required': False, - 'description': 'SMTP server hostname (e.g. smtp.gmail.com)' + "key": "SMTP_HOST", + "label": "SMTP Server", + "type": "text", + "required": False, + "description": "SMTP server hostname (e.g. smtp.gmail.com)", }, { - 'key': 'SMTP_PORT', - 'label': 'SMTP Port', - 'type': 'number', - 'default': '587', - 'description': 'SMTP port (587 for TLS, 465 for SSL)' + "key": "SMTP_PORT", + "label": "SMTP Port", + "type": "number", + "default": "587", + "description": "SMTP port (587 for TLS, 465 for SSL)", }, { - 'key': 'SMTP_USER', - 'label': 'SMTP Username', - 'type': 'text', - 'required': False, - 'description': 'SMTP authentication username (usually email address)' + "key": "SMTP_USER", + "label": "SMTP Username", + "type": "text", + "required": False, + "description": "SMTP authentication username (usually email address)", }, { - 'key': 'SMTP_PASSWORD', - 'label': 'SMTP Password', - 'type': 'password', - 'required': False, - 'description': 'SMTP authentication password or app-specific password' + "key": "SMTP_PASSWORD", + "label": "SMTP Password", + "type": "password", + "required": False, + "description": "SMTP authentication password or app-specific password", }, { - 'key': 'SMTP_FROM', - 'label': 'Sender Address', - 'type': 'text', - 'required': False, - 'description': 'Email sender address (From header)' + "key": "SMTP_FROM", + "label": "Sender Address", + "type": "text", + "required": False, + "description": "Email sender address (From header)", }, { - 'key': 'SMTP_USE_TLS', - 'label': 'Use TLS', - 'type': 'boolean', - 'default': 'True', - 'description': 'Enable STARTTLS encryption (recommended for port 587)' + "key": "SMTP_USE_TLS", + "label": "Use TLS", + "type": "boolean", + "default": "True", + "description": "Enable STARTTLS encryption (recommended for port 587)", }, { - 'key': 'SMTP_USE_SSL', - 'label': 'Use SSL', - 'type': 'boolean', - 'default': 'False', - 'description': 'Enable SSL encryption (for port 465)' + "key": "SMTP_USE_SSL", + "label": "Use SSL", + "type": "boolean", + "default": "False", + "description": "Enable SSL encryption (for port 465)", }, - ] + ], }, - # ==================== 6. SMS configuration ==================== - 'sms': { - 'title': 'SMS (Twilio)', - 'icon': 'phone', - 'order': 6, - 'items': [ + "sms": { + "title": "SMS (Twilio)", + "icon": "phone", + "order": 6, + "items": [ { - 'key': 'TWILIO_ACCOUNT_SID', - 'label': 'Account SID', - 'type': 'password', - 'required': False, - 'link': 'https://console.twilio.com/', - 'link_text': 'settings.link.getApi', - 'description': 'Twilio Account SID from console dashboard' + "key": "TWILIO_ACCOUNT_SID", + "label": "Account SID", + "type": "password", + "required": False, + "link": "https://console.twilio.com/", + "link_text": "settings.link.getApi", + "description": "Twilio Account SID from console dashboard", }, { - 'key': 'TWILIO_AUTH_TOKEN', - 'label': 'Auth Token', - 'type': 'password', - 'required': False, - 'description': 'Twilio Auth Token from console dashboard' + "key": "TWILIO_AUTH_TOKEN", + "label": "Auth Token", + "type": "password", + "required": False, + "description": "Twilio Auth Token from console dashboard", }, { - 'key': 'TWILIO_FROM_NUMBER', - 'label': 'Sender Number', - 'type': 'text', - 'required': False, - 'description': 'Twilio phone number for sending SMS (e.g. +1234567890)' + "key": "TWILIO_FROM_NUMBER", + "label": "Sender Number", + "type": "text", + "required": False, + "description": "Twilio phone number for sending SMS (e.g. +1234567890)", }, - ] + ], }, - # ==================== 7. AI Agent ==================== - 'agent': { - 'title': 'AI Agent', - 'icon': 'experiment', - 'order': 7, - 'items': [ + "agent": { + "title": "AI Agent", + "icon": "experiment", + "order": 7, + "items": [ { - 'key': 'ENABLE_REFLECTION_WORKER', - 'label': 'Enable Auto Reflection', - 'type': 'boolean', - 'default': 'False', - 'description': 'Enable background worker for automatic trade reflection and calibration' + "key": "ENABLE_REFLECTION_WORKER", + "label": "Enable Auto Reflection", + "type": "boolean", + "default": "False", + "description": "Enable background worker for automatic trade reflection and calibration", }, { - 'key': 'REFLECTION_WORKER_INTERVAL_SEC', - 'label': 'Reflection Interval (sec)', - 'type': 'number', - 'default': '86400', - 'description': 'Reflection worker run interval in seconds (86400 = 1 day)' + "key": "REFLECTION_WORKER_INTERVAL_SEC", + "label": "Reflection Interval (sec)", + "type": "number", + "default": "86400", + "description": "Reflection worker run interval in seconds (86400 = 1 day)", }, { - 'key': 'REFLECTION_MIN_AGE_DAYS', - 'label': 'Min Age for Validation (days)', - 'type': 'number', - 'default': '7', - 'description': 'Only validate analyses older than N days' + "key": "REFLECTION_MIN_AGE_DAYS", + "label": "Min Age for Validation (days)", + "type": "number", + "default": "7", + "description": "Only validate analyses older than N days", }, { - 'key': 'REFLECTION_VALIDATE_LIMIT', - 'label': 'Validation Batch Limit', - 'type': 'number', - 'default': '200', - 'description': 'Max records to validate per reflection cycle' + "key": "REFLECTION_VALIDATE_LIMIT", + "label": "Validation Batch Limit", + "type": "number", + "default": "200", + "description": "Max records to validate per reflection cycle", }, { - 'key': 'ENABLE_CONFIDENCE_CALIBRATION', - 'label': 'Enable Confidence Calibration', - 'type': 'boolean', - 'default': 'False', - 'description': 'Adjust confidence by historical accuracy in each bucket' + "key": "ENABLE_CONFIDENCE_CALIBRATION", + "label": "Enable Confidence Calibration", + "type": "boolean", + "default": "False", + "description": "Adjust confidence by historical accuracy in each bucket", }, { - 'key': 'ENABLE_AI_ENSEMBLE', - 'label': 'Enable Multi-Model Voting', - 'type': 'boolean', - 'default': 'False', - 'description': 'Use 2-3 models and majority vote for more stable decisions' + "key": "ENABLE_AI_ENSEMBLE", + "label": "Enable Multi-Model Voting", + "type": "boolean", + "default": "False", + "description": "Use 2-3 models and majority vote for more stable decisions", }, { - 'key': 'AI_ENSEMBLE_MODELS', - 'label': 'Ensemble Models', - 'type': 'text', - 'default': 'openai/gpt-4o,openai/gpt-4o-mini', - 'description': 'Comma-separated model IDs for ensemble voting' + "key": "AI_ENSEMBLE_MODELS", + "label": "Ensemble Models", + "type": "text", + "default": "openai/gpt-4o,openai/gpt-4o-mini", + "description": "Comma-separated model IDs for ensemble voting", }, { - 'key': 'AI_CALIBRATION_MARKETS', - 'label': 'Calibration Markets', - 'type': 'text', - 'default': 'Crypto', - 'description': 'Comma-separated markets to run threshold calibration' + "key": "AI_CALIBRATION_MARKETS", + "label": "Calibration Markets", + "type": "text", + "default": "Crypto", + "description": "Comma-separated markets to run threshold calibration", }, { - 'key': 'AI_CALIBRATION_LOOKBACK_DAYS', - 'label': 'Calibration Lookback (days)', - 'type': 'number', - 'default': '30', - 'description': 'Days of validated data for calibration' + "key": "AI_CALIBRATION_LOOKBACK_DAYS", + "label": "Calibration Lookback (days)", + "type": "number", + "default": "30", + "description": "Days of validated data for calibration", }, { - 'key': 'AI_CALIBRATION_MIN_SAMPLES', - 'label': 'Calibration Min Samples', - 'type': 'number', - 'default': '80', - 'description': 'Minimum validated samples required for calibration' + "key": "AI_CALIBRATION_MIN_SAMPLES", + "label": "Calibration Min Samples", + "type": "number", + "default": "80", + "description": "Minimum validated samples required for calibration", }, - ] + ], }, - # ==================== 8. Network proxy ==================== - 'network': { - 'title': 'Network & Proxy', - 'icon': 'global', - 'order': 8, - 'items': [ + "network": { + "title": "Network & Proxy", + "icon": "global", + "order": 8, + "items": [ { - 'key': 'PROXY_URL', - 'label': 'Proxy URL', - 'type': 'text', - 'required': False, - 'description': 'Global outbound proxy URL. Used by requests and by crypto data requests when a proxy is needed.' + "key": "PROXY_URL", + "label": "Proxy URL", + "type": "text", + "required": False, + "description": "Global outbound proxy URL. Used by requests and by crypto data requests when a proxy is needed.", }, - ] + ], }, - # ==================== 10. Registration and OAuth ==================== - 'security': { - 'title': 'Registration & OAuth', - 'icon': 'safety', - 'order': 10, - 'items': [ + "security": { + "title": "Registration & OAuth", + "icon": "safety", + "order": 10, + "items": [ { - 'key': 'ENABLE_REGISTRATION', - 'label': 'Enable Registration', - 'type': 'boolean', - 'default': 'True', - 'description': 'Allow new users to register accounts' + "key": "ENABLE_REGISTRATION", + "label": "Enable Registration", + "type": "boolean", + "default": "True", + "description": "Allow new users to register accounts", }, { - 'key': 'FRONTEND_URL', - 'label': 'Frontend URL', - 'type': 'text', - 'default': 'http://localhost:8080', - 'description': 'Frontend URL for OAuth redirects' + "key": "FRONTEND_URL", + "label": "Frontend URL", + "type": "text", + "default": "http://localhost:8080", + "description": "Frontend URL for OAuth redirects", }, { - 'key': 'TURNSTILE_SITE_KEY', - 'label': 'Turnstile Site Key', - 'type': 'text', - 'required': False, - 'link': 'https://dash.cloudflare.com/?to=/:account/turnstile', - 'link_text': 'settings.link.getTurnstileKey', - 'description': 'Cloudflare Turnstile site key for CAPTCHA' + "key": "TURNSTILE_SITE_KEY", + "label": "Turnstile Site Key", + "type": "text", + "required": False, + "link": "https://dash.cloudflare.com/?to=/:account/turnstile", + "link_text": "settings.link.getTurnstileKey", + "description": "Cloudflare Turnstile site key for CAPTCHA", }, { - 'key': 'TURNSTILE_SECRET_KEY', - 'label': 'Turnstile Secret Key', - 'type': 'password', - 'required': False, - 'description': 'Cloudflare Turnstile secret key' + "key": "TURNSTILE_SECRET_KEY", + "label": "Turnstile Secret Key", + "type": "password", + "required": False, + "description": "Cloudflare Turnstile secret key", }, { - 'key': 'GOOGLE_CLIENT_ID', - 'label': 'Google OAuth Client ID', - 'type': 'text', - 'required': False, - 'link': 'https://console.cloud.google.com/apis/credentials', - 'link_text': 'settings.link.getGoogleCredentials', - 'description': 'Google OAuth Client ID for Google login' + "key": "GOOGLE_CLIENT_ID", + "label": "Google OAuth Client ID", + "type": "text", + "required": False, + "link": "https://console.cloud.google.com/apis/credentials", + "link_text": "settings.link.getGoogleCredentials", + "description": "Google OAuth Client ID for Google login", }, { - 'key': 'GOOGLE_CLIENT_SECRET', - 'label': 'Google OAuth Secret', - 'type': 'password', - 'required': False, - 'description': 'Google OAuth Client Secret' + "key": "GOOGLE_CLIENT_SECRET", + "label": "Google OAuth Secret", + "type": "password", + "required": False, + "description": "Google OAuth Client Secret", }, { - 'key': 'GITHUB_CLIENT_ID', - 'label': 'GitHub OAuth Client ID', - 'type': 'text', - 'required': False, - 'link': 'https://github.com/settings/developers', - 'link_text': 'settings.link.getGithubCredentials', - 'description': 'GitHub OAuth Client ID for GitHub login' + "key": "GITHUB_CLIENT_ID", + "label": "GitHub OAuth Client ID", + "type": "text", + "required": False, + "link": "https://github.com/settings/developers", + "link_text": "settings.link.getGithubCredentials", + "description": "GitHub OAuth Client ID for GitHub login", }, { - 'key': 'GITHUB_CLIENT_SECRET', - 'label': 'GitHub OAuth Secret', - 'type': 'password', - 'required': False, - 'description': 'GitHub OAuth Client Secret' + "key": "GITHUB_CLIENT_SECRET", + "label": "GitHub OAuth Secret", + "type": "password", + "required": False, + "description": "GitHub OAuth Client Secret", }, - ] + ], }, - # ==================== 11. Billing configuration ==================== - 'billing': { - 'title': 'Billing & Credits', - 'icon': 'dollar', - 'order': 11, - 'items': [ + "billing": { + "title": "Billing & Credits", + "icon": "dollar", + "order": 11, + "items": [ { - 'key': 'BILLING_ENABLED', - 'label': 'Enable Billing', - 'type': 'boolean', - 'default': 'False', - 'description': 'Enable billing system. Users need credits to use certain features' + "key": "BILLING_ENABLED", + "label": "Enable Billing", + "type": "boolean", + "default": "False", + "description": "Enable billing system. Users need credits to use certain features", }, - # ===== Membership Plans (3 tiers) ===== { - 'key': 'MEMBERSHIP_MONTHLY_PRICE_USD', - 'label': 'Monthly Membership Price (USD)', - 'type': 'number', - 'default': '19.9', - 'description': 'Monthly membership price in USD (mock payment in current version)' + "key": "MEMBERSHIP_MONTHLY_PRICE_USD", + "label": "Monthly Membership Price (USD)", + "type": "number", + "default": "19.9", + "description": "Monthly membership price in USD (mock payment in current version)", }, { - 'key': 'MEMBERSHIP_MONTHLY_CREDITS', - 'label': 'Monthly Membership Bonus Credits', - 'type': 'number', - 'default': '500', - 'description': 'Credits granted immediately after purchasing monthly membership' + "key": "MEMBERSHIP_MONTHLY_CREDITS", + "label": "Monthly Membership Bonus Credits", + "type": "number", + "default": "500", + "description": "Credits granted immediately after purchasing monthly membership", }, { - 'key': 'MEMBERSHIP_YEARLY_PRICE_USD', - 'label': 'Yearly Membership Price (USD)', - 'type': 'number', - 'default': '199', - 'description': 'Yearly membership price in USD (mock payment in current version)' + "key": "MEMBERSHIP_YEARLY_PRICE_USD", + "label": "Yearly Membership Price (USD)", + "type": "number", + "default": "199", + "description": "Yearly membership price in USD (mock payment in current version)", }, { - 'key': 'MEMBERSHIP_YEARLY_CREDITS', - 'label': 'Yearly Membership Bonus Credits', - 'type': 'number', - 'default': '8000', - 'description': 'Credits granted immediately after purchasing yearly membership' + "key": "MEMBERSHIP_YEARLY_CREDITS", + "label": "Yearly Membership Bonus Credits", + "type": "number", + "default": "8000", + "description": "Credits granted immediately after purchasing yearly membership", }, { - 'key': 'MEMBERSHIP_LIFETIME_PRICE_USD', - 'label': 'Lifetime Membership Price (USD)', - 'type': 'number', - 'default': '499', - 'description': 'Lifetime membership price in USD (mock payment in current version)' + "key": "MEMBERSHIP_LIFETIME_PRICE_USD", + "label": "Lifetime Membership Price (USD)", + "type": "number", + "default": "499", + "description": "Lifetime membership price in USD (mock payment in current version)", }, { - 'key': 'MEMBERSHIP_LIFETIME_MONTHLY_CREDITS', - 'label': 'Lifetime Membership Monthly Credits', - 'type': 'number', - 'default': '800', - 'description': 'Credits granted every 30 days for lifetime members' + "key": "MEMBERSHIP_LIFETIME_MONTHLY_CREDITS", + "label": "Lifetime Membership Monthly Credits", + "type": "number", + "default": "800", + "description": "Credits granted every 30 days for lifetime members", }, - # ===== USDT Pay (Plan B: independent address for each order) ===== { - 'key': 'USDT_PAY_ENABLED', - 'label': 'Enable USDT Pay', - 'type': 'boolean', - 'default': 'False', - 'description': 'Enable USDT scan-to-pay flow (per-order unique address)' + "key": "USDT_PAY_ENABLED", + "label": "Enable USDT Pay", + "type": "boolean", + "default": "False", + "description": "Enable USDT scan-to-pay flow (per-order unique address)", }, { - 'key': 'USDT_PAY_CHAIN', - 'label': 'USDT Chain', - 'type': 'select', - 'default': 'TRC20', - 'options': ['TRC20'], - 'description': 'Currently only TRC20 is supported' + "key": "USDT_PAY_CHAIN", + "label": "USDT Chain", + "type": "select", + "default": "TRC20", + "options": ["TRC20"], + "description": "Currently only TRC20 is supported", }, { - 'key': 'USDT_TRC20_XPUB', - 'label': 'TRC20 XPUB (Watch-only)', - 'type': 'password', - 'required': False, - 'description': 'Watch-only xpub used to derive per-order deposit addresses. Do NOT paste private key.' + "key": "USDT_TRC20_XPUB", + "label": "TRC20 XPUB (Watch-only)", + "type": "password", + "required": False, + "description": "Watch-only xpub used to derive per-order deposit addresses. Do NOT paste private key.", }, { - 'key': 'USDT_TRC20_CONTRACT', - 'label': 'USDT TRC20 Contract', - 'type': 'text', - 'default': 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj', - 'description': 'USDT contract address on TRON' + "key": "USDT_TRC20_CONTRACT", + "label": "USDT TRC20 Contract", + "type": "text", + "default": "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj", + "description": "USDT contract address on TRON", }, { - 'key': 'TRONGRID_BASE_URL', - 'label': 'TronGrid Base URL', - 'type': 'text', - 'default': 'https://api.trongrid.io', - 'description': 'TronGrid API base URL' + "key": "TRONGRID_BASE_URL", + "label": "TronGrid Base URL", + "type": "text", + "default": "https://api.trongrid.io", + "description": "TronGrid API base URL", }, { - 'key': 'TRONGRID_API_KEY', - 'label': 'TronGrid API Key', - 'type': 'password', - 'required': False, - 'description': 'Optional TronGrid API key for higher rate limits' + "key": "TRONGRID_API_KEY", + "label": "TronGrid API Key", + "type": "password", + "required": False, + "description": "Optional TronGrid API key for higher rate limits", }, { - 'key': 'USDT_PAY_CONFIRM_SECONDS', - 'label': 'Confirm Delay (sec)', - 'type': 'number', - 'default': '30', - 'description': 'Delay before marking a paid transaction as confirmed (TRC20)' + "key": "USDT_PAY_CONFIRM_SECONDS", + "label": "Confirm Delay (sec)", + "type": "number", + "default": "30", + "description": "Delay before marking a paid transaction as confirmed (TRC20)", }, { - 'key': 'USDT_PAY_EXPIRE_MINUTES', - 'label': 'Order Expire (min)', - 'type': 'number', - 'default': '30', - 'description': 'USDT payment order expiration time in minutes' + "key": "USDT_PAY_EXPIRE_MINUTES", + "label": "Order Expire (min)", + "type": "number", + "default": "30", + "description": "USDT payment order expiration time in minutes", }, { - 'key': 'BILLING_COST_AI_ANALYSIS', - 'label': 'AI Analysis Cost (per symbol)', - 'type': 'number', - 'default': '10', - 'description': 'Credits per symbol (instant analysis, AI filter, scheduled tasks all use this price)' + "key": "BILLING_COST_AI_ANALYSIS", + "label": "AI Analysis Cost (per symbol)", + "type": "number", + "default": "10", + "description": "Credits per symbol (instant analysis, AI filter, scheduled tasks all use this price)", }, { - 'key': 'BILLING_COST_AI_CODE_GEN', - 'label': 'AI Code Generation Cost', - 'type': 'number', - 'default': '30', - 'description': 'Credits per AI strategy/indicator code generation (higher token usage)' + "key": "BILLING_COST_AI_CODE_GEN", + "label": "AI Code Generation Cost", + "type": "number", + "default": "30", + "description": "Credits per AI strategy/indicator code generation (higher token usage)", }, { - 'key': 'CREDITS_REGISTER_BONUS', - 'label': 'Register Bonus', - 'type': 'number', - 'default': '100', - 'description': 'Credits awarded to new users on registration' + "key": "CREDITS_REGISTER_BONUS", + "label": "Register Bonus", + "type": "number", + "default": "100", + "description": "Credits awarded to new users on registration", }, { - 'key': 'CREDITS_REFERRAL_BONUS', - 'label': 'Referral Bonus', - 'type': 'number', - 'default': '50', - 'description': 'Credits awarded to referrer for each signup' + "key": "CREDITS_REFERRAL_BONUS", + "label": "Referral Bonus", + "type": "number", + "default": "50", + "description": "Credits awarded to referrer for each signup", }, - ] + ], }, - } def read_env_file(): """Read .env file""" env_values = {} - + if not os.path.exists(ENV_FILE_PATH): logger.warning(f".env file not found at {ENV_FILE_PATH}") return env_values - + try: - with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f: + with open(ENV_FILE_PATH, "r", encoding="utf-8") as f: for line in f: line = line.strip() # Skip empty lines and comments - if not line or line.startswith('#'): + if not line or line.startswith("#"): continue # Parse KEY=VALUE - if '=' in line: - key, value = line.split('=', 1) + if "=" in line: + key, value = line.split("=", 1) key = key.strip() value = value.strip() # Remove quotes - if (value.startswith('"') and value.endswith('"')) or \ - (value.startswith("'") and value.endswith("'")): + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): value = value[1:-1] env_values[key] = value except Exception as e: logger.error(f"Failed to read .env file: {e}") - + return env_values @@ -852,54 +834,54 @@ def write_env_file(env_values): """Write to .env file, preserving comments and formatting""" lines = [] existing_keys = set() - + # Read the original file and keep the format if os.path.exists(ENV_FILE_PATH): try: - with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f: + with open(ENV_FILE_PATH, "r", encoding="utf-8") as f: for line in f: original_line = line stripped = line.strip() - + # Keep blank lines and comments - if not stripped or stripped.startswith('#'): + if not stripped or stripped.startswith("#"): lines.append(original_line) continue - + # Update an existing key - if '=' in stripped: - key = stripped.split('=', 1)[0].strip() + if "=" in stripped: + key = stripped.split("=", 1)[0].strip() if key in env_values: existing_keys.add(key) value = env_values[key] # If the value contains special characters, wrap it in quotes - if ' ' in str(value) or '"' in str(value) or "'" in str(value): + if " " in str(value) or '"' in str(value) or "'" in str(value): lines.append(f'{key}="{value}"\n') else: - lines.append(f'{key}={value}\n') + lines.append(f"{key}={value}\n") else: lines.append(original_line) else: lines.append(original_line) except Exception as e: logger.error(f"Failed to read .env file for update: {e}") - + # Add new key new_keys = set(env_values.keys()) - existing_keys if new_keys: - if lines and not lines[-1].endswith('\n'): - lines.append('\n') - lines.append('\n# Added by Settings UI\n') + if lines and not lines[-1].endswith("\n"): + lines.append("\n") + lines.append("\n# Added by Settings UI\n") for key in sorted(new_keys): value = env_values[key] - if ' ' in str(value) or '"' in str(value) or "'" in str(value): + if " " in str(value) or '"' in str(value) or "'" in str(value): lines.append(f'{key}="{value}"\n') else: - lines.append(f'{key}={value}\n') - + lines.append(f"{key}={value}\n") + # write file try: - with open(ENV_FILE_PATH, 'w', encoding='utf-8') as f: + with open(ENV_FILE_PATH, "w", encoding="utf-8") as f: f.writelines(lines) return True except Exception as e: @@ -907,45 +889,37 @@ def write_env_file(env_values): return False -@settings_bp.route('/schema', methods=['GET']) +@settings_bp.route("/schema", methods=["GET"]) @login_required @admin_required def get_settings_schema(): """Get configuration item definition (admin only)""" - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': CONFIG_SCHEMA - }) + return jsonify({"code": 1, "msg": "success", "data": CONFIG_SCHEMA}) -@settings_bp.route('/values', methods=['GET']) +@settings_bp.route("/values", methods=["GET"]) @login_required @admin_required def get_settings_values(): """Get current configuration values ​​- including sensitive information (real values) (admin only)""" env_values = read_env_file() - + # Construct return data and return true value result = {} for group_key, group in CONFIG_SCHEMA.items(): result[group_key] = {} - for item in group['items']: - key = item['key'] - value = env_values.get(key, item.get('default', '')) + for item in group["items"]: + key = item["key"] + value = env_values.get(key, item.get("default", "")) result[group_key][key] = value # Marks whether the password type is configured - if item['type'] == 'password': - result[group_key][f'{key}_configured'] = bool(value) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': result - }) + if item["type"] == "password": + result[group_key][f"{key}_configured"] = bool(value) + + return jsonify({"code": 1, "msg": "success", "data": result}) -@settings_bp.route('/save', methods=['POST']) +@settings_bp.route("/save", methods=["POST"]) @login_required @admin_required def save_settings(): @@ -953,32 +927,32 @@ def save_settings(): try: data = request.get_json() if not data: - return jsonify({'code': 0, 'msg': 'Invalid request payload'}) - + return jsonify({"code": 0, "msg": "Invalid request payload"}) + # Read current configuration current_env = read_env_file() - + # Update configuration updates = {} for group_key, group_values in data.items(): if group_key not in CONFIG_SCHEMA: continue - - for item in CONFIG_SCHEMA[group_key]['items']: - key = item['key'] + + for item in CONFIG_SCHEMA[group_key]["items"]: + key = item["key"] if key in group_values: new_value = group_values[key] - + # Null value handling - if new_value is None or new_value == '': - if not item.get('required', True): - updates[key] = '' + if new_value is None or new_value == "": + if not item.get("required", True): + updates[key] = "" else: updates[key] = str(new_value) - + # Merge updates current_env.update(updates) - + # write file if write_env_file(current_env): # Clear configuration cache @@ -987,139 +961,122 @@ def save_settings(): _reload_runtime_env() # Reset the service singleton that depends on the configuration (the next request will be automatically rebuilt according to the new configuration) _refresh_runtime_services() - - return jsonify({ - 'code': 1, - 'msg': 'Settings saved successfully', - 'data': { - 'updated_keys': list(updates.keys()), - 'requires_restart': False, - 'hot_reloaded': True, - 'services_refreshed': True + + return jsonify( + { + "code": 1, + "msg": "Settings saved successfully", + "data": { + "updated_keys": list(updates.keys()), + "requires_restart": False, + "hot_reloaded": True, + "services_refreshed": True, + }, } - }) + ) else: - return jsonify({'code': 0, 'msg': 'Failed to save settings'}) - + return jsonify({"code": 0, "msg": "Failed to save settings"}) + except Exception as e: logger.error(f"Failed to save settings: {e}") - return jsonify({'code': 0, 'msg': f'Save failed: {str(e)}'}) + return jsonify({"code": 0, "msg": f"Save failed: {str(e)}"}) -@settings_bp.route('/openrouter-balance', methods=['GET']) +@settings_bp.route("/openrouter-balance", methods=["GET"]) @login_required @admin_required def get_openrouter_balance(): """Check OpenRouter account balance (admin only)""" try: import requests + from app.config.api_keys import APIKeys - + api_key = APIKeys.OPENROUTER_API_KEY if not api_key: - return jsonify({ - 'code': 0, - 'msg': 'OpenRouter API key is not configured', - 'data': None - }) - + return jsonify({"code": 0, "msg": "OpenRouter API key is not configured", "data": None}) + # Call OpenRouter API to query balance # https://openrouter.ai/docs#limits resp = requests.get( - 'https://openrouter.ai/api/v1/auth/key', - headers={ - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json' - }, - timeout=10 + "https://openrouter.ai/api/v1/auth/key", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + timeout=10, ) - + if resp.status_code == 200: data = resp.json() # OpenRouter return format: {"data": {"label": "...", "usage": 0.0, "limit": null, ...}} - key_data = data.get('data', {}) - usage = key_data.get('usage', 0) # Amount used - limit = key_data.get('limit') # limit (may be null for no limit) - limit_remaining = key_data.get('limit_remaining') # remaining balance - is_free_tier = key_data.get('is_free_tier', False) - rate_limit = key_data.get('rate_limit', {}) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'usage': round(usage, 4), # Used (USD) - 'limit': limit, # total limit - 'limit_remaining': round(limit_remaining, 4) if limit_remaining is not None else None, # remaining balance - 'is_free_tier': is_free_tier, - 'rate_limit': rate_limit, - 'label': key_data.get('label', '') + key_data = data.get("data", {}) + usage = key_data.get("usage", 0) # Amount used + limit = key_data.get("limit") # limit (may be null for no limit) + limit_remaining = key_data.get("limit_remaining") # remaining balance + is_free_tier = key_data.get("is_free_tier", False) + rate_limit = key_data.get("rate_limit", {}) + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "usage": round(usage, 4), # Used (USD) + "limit": limit, # total limit + "limit_remaining": round(limit_remaining, 4) + if limit_remaining is not None + else None, # remaining balance + "is_free_tier": is_free_tier, + "rate_limit": rate_limit, + "label": key_data.get("label", ""), + }, } - }) + ) elif resp.status_code == 401: - return jsonify({ - 'code': 0, - 'msg': 'API key is invalid or expired', - 'data': None - }) + return jsonify({"code": 0, "msg": "API key is invalid or expired", "data": None}) else: - return jsonify({ - 'code': 0, - 'msg': f'Query failed: HTTP {resp.status_code}', - 'data': None - }) - + return jsonify({"code": 0, "msg": f"Query failed: HTTP {resp.status_code}", "data": None}) + except requests.exceptions.Timeout: - return jsonify({ - 'code': 0, - 'msg': 'Request timed out. Please check the network connection.', - 'data': None - }) + return jsonify({"code": 0, "msg": "Request timed out. Please check the network connection.", "data": None}) except Exception as e: logger.error(f"Get OpenRouter balance failed: {e}") - return jsonify({ - 'code': 0, - 'msg': f'Query failed: {str(e)}', - 'data': None - }) + return jsonify({"code": 0, "msg": f"Query failed: {str(e)}", "data": None}) -@settings_bp.route('/test-connection', methods=['POST']) +@settings_bp.route("/test-connection", methods=["POST"]) @login_required @admin_required def test_connection(): """Test API connection (admin only)""" try: data = request.get_json() - service = data.get('service') - - if service == 'openrouter': + service = data.get("service") + + if service == "openrouter": # Test OpenRouter connection from app.services.llm import LLMService + llm = LLMService() result = llm.test_connection() if result: - return jsonify({'code': 1, 'msg': 'OpenRouter connection successful'}) + return jsonify({"code": 1, "msg": "OpenRouter connection successful"}) else: - return jsonify({'code': 0, 'msg': 'OpenRouter connection failed'}) - - elif service == 'finnhub': + return jsonify({"code": 0, "msg": "OpenRouter connection failed"}) + + elif service == "finnhub": # Test Finnhub connection import requests - api_key = data.get('api_key') or os.getenv('FINNHUB_API_KEY') + + api_key = data.get("api_key") or os.getenv("FINNHUB_API_KEY") if not api_key: - return jsonify({'code': 0, 'msg': 'API key is not configured'}) - resp = requests.get( - f'https://finnhub.io/api/v1/quote?symbol=AAPL&token={api_key}', - timeout=10 - ) + return jsonify({"code": 0, "msg": "API key is not configured"}) + resp = requests.get(f"https://finnhub.io/api/v1/quote?symbol=AAPL&token={api_key}", timeout=10) if resp.status_code == 200: - return jsonify({'code': 1, 'msg': 'Finnhub connection successful'}) + return jsonify({"code": 1, "msg": "Finnhub connection successful"}) else: - return jsonify({'code': 0, 'msg': f'Finnhub connection failed: {resp.status_code}'}) - - return jsonify({'code': 0, 'msg': 'Unknown service'}) - + return jsonify({"code": 0, "msg": f"Finnhub connection failed: {resp.status_code}"}) + + return jsonify({"code": 0, "msg": "Unknown service"}) + except Exception as e: logger.error(f"Connection test failed: {e}") - return jsonify({'code': 0, 'msg': f'Test failed: {str(e)}'}) + return jsonify({"code": 0, "msg": f"Test failed: {str(e)}"}) diff --git a/backend_api_python/app/routes/strategy.py b/backend_api_python/app/routes/strategy.py index af9437b..3fcb946 100644 --- a/backend_api_python/app/routes/strategy.py +++ b/backend_api_python/app/routes/strategy.py @@ -1,37 +1,40 @@ """ Trading Strategy API Routes """ -from flask import Blueprint, request, jsonify, g -from datetime import datetime + import json import re -import traceback import time +import traceback +from datetime import datetime +from flask import Blueprint, g, jsonify, request + +from app import get_trading_executor +from app.services.backtest import BacktestService from app.services.strategy import StrategyService from app.services.strategy_compiler import StrategyCompiler -from app.services.backtest import BacktestService from app.services.strategy_snapshot import StrategySnapshotResolver -from app import get_trading_executor -from app.utils.logger import get_logger from app.utils.db import get_db_connection +from app.utils.logger import get_logger try: from psycopg2.errors import UndefinedTable as PgUndefinedTable except Exception: # pragma: no cover PgUndefinedTable = None # type: ignore -from app.utils.auth import login_required from app.data_sources import DataSourceFactory +from app.utils.auth import login_required logger = get_logger(__name__) -strategy_bp = Blueprint('strategy', __name__) +strategy_bp = Blueprint("strategy", __name__) # Local mode: avoid heavy initialization during module import. # Instantiate services lazily on first use to keep startup clean. _strategy_service = None _backtest_service = None + def get_strategy_service() -> StrategyService: global _strategy_service if _strategy_service is None: @@ -46,7 +49,7 @@ def get_backtest_service() -> BacktestService: return _backtest_service -@strategy_bp.route('/strategies', methods=['GET']) +@strategy_bp.route("/strategies", methods=["GET"]) @login_required def list_strategies(): """ @@ -55,158 +58,160 @@ def list_strategies(): try: user_id = g.user_id items = get_strategy_service().list_strategies(user_id=user_id) - return jsonify({'code': 1, 'msg': 'success', 'data': {'strategies': items}}) + return jsonify({"code": 1, "msg": "success", "data": {"strategies": items}}) except Exception as e: logger.error(f"list_strategies failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': {'strategies': []}}), 500 + return jsonify({"code": 0, "msg": str(e), "data": {"strategies": []}}), 500 -@strategy_bp.route('/strategies/detail', methods=['GET']) +@strategy_bp.route("/strategies/detail", methods=["GET"]) @login_required def get_strategy_detail(): try: user_id = g.user_id - strategy_id = request.args.get('id', type=int) + strategy_id = request.args.get("id", type=int) if not strategy_id: - return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Missing strategy id parameter", "data": None}), 400 st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) if not st: - return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404 - return jsonify({'code': 1, 'msg': 'success', 'data': st}) + return jsonify({"code": 0, "msg": "Strategy not found", "data": None}), 404 + return jsonify({"code": 1, "msg": "success", "data": st}) except Exception as e: logger.error(f"get_strategy_detail failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@strategy_bp.route('/strategies/backtest', methods=['POST']) +@strategy_bp.route("/strategies/backtest", methods=["POST"]) @login_required def run_strategy_backtest(): try: payload = request.get_json() or {} user_id = g.user_id - strategy_id = int(payload.get('strategyId') or 0) + strategy_id = int(payload.get("strategyId") or 0) if not strategy_id: - return jsonify({'code': 0, 'msg': 'strategyId is required', 'data': None}), 400 + return jsonify({"code": 0, "msg": "strategyId is required", "data": None}), 400 - start_date_str = str(payload.get('startDate') or '').strip() - end_date_str = str(payload.get('endDate') or '').strip() + start_date_str = str(payload.get("startDate") or "").strip() + end_date_str = str(payload.get("endDate") or "").strip() if not start_date_str or not end_date_str: - return jsonify({'code': 0, 'msg': 'startDate and endDate are required', 'data': None}), 400 + return jsonify({"code": 0, "msg": "startDate and endDate are required", "data": None}), 400 strategy = get_strategy_service().get_strategy(strategy_id, user_id=user_id) if not strategy: - return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404 + return jsonify({"code": 0, "msg": "Strategy not found", "data": None}), 404 resolver = StrategySnapshotResolver(user_id=user_id) - snapshot = resolver.resolve(strategy, payload.get('overrideConfig') or {}) - snapshot['user_id'] = user_id + snapshot = resolver.resolve(strategy, payload.get("overrideConfig") or {}) + snapshot["user_id"] = user_id - start_date = datetime.strptime(start_date_str, '%Y-%m-%d') - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) + start_date = datetime.strptime(start_date_str, "%Y-%m-%d") + end_date = datetime.strptime(end_date_str, "%Y-%m-%d").replace(hour=23, minute=59, second=59) days_diff = (end_date - start_date).days - timeframe = snapshot.get('timeframe') or '1D' - if timeframe == '1m': + timeframe = snapshot.get("timeframe") or "1D" + if timeframe == "1m": max_days = 30 - max_range_text = '1 month' - elif timeframe == '5m': + max_range_text = "1 month" + elif timeframe == "5m": max_days = 180 - max_range_text = '6 months' - elif timeframe in ['15m', '30m']: + max_range_text = "6 months" + elif timeframe in ["15m", "30m"]: max_days = 365 - max_range_text = '1 year' + max_range_text = "1 year" else: max_days = 1095 - max_range_text = '3 years' + max_range_text = "3 years" if days_diff > max_days: - return jsonify({ - 'code': 0, - 'msg': f'Backtest range exceeds limit: timeframe {timeframe} supports up to {max_range_text} ({max_days} days), but you selected {days_diff} days', - 'data': None - }), 400 + return jsonify( + { + "code": 0, + "msg": f"Backtest range exceeds limit: timeframe {timeframe} supports up to {max_range_text} ({max_days} days), but you selected {days_diff} days", + "data": None, + } + ), 400 svc = get_backtest_service() result = svc.run_strategy_snapshot(snapshot, start_date=start_date, end_date=end_date) run_id = svc.persist_run( user_id=user_id, - indicator_id=snapshot.get('indicator_id'), - strategy_id=snapshot.get('strategy_id'), - strategy_name=snapshot.get('strategy_name') or '', - run_type=snapshot.get('run_type') or 'strategy_indicator', - market=snapshot.get('market') or '', - symbol=snapshot.get('symbol') or '', - timeframe=snapshot.get('timeframe') or '', + indicator_id=snapshot.get("indicator_id"), + strategy_id=snapshot.get("strategy_id"), + strategy_name=snapshot.get("strategy_name") or "", + run_type=snapshot.get("run_type") or "strategy_indicator", + market=snapshot.get("market") or "", + symbol=snapshot.get("symbol") or "", + timeframe=snapshot.get("timeframe") or "", start_date_str=start_date_str, end_date_str=end_date_str, - initial_capital=float(snapshot.get('initial_capital') or 0), - commission=float(snapshot.get('commission') or 0), - slippage=float(snapshot.get('slippage') or 0), - leverage=int(snapshot.get('leverage') or 1), - trade_direction=str(snapshot.get('trade_direction') or 'long'), - strategy_config=snapshot.get('strategy_config') or {}, - config_snapshot=snapshot.get('config_snapshot') or {}, - status='success', - error_message='', + initial_capital=float(snapshot.get("initial_capital") or 0), + commission=float(snapshot.get("commission") or 0), + slippage=float(snapshot.get("slippage") or 0), + leverage=int(snapshot.get("leverage") or 1), + trade_direction=str(snapshot.get("trade_direction") or "long"), + strategy_config=snapshot.get("strategy_config") or {}, + config_snapshot=snapshot.get("config_snapshot") or {}, + status="success", + error_message="", result=result, - code=snapshot.get('code') or '', + code=snapshot.get("code") or "", ) - return jsonify({'code': 1, 'msg': 'success', 'data': {'runId': run_id, 'result': result}}) + return jsonify({"code": 1, "msg": "success", "data": {"runId": run_id, "result": result}}) except ValueError as e: - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400 + return jsonify({"code": 0, "msg": str(e), "data": None}), 400 except Exception as e: logger.error(f"run_strategy_backtest failed: {str(e)}") logger.error(traceback.format_exc()) try: payload = payload if isinstance(payload, dict) else {} - strategy_id = int(payload.get('strategyId') or 0) + strategy_id = int(payload.get("strategyId") or 0) strategy = get_strategy_service().get_strategy(strategy_id, user_id=g.user_id) if strategy_id else None if strategy: resolver = StrategySnapshotResolver(user_id=g.user_id) - snapshot = resolver.resolve(strategy, payload.get('overrideConfig') or {}) - snapshot['user_id'] = g.user_id + snapshot = resolver.resolve(strategy, payload.get("overrideConfig") or {}) + snapshot["user_id"] = g.user_id get_backtest_service().persist_run( user_id=g.user_id, - indicator_id=snapshot.get('indicator_id'), - strategy_id=snapshot.get('strategy_id'), - strategy_name=snapshot.get('strategy_name') or '', - run_type=snapshot.get('run_type') or 'strategy_indicator', - market=snapshot.get('market') or '', - symbol=snapshot.get('symbol') or '', - timeframe=snapshot.get('timeframe') or '', - start_date_str=str(payload.get('startDate') or ''), - end_date_str=str(payload.get('endDate') or ''), - initial_capital=float(snapshot.get('initial_capital') or 0), - commission=float(snapshot.get('commission') or 0), - slippage=float(snapshot.get('slippage') or 0), - leverage=int(snapshot.get('leverage') or 1), - trade_direction=str(snapshot.get('trade_direction') or 'long'), - strategy_config=snapshot.get('strategy_config') or {}, - config_snapshot=snapshot.get('config_snapshot') or {}, - status='failed', + indicator_id=snapshot.get("indicator_id"), + strategy_id=snapshot.get("strategy_id"), + strategy_name=snapshot.get("strategy_name") or "", + run_type=snapshot.get("run_type") or "strategy_indicator", + market=snapshot.get("market") or "", + symbol=snapshot.get("symbol") or "", + timeframe=snapshot.get("timeframe") or "", + start_date_str=str(payload.get("startDate") or ""), + end_date_str=str(payload.get("endDate") or ""), + initial_capital=float(snapshot.get("initial_capital") or 0), + commission=float(snapshot.get("commission") or 0), + slippage=float(snapshot.get("slippage") or 0), + leverage=int(snapshot.get("leverage") or 1), + trade_direction=str(snapshot.get("trade_direction") or "long"), + strategy_config=snapshot.get("strategy_config") or {}, + config_snapshot=snapshot.get("config_snapshot") or {}, + status="failed", error_message=str(e), result=None, - code=snapshot.get('code') or '', + code=snapshot.get("code") or "", ) except Exception: pass - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@strategy_bp.route('/strategies/backtest/history', methods=['GET']) +@strategy_bp.route("/strategies/backtest/history", methods=["GET"]) @login_required def get_strategy_backtest_history(): try: user_id = g.user_id - strategy_id = int(request.args.get('strategyId') or request.args.get('id') or 0) + strategy_id = int(request.args.get("strategyId") or request.args.get("id") or 0) if not strategy_id: - return jsonify({'code': 0, 'msg': 'strategyId is required', 'data': None}), 400 - limit = max(1, min(int(request.args.get('limit') or 50), 200)) - offset = max(0, int(request.args.get('offset') or 0)) - symbol = (request.args.get('symbol') or '').strip() - market = (request.args.get('market') or '').strip() - timeframe = (request.args.get('timeframe') or '').strip() + return jsonify({"code": 0, "msg": "strategyId is required", "data": None}), 400 + limit = max(1, min(int(request.args.get("limit") or 50), 200)) + offset = max(0, int(request.args.get("offset") or 0)) + symbol = (request.args.get("symbol") or "").strip() + market = (request.args.get("market") or "").strip() + timeframe = (request.args.get("timeframe") or "").strip() rows = get_backtest_service().list_runs( user_id=user_id, strategy_id=strategy_id, @@ -216,55 +221,55 @@ def get_strategy_backtest_history(): market=market, timeframe=timeframe, ) - rows = [r for r in rows if str(r.get('run_type') or '').startswith('strategy_')] - return jsonify({'code': 1, 'msg': 'success', 'data': rows}) + rows = [r for r in rows if str(r.get("run_type") or "").startswith("strategy_")] + return jsonify({"code": 1, "msg": "success", "data": rows}) except Exception as e: logger.error(f"get_strategy_backtest_history failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@strategy_bp.route('/strategies/backtest/get', methods=['GET']) +@strategy_bp.route("/strategies/backtest/get", methods=["GET"]) @login_required def get_strategy_backtest_run(): try: user_id = g.user_id - run_id = int(request.args.get('runId') or 0) + run_id = int(request.args.get("runId") or 0) if not run_id: - return jsonify({'code': 0, 'msg': 'runId is required', 'data': None}), 400 + return jsonify({"code": 0, "msg": "runId is required", "data": None}), 400 row = get_backtest_service().get_run(user_id=user_id, run_id=run_id) - if not row or not str(row.get('run_type') or '').startswith('strategy_'): - return jsonify({'code': 0, 'msg': 'run not found', 'data': None}), 404 - return jsonify({'code': 1, 'msg': 'success', 'data': row}) + if not row or not str(row.get("run_type") or "").startswith("strategy_"): + return jsonify({"code": 0, "msg": "run not found", "data": None}), 404 + return jsonify({"code": 1, "msg": "success", "data": row}) except Exception as e: logger.error(f"get_strategy_backtest_run failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@strategy_bp.route('/strategies/create', methods=['POST']) +@strategy_bp.route("/strategies/create", methods=["POST"]) @login_required def create_strategy(): try: user_id = g.user_id payload = request.get_json() or {} # Use current user's ID - payload['user_id'] = user_id - payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy' + payload["user_id"] = user_id + payload["strategy_type"] = payload.get("strategy_type") or "IndicatorStrategy" new_id = get_strategy_service().create_strategy(payload) - return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}}) + return jsonify({"code": 1, "msg": "success", "data": {"id": new_id}}) except Exception as e: logger.error(f"create_strategy failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@strategy_bp.route('/strategies/batch-create', methods=['POST']) +@strategy_bp.route("/strategies/batch-create", methods=["POST"]) @login_required def batch_create_strategies(): """ Batch create strategies (multiple symbols) - + Request body: strategy_name: Base strategy name symbols: Array of symbols, e.g. ["Crypto:BTC/USDT", "Crypto:ETH/USDT"] @@ -273,35 +278,29 @@ def batch_create_strategies(): try: user_id = g.user_id payload = request.get_json() or {} - payload['user_id'] = user_id - payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy' - + payload["user_id"] = user_id + payload["strategy_type"] = payload.get("strategy_type") or "IndicatorStrategy" + result = get_strategy_service().batch_create_strategies(payload) - - if result['success']: - return jsonify({ - 'code': 1, - 'msg': f"Successfully created {result['total_created']} strategies", - 'data': result - }) + + if result["success"]: + return jsonify( + {"code": 1, "msg": f"Successfully created {result['total_created']} strategies", "data": result} + ) else: - return jsonify({ - 'code': 0, - 'msg': 'Batch creation failed', - 'data': result - }) + return jsonify({"code": 0, "msg": "Batch creation failed", "data": result}) except Exception as e: logger.error(f"batch_create_strategies failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@strategy_bp.route('/strategies/batch-start', methods=['POST']) +@strategy_bp.route("/strategies/batch-start", methods=["POST"]) @login_required def batch_start_strategies(): """ Batch start strategies - + Request body: strategy_ids: Array of strategy IDs or @@ -310,44 +309,46 @@ def batch_start_strategies(): try: user_id = g.user_id payload = request.get_json() or {} - strategy_ids = payload.get('strategy_ids') or [] - strategy_group_id = payload.get('strategy_group_id') - + strategy_ids = payload.get("strategy_ids") or [] + strategy_group_id = payload.get("strategy_group_id") + # If strategy_group_id provided, get all strategies in the group if strategy_group_id and not strategy_ids: strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id) - + if not strategy_ids: - return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Please provide strategy IDs", "data": None}), 400 + # Update database status first result = get_strategy_service().batch_start_strategies(strategy_ids, user_id=user_id) - + # Then start executor executor = get_trading_executor() - for sid in result.get('success_ids', []): + for sid in result.get("success_ids", []): try: executor.start_strategy(sid) except Exception as e: logger.error(f"Failed to start executor for strategy {sid}: {e}") - - return jsonify({ - 'code': 1 if result['success'] else 0, - 'msg': f"Successfully started {len(result.get('success_ids', []))} strategies", - 'data': result - }) + + return jsonify( + { + "code": 1 if result["success"] else 0, + "msg": f"Successfully started {len(result.get('success_ids', []))} strategies", + "data": result, + } + ) except Exception as e: logger.error(f"batch_start_strategies failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@strategy_bp.route('/strategies/batch-stop', methods=['POST']) +@strategy_bp.route("/strategies/batch-stop", methods=["POST"]) @login_required def batch_stop_strategies(): """ Batch stop strategies - + Request body: strategy_ids: Array of strategy IDs or @@ -356,15 +357,15 @@ def batch_stop_strategies(): try: user_id = g.user_id payload = request.get_json() or {} - strategy_ids = payload.get('strategy_ids') or [] - strategy_group_id = payload.get('strategy_group_id') - + strategy_ids = payload.get("strategy_ids") or [] + strategy_group_id = payload.get("strategy_group_id") + if strategy_group_id and not strategy_ids: strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id) - + if not strategy_ids: - return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Please provide strategy IDs", "data": None}), 400 + # Stop executor first executor = get_trading_executor() for sid in strategy_ids: @@ -372,27 +373,29 @@ def batch_stop_strategies(): executor.stop_strategy(sid) except Exception as e: logger.error(f"Failed to stop executor for strategy {sid}: {e}") - + # Then update database status result = get_strategy_service().batch_stop_strategies(strategy_ids, user_id=user_id) - - return jsonify({ - 'code': 1 if result['success'] else 0, - 'msg': f"Successfully stopped {len(result.get('success_ids', []))} strategies", - 'data': result - }) + + return jsonify( + { + "code": 1 if result["success"] else 0, + "msg": f"Successfully stopped {len(result.get('success_ids', []))} strategies", + "data": result, + } + ) except Exception as e: logger.error(f"batch_stop_strategies failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@strategy_bp.route('/strategies/batch-delete', methods=['DELETE']) +@strategy_bp.route("/strategies/batch-delete", methods=["DELETE"]) @login_required def batch_delete_strategies(): """ Batch delete strategies - + Request body: strategy_ids: Array of strategy IDs or @@ -401,87 +404,91 @@ def batch_delete_strategies(): try: user_id = g.user_id payload = request.get_json() or {} - strategy_ids = payload.get('strategy_ids') or [] - strategy_group_id = payload.get('strategy_group_id') - + strategy_ids = payload.get("strategy_ids") or [] + strategy_group_id = payload.get("strategy_group_id") + if strategy_group_id and not strategy_ids: strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id, user_id=user_id) - + if not strategy_ids: - return jsonify({'code': 0, 'msg': 'Please provide strategy IDs', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Please provide strategy IDs", "data": None}), 400 + # Stop executor first executor = get_trading_executor() for sid in strategy_ids: try: executor.stop_strategy(sid) - except Exception as e: + except Exception: pass # Ignore stop errors - + # Then delete result = get_strategy_service().batch_delete_strategies(strategy_ids, user_id=user_id) - - return jsonify({ - 'code': 1 if result['success'] else 0, - 'msg': f"Successfully deleted {len(result.get('success_ids', []))} strategies", - 'data': result - }) + + return jsonify( + { + "code": 1 if result["success"] else 0, + "msg": f"Successfully deleted {len(result.get('success_ids', []))} strategies", + "data": result, + } + ) except Exception as e: logger.error(f"batch_delete_strategies failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@strategy_bp.route('/strategies/update', methods=['PUT']) +@strategy_bp.route("/strategies/update", methods=["PUT"]) @login_required def update_strategy(): try: user_id = g.user_id - strategy_id = request.args.get('id', type=int) + strategy_id = request.args.get("id", type=int) if not strategy_id: - return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Missing strategy id parameter", "data": None}), 400 payload = request.get_json() or {} ok = get_strategy_service().update_strategy(strategy_id, payload, user_id=user_id) if not ok: - return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404 - return jsonify({'code': 1, 'msg': 'success', 'data': None}) + return jsonify({"code": 0, "msg": "Strategy not found", "data": None}), 404 + return jsonify({"code": 1, "msg": "success", "data": None}) except Exception as e: logger.error(f"update_strategy failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@strategy_bp.route('/strategies/delete', methods=['DELETE']) +@strategy_bp.route("/strategies/delete", methods=["DELETE"]) @login_required def delete_strategy(): try: user_id = g.user_id - strategy_id = request.args.get('id', type=int) + strategy_id = request.args.get("id", type=int) if not strategy_id: - return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Missing strategy id parameter", "data": None}), 400 ok = get_strategy_service().delete_strategy(strategy_id, user_id=user_id) - return jsonify({'code': 1 if ok else 0, 'msg': 'success' if ok else 'failed', 'data': None}) + return jsonify({"code": 1 if ok else 0, "msg": "success" if ok else "failed", "data": None}) except Exception as e: logger.error(f"delete_strategy failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@strategy_bp.route('/strategies/trades', methods=['GET']) +@strategy_bp.route("/strategies/trades", methods=["GET"]) @login_required def get_trades(): """Get trade records for the current user's strategy.""" try: user_id = g.user_id - strategy_id = request.args.get('id', type=int) + strategy_id = request.args.get("id", type=int) if not strategy_id: - return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': {'trades': [], 'items': []}}), 400 - + return jsonify( + {"code": 0, "msg": "Missing strategy id parameter", "data": {"trades": [], "items": []}} + ), 400 + # Verify strategy belongs to user st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) if not st: - return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': {'trades': [], 'items': []}}), 404 - + return jsonify({"code": 0, "msg": "Strategy not found", "data": {"trades": [], "items": []}}), 404 + with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -491,54 +498,57 @@ def get_trades(): WHERE strategy_id = ? ORDER BY id DESC """, - (strategy_id,) + (strategy_id,), ) 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') + created_at = trade.get("created_at") if created_at: - if hasattr(created_at, 'timestamp'): + if hasattr(created_at, "timestamp"): # datetime object - convert to UTC timestamp - trade['created_at'] = int(created_at.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()) + + 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': processed_rows, 'items': processed_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()) - return jsonify({'code': 0, 'msg': str(e), 'data': {'trades': [], 'items': []}}), 500 + return jsonify({"code": 0, "msg": str(e), "data": {"trades": [], "items": []}}), 500 -@strategy_bp.route('/strategies/positions', methods=['GET']) +@strategy_bp.route("/strategies/positions", methods=["GET"]) @login_required def get_positions(): """Get position records for the current user's strategy.""" try: user_id = g.user_id - strategy_id = request.args.get('id', type=int) + strategy_id = request.args.get("id", type=int) if not strategy_id: - return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': {'positions': [], 'items': []}}), 400 - + return jsonify( + {"code": 0, "msg": "Missing strategy id parameter", "data": {"positions": [], "items": []}} + ), 400 + # Verify strategy belongs to user st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) if not st: - return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': {'positions': [], 'items': []}}), 404 - + return jsonify({"code": 0, "msg": "Strategy not found", "data": {"positions": [], "items": []}}), 404 + with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -549,7 +559,7 @@ def get_positions(): WHERE strategy_id = ? ORDER BY id DESC """, - (strategy_id,) + (strategy_id,), ) rows = cur.fetchall() or [] cur.close() @@ -631,27 +641,27 @@ def get_positions(): db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': {'positions': out, 'items': out}}) + return jsonify({"code": 1, "msg": "success", "data": {"positions": out, "items": out}}) 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': {'positions': [], 'items': []}}), 500 + return jsonify({"code": 0, "msg": str(e), "data": {"positions": [], "items": []}}), 500 -@strategy_bp.route('/strategies/equityCurve', methods=['GET']) +@strategy_bp.route("/strategies/equityCurve", methods=["GET"]) @login_required def get_equity_curve(): """Get equity curve for the current user's strategy.""" try: user_id = g.user_id - strategy_id = request.args.get('id', type=int) + strategy_id = request.args.get("id", type=int) if not strategy_id: - return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': []}), 400 + return jsonify({"code": 0, "msg": "Missing strategy id parameter", "data": []}), 400 st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) or {} if not st: - return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': []}), 404 - initial = float(st.get('initial_capital') or (st.get('trading_config') or {}).get('initial_capital') or 0) + return jsonify({"code": 0, "msg": "Strategy not found", "data": []}), 404 + initial = float(st.get("initial_capital") or (st.get("trading_config") or {}).get("initial_capital") or 0) if initial <= 0: initial = 1000.0 @@ -664,7 +674,7 @@ def get_equity_curve(): WHERE strategy_id = ? ORDER BY created_at ASC """, - (strategy_id,) + (strategy_id,), ) rows = cur.fetchall() or [] cur.execute( @@ -682,280 +692,241 @@ def get_equity_curve(): curve = [] for r in rows: try: - equity += float(r.get('profit') or 0) + equity += float(r.get("profit") or 0) except Exception: pass - created_at = r.get('created_at') - if created_at and hasattr(created_at, 'timestamp'): + created_at = r.get("created_at") + if created_at and hasattr(created_at, "timestamp"): ts = int(created_at.timestamp()) elif created_at: ts = int(created_at) else: ts = int(time.time()) - curve.append({'time': ts, 'equity': round(equity, 2)}) + curve.append({"time": ts, "equity": round(equity, 2)}) # 将未实现盈亏并入曲线末端,便于「持仓中」也能在绩效里看到浮动权益 try: - unreal = float(prow.get('u') or prow.get('U') or 0) + unreal = float(prow.get("u") or prow.get("U") or 0) except Exception: unreal = 0.0 live_equity = float(equity) + unreal now_ts = int(time.time()) if abs(unreal) > 1e-12 or not curve: - curve.append({'time': now_ts, 'equity': round(live_equity, 2)}) + curve.append({"time": now_ts, "equity": round(live_equity, 2)}) - return jsonify({'code': 1, 'msg': 'success', 'data': curve}) + return jsonify({"code": 1, "msg": "success", "data": curve}) except Exception as e: logger.error(f"get_equity_curve failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 + return jsonify({"code": 0, "msg": str(e), "data": []}), 500 - - - -@strategy_bp.route('/strategies/stop', methods=['POST']) +@strategy_bp.route("/strategies/stop", methods=["POST"]) @login_required def stop_strategy(): """ Stop a strategy for the current user. - + Params: id: Strategy ID """ try: user_id = g.user_id - strategy_id = request.args.get('id', type=int) - + strategy_id = request.args.get("id", type=int) + if not strategy_id: - return jsonify({ - 'code': 0, - 'msg': 'Missing strategy id parameter', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": "Missing strategy id parameter", "data": None}), 400 + # Verify strategy belongs to user st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) if not st: - return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404 - + return jsonify({"code": 0, "msg": "Strategy not found", "data": None}), 404 + # Get strategy type strategy_type = get_strategy_service().get_strategy_type(strategy_id) - + # Local backend: AI strategy executor was removed. Only indicator strategies are supported. - if strategy_type == 'PromptBasedStrategy': - return jsonify({'code': 0, 'msg': 'AI strategy has been removed; local edition does not support starting/stopping AI strategies', 'data': None}), 400 + if strategy_type == "PromptBasedStrategy": + return jsonify( + { + "code": 0, + "msg": "AI strategy has been removed; local edition does not support starting/stopping AI strategies", + "data": None, + } + ), 400 # Indicator strategy get_trading_executor().stop_strategy(strategy_id) - + # Update strategy status - get_strategy_service().update_strategy_status(strategy_id, 'stopped', user_id=user_id) - - return jsonify({ - 'code': 1, - 'msg': 'Stopped successfully', - 'data': None - }) - + get_strategy_service().update_strategy_status(strategy_id, "stopped", user_id=user_id) + + return jsonify({"code": 1, "msg": "Stopped successfully", "data": None}) + except Exception as e: logger.error(f"Failed to stop strategy: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({ - 'code': 0, - 'msg': f'Failed to stop strategy: {str(e)}', - 'data': None - }), 500 + return jsonify({"code": 0, "msg": f"Failed to stop strategy: {str(e)}", "data": None}), 500 -@strategy_bp.route('/strategies/start', methods=['POST']) +@strategy_bp.route("/strategies/start", methods=["POST"]) @login_required def start_strategy(): """ Start a strategy for the current user. - + Params: id: Strategy ID """ try: user_id = g.user_id - strategy_id = request.args.get('id', type=int) - + strategy_id = request.args.get("id", type=int) + if not strategy_id: - return jsonify({ - 'code': 0, - 'msg': 'Missing strategy id parameter', - 'data': None - }), 400 - + return jsonify({"code": 0, "msg": "Missing strategy id parameter", "data": None}), 400 + # Verify strategy belongs to user st = get_strategy_service().get_strategy(strategy_id, user_id=user_id) if not st: - return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404 - + return jsonify({"code": 0, "msg": "Strategy not found", "data": None}), 404 + # Get strategy type strategy_type = get_strategy_service().get_strategy_type(strategy_id) # IndicatorStrategy and ScriptStrategy are executed by TradingExecutor. - if strategy_type == 'PromptBasedStrategy': - return jsonify({ - 'code': 0, - 'msg': 'AI strategy has been removed; local edition does not support starting AI strategies', - 'data': None - }), 400 - get_strategy_service().update_strategy_status(strategy_id, 'running', user_id=user_id) + if strategy_type == "PromptBasedStrategy": + return jsonify( + { + "code": 0, + "msg": "AI strategy has been removed; local edition does not support starting AI strategies", + "data": None, + } + ), 400 + get_strategy_service().update_strategy_status(strategy_id, "running", user_id=user_id) success = get_trading_executor().start_strategy(strategy_id) - + if not success: # If start failed, restore status - get_strategy_service().update_strategy_status(strategy_id, 'stopped', user_id=user_id) - return jsonify({ - 'code': 0, - 'msg': 'Failed to start strategy executor', - 'data': None - }), 500 - - return jsonify({ - 'code': 1, - 'msg': 'Started successfully', - 'data': None - }) - + get_strategy_service().update_strategy_status(strategy_id, "stopped", user_id=user_id) + return jsonify({"code": 0, "msg": "Failed to start strategy executor", "data": None}), 500 + + return jsonify({"code": 1, "msg": "Started successfully", "data": None}) + except Exception as e: logger.error(f"Failed to start strategy: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({ - 'code': 0, - 'msg': f'Failed to start strategy: {str(e)}', - 'data': None - }), 500 + return jsonify({"code": 0, "msg": f"Failed to start strategy: {str(e)}", "data": None}), 500 -@strategy_bp.route('/strategies/test-connection', methods=['POST']) +@strategy_bp.route("/strategies/test-connection", methods=["POST"]) @login_required def test_connection(): """ Test exchange connection. - + Request body: exchange_config: Exchange configuration (may contain credential_id or inline keys) """ try: data = request.get_json() or {} - + # Log request data (for debugging, but do not log sensitive information) logger.debug(f"Connection test request keys: {list(data.keys())}") - + # Get exchange configuration - exchange_config = data.get('exchange_config', data) - + exchange_config = data.get("exchange_config", data) + # Local deployment: no encryption/decryption; accept dict or JSON string. if isinstance(exchange_config, str): try: import json + exchange_config = json.loads(exchange_config) except Exception: pass - + # Verify exchange_config is a dictionary if not isinstance(exchange_config, dict): logger.error(f"Invalid exchange_config type: {type(exchange_config)}, data: {str(exchange_config)[:200]}") # Frontend expects HTTP 200 with {code:0} for business failures. - return jsonify({'code': 0, 'msg': 'Invalid exchange config format; please check your payload', 'data': None}) + return jsonify( + {"code": 0, "msg": "Invalid exchange config format; please check your payload", "data": None} + ) # Resolve credential_id → full config (merges credential keys with any overrides). # This allows the frontend to send just {credential_id: 5} without raw api_key/secret_key. from app.services.exchange_execution import resolve_exchange_config - user_id = g.user_id if hasattr(g, 'user_id') else 1 + + user_id = g.user_id if hasattr(g, "user_id") else 1 resolved = resolve_exchange_config(exchange_config, user_id=user_id) # Verify required fields (check resolved config after credential merge) - if not resolved.get('exchange_id'): - return jsonify({'code': 0, 'msg': 'Please select an exchange', 'data': None}) - - api_key = resolved.get('api_key', '') - secret_key = resolved.get('secret_key', '') - + if not resolved.get("exchange_id"): + return jsonify({"code": 0, "msg": "Please select an exchange", "data": None}) + + api_key = resolved.get("api_key", "") + secret_key = resolved.get("secret_key", "") + # Detailed log troubleshooting logger.info(f"Testing connection: exchange_id={resolved.get('exchange_id')}") if api_key: logger.info(f"API Key: {api_key[:5]}... (len={len(api_key)})") if secret_key: logger.info(f"Secret Key: {secret_key[:5]}... (len={len(secret_key)})") - + # Check if there are special characters if api_key and api_key.strip() != api_key: logger.warning("API key contains leading/trailing whitespace") if secret_key and secret_key.strip() != secret_key: logger.warning("Secret key contains leading/trailing whitespace") - + if not api_key or not secret_key: - return jsonify({'code': 0, 'msg': 'Please provide API key and secret key', 'data': None}) - + return jsonify({"code": 0, "msg": "Please provide API key and secret key", "data": None}) + # Pass the resolved config (with actual keys) to the service result = get_strategy_service().test_exchange_connection(resolved, user_id=user_id) - - if result['success']: - return jsonify({'code': 1, 'msg': result.get('message') or 'Connection successful', 'data': result.get('data')}) + + if result["success"]: + return jsonify( + {"code": 1, "msg": result.get("message") or "Connection successful", "data": result.get("data")} + ) # Always return HTTP 200 for business-level failures. - return jsonify({'code': 0, 'msg': result.get('message') or 'Connection failed', 'data': result.get('data')}) - + return jsonify({"code": 0, "msg": result.get("message") or "Connection failed", "data": result.get("data")}) + except Exception as e: logger.error(f"Connection test failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({ - 'code': 0, - 'msg': f'Connection test failed: {str(e)}', - 'data': None - }), 500 + return jsonify({"code": 0, "msg": f"Connection test failed: {str(e)}", "data": None}), 500 -@strategy_bp.route('/strategies/get-symbols', methods=['POST']) +@strategy_bp.route("/strategies/get-symbols", methods=["POST"]) @login_required def get_symbols(): """ Get exchange trading pairs list. - + Request body: exchange_config: Exchange configuration """ try: data = request.get_json() or {} - exchange_config = data.get('exchange_config', data) - + exchange_config = data.get("exchange_config", data) + result = get_strategy_service().get_exchange_symbols(exchange_config) - - if result['success']: - return jsonify({ - 'code': 1, - 'msg': result['message'], - 'data': { - 'symbols': result['symbols'] - } - }) + + if result["success"]: + return jsonify({"code": 1, "msg": result["message"], "data": {"symbols": result["symbols"]}}) else: - return jsonify({ - 'code': 0, - 'msg': result['message'], - 'data': { - 'symbols': [] - } - }) - + return jsonify({"code": 0, "msg": result["message"], "data": {"symbols": []}}) + except Exception as e: logger.error(f"Failed to fetch symbols: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({ - 'code': 0, - 'msg': f'Failed to fetch symbols: {str(e)}', - 'data': { - 'symbols': [] - } - }), 500 + return jsonify({"code": 0, "msg": f"Failed to fetch symbols: {str(e)}", "data": {"symbols": []}}), 500 -@strategy_bp.route('/strategies/preview-compile', methods=['POST']) +@strategy_bp.route("/strategies/preview-compile", methods=["POST"]) @login_required def preview_compile(): """ @@ -964,45 +935,36 @@ def preview_compile(): try: data = request.get_json() or {} # strategy_config is passed as 'config' - config = data.get('config') - + config = data.get("config") + if not config: - return jsonify({'code': 0, 'msg': 'Missing config'}), 400 + return jsonify({"code": 0, "msg": "Missing config"}), 400 # Compile compiler = StrategyCompiler() try: code = compiler.compile(config) except Exception as e: - return jsonify({'code': 0, 'msg': f'Compilation failed: {str(e)}'}), 400 - - # Execute - symbol = config.get('symbol', 'BTC/USDT') - timeframe = config.get('timeframe', '4h') - - backtest_service = BacktestService() - result = backtest_service.run_code_strategy( - code=code, - symbol=symbol, - timeframe=timeframe, - limit=500 - ) - - if result.get('error'): - return jsonify({'code': 0, 'msg': f"Execution failed: {result['error']}"}), 400 + return jsonify({"code": 0, "msg": f"Compilation failed: {str(e)}"}), 400 + + # Execute + symbol = config.get("symbol", "BTC/USDT") + timeframe = config.get("timeframe", "4h") + + backtest_service = BacktestService() + result = backtest_service.run_code_strategy(code=code, symbol=symbol, timeframe=timeframe, limit=500) + + if result.get("error"): + return jsonify({"code": 0, "msg": f"Execution failed: {result['error']}"}), 400 + + return jsonify({"code": 1, "msg": "Success", "data": result}) - return jsonify({ - 'code': 1, - 'msg': 'Success', - 'data': result - }) - except Exception as e: logger.error(f"Preview failed: {e}") - return jsonify({'code': 0, 'msg': str(e)}), 500 + return jsonify({"code": 0, "msg": str(e)}), 500 -@strategy_bp.route('/strategies/notifications', methods=['GET']) +@strategy_bp.route("/strategies/notifications", methods=["GET"]) @login_required def get_strategy_notifications(): """ @@ -1015,10 +977,10 @@ def get_strategy_notifications(): """ try: user_id = g.user_id - strategy_id = request.args.get('id', type=int) - limit = request.args.get('limit', type=int) or 50 + strategy_id = request.args.get("id", type=int) + limit = request.args.get("limit", type=int) or 50 limit = max(1, min(200, int(limit))) - since_id = request.args.get('since_id', type=int) or 0 + since_id = request.args.get("since_id", type=int) or 0 # Get user's strategy IDs for filtering notifications user_strategy_ids = [] @@ -1026,19 +988,19 @@ def get_strategy_notifications(): cur = db.cursor() cur.execute("SELECT id FROM qd_strategies_trading WHERE user_id = ?", (user_id,)) rows = cur.fetchall() or [] - user_strategy_ids = [r.get('id') for r in rows if r.get('id')] + user_strategy_ids = [r.get("id") for r in rows if r.get("id")] cur.close() - + where = [] args = [] - + # Filter by user's strategies if strategy_id: if strategy_id in user_strategy_ids: where.append("strategy_id = ?") args.append(int(strategy_id)) else: - return jsonify({'code': 1, 'msg': 'success', 'data': {'items': []}}) + return jsonify({"code": 1, "msg": "success", "data": {"items": []}}) else: if user_strategy_ids: placeholders = ",".join(["?"] * len(user_strategy_ids)) @@ -1049,7 +1011,7 @@ def get_strategy_notifications(): # Only portfolio monitor notifications (strategy_id is NULL) where.append("strategy_id IS NULL AND user_id = ?") args.append(user_id) - + if since_id: where.append("id > ?") args.append(int(since_id)) @@ -1072,33 +1034,35 @@ def get_strategy_notifications(): # Convert created_at to UTC timestamp (seconds) for frontend from datetime import timezone as _dt_tz + processed_rows = [] for row in rows: item = dict(row) - created_at = item.get('created_at') + created_at = item.get("created_at") if created_at: - if hasattr(created_at, 'timestamp'): + if hasattr(created_at, "timestamp"): # No time zone datetime: The connection has SET TIME ZONE UTC, interpret it according to UTC and then transfer to Unix to avoid misjudgment of the local TZ on the server side. - if getattr(created_at, 'tzinfo', None) is None: + if getattr(created_at, "tzinfo", None) is None: created_at = created_at.replace(tzinfo=_dt_tz.utc) - item['created_at'] = int(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()) + + 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}}) + 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()) - return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500 + return jsonify({"code": 0, "msg": str(e), "data": {"items": []}}), 500 -@strategy_bp.route('/strategies/notifications/unread-count', methods=['GET']) +@strategy_bp.route("/strategies/notifications/unread-count", methods=["GET"]) @login_required def get_unread_notification_count(): """ @@ -1112,7 +1076,7 @@ def get_unread_notification_count(): cur = db.cursor() cur.execute("SELECT id FROM qd_strategies_trading WHERE user_id = ?", (user_id,)) rows = cur.fetchall() or [] - user_strategy_ids = [r.get('id') for r in rows if r.get('id')] + user_strategy_ids = [r.get("id") for r in rows if r.get("id")] cur.close() where = ["is_read = 0"] @@ -1138,47 +1102,47 @@ def get_unread_notification_count(): cnt = int((cur.fetchone() or {}).get("cnt") or 0) cur.close() - return jsonify({'code': 1, 'msg': 'success', 'data': {'unread': cnt}}) + return jsonify({"code": 1, "msg": "success", "data": {"unread": cnt}}) except Exception as e: logger.error(f"get_unread_notification_count failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': {'unread': 0}}), 500 + return jsonify({"code": 0, "msg": str(e), "data": {"unread": 0}}), 500 -@strategy_bp.route('/strategies/notifications/read', methods=['POST']) +@strategy_bp.route("/strategies/notifications/read", methods=["POST"]) @login_required def mark_notification_read(): """Mark a single notification as read for the current user.""" try: user_id = g.user_id data = request.get_json(force=True, silent=True) or {} - notification_id = data.get('id') + notification_id = data.get("id") if not notification_id: - return jsonify({'code': 0, 'msg': 'Missing id'}), 400 + return jsonify({"code": 0, "msg": "Missing id"}), 400 # Update notifications for user's strategies OR portfolio monitor notifications with get_db_connection() as db: cur = db.cursor() cur.execute( """ - UPDATE qd_strategy_notifications SET is_read = 1 + UPDATE qd_strategy_notifications SET is_read = 1 WHERE id = ? AND ( strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?) OR (strategy_id IS NULL AND user_id = ?) ) """, - (int(notification_id), user_id, user_id) + (int(notification_id), user_id, user_id), ) db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success'}) + 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 + return jsonify({"code": 0, "msg": str(e)}), 500 -@strategy_bp.route('/strategies/notifications/read-all', methods=['POST']) +@strategy_bp.route("/strategies/notifications/read-all", methods=["POST"]) @login_required def mark_all_notifications_read(): """Mark all notifications as read for the current user.""" @@ -1188,22 +1152,22 @@ def mark_all_notifications_read(): cur = db.cursor() cur.execute( """ - UPDATE qd_strategy_notifications SET is_read = 1 + UPDATE qd_strategy_notifications SET is_read = 1 WHERE strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?) OR (strategy_id IS NULL AND user_id = ?) """, - (user_id, user_id) + (user_id, user_id), ) db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success'}) + 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 + return jsonify({"code": 0, "msg": str(e)}), 500 -@strategy_bp.route('/strategies/notifications/clear', methods=['DELETE']) +@strategy_bp.route("/strategies/notifications/clear", methods=["DELETE"]) @login_required def clear_notifications(): """Clear all notifications for the current user.""" @@ -1213,78 +1177,74 @@ def clear_notifications(): cur = db.cursor() cur.execute( """ - DELETE FROM qd_strategy_notifications + DELETE FROM qd_strategy_notifications WHERE strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?) OR (strategy_id IS NULL AND user_id = ?) """, - (user_id, user_id) + (user_id, user_id), ) db.commit() cur.close() - return jsonify({'code': 1, 'msg': 'success'}) + 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 + return jsonify({"code": 0, "msg": str(e)}), 500 # ===== Script Strategy Endpoints ===== -@strategy_bp.route('/strategies/verify-code', methods=['POST']) + +@strategy_bp.route("/strategies/verify-code", methods=["POST"]) @login_required def verify_strategy_code(): """Verify script strategy code syntax and safety.""" try: payload = request.get_json() or {} - code = payload.get('code', '') + code = payload.get("code", "") if not code.strip(): - return jsonify({'success': False, 'message': 'Code is empty'}) + return jsonify({"success": False, "message": "Code is empty"}) - required_funcs = ['on_bar', 'on_init'] - found = [f for f in required_funcs if f'def {f}' in code] + required_funcs = ["on_bar", "on_init"] + found = [f for f in required_funcs if f"def {f}" in code] missing = [f for f in required_funcs if f not in found] if missing: - return jsonify({ - 'success': False, - 'message': f'Missing required functions: {", ".join(missing)}' - }) + return jsonify({"success": False, "message": f"Missing required functions: {', '.join(missing)}"}) try: - compile(code, '', 'exec') + compile(code, "", "exec") except SyntaxError as se: - return jsonify({ - 'success': False, - 'message': f'Syntax error at line {se.lineno}: {se.msg}' - }) + return jsonify({"success": False, "message": f"Syntax error at line {se.lineno}: {se.msg}"}) - return jsonify({'success': True, 'message': 'Code verification passed'}) + return jsonify({"success": True, "message": "Code verification passed"}) except Exception as e: logger.error(f"verify_strategy_code failed: {str(e)}") - return jsonify({'success': False, 'message': str(e)}) + return jsonify({"success": False, "message": str(e)}) -@strategy_bp.route('/strategies/ai-generate', methods=['POST']) +@strategy_bp.route("/strategies/ai-generate", methods=["POST"]) @login_required def ai_generate_strategy(): """Generate strategy code or suggest template parameter updates using AI.""" try: payload = request.get_json() or {} - prompt = payload.get('prompt', '') + prompt = payload.get("prompt", "") if not prompt.strip(): - return jsonify({'code': '', 'msg': 'Prompt is empty', 'params': None}) + return jsonify({"code": "", "msg": "Prompt is empty", "params": None}) - intent = (payload.get('intent') or 'generate_code').strip() + intent = (payload.get("intent") or "generate_code").strip() from app.services.llm import LLMService + llm = LLMService() api_key = llm.get_api_key() if not api_key: - return jsonify({'code': '', 'msg': 'No LLM API key configured', 'params': None}) + return jsonify({"code": "", "msg": "No LLM API key configured", "params": None}) - if intent == 'adjust_params': - template_key = payload.get('template_key') or '' - current_params = payload.get('params') or {} - code_snapshot = (payload.get('code') or '')[:8000] + if intent == "adjust_params": + template_key = payload.get("template_key") or "" + current_params = payload.get("params") or {} + code_snapshot = (payload.get("code") or "")[:8000] system_prompt = """You tune quantitative strategy template parameters from the user's request. Return ONLY a single JSON object: keys are parameter names (strings), values are JSON numbers or booleans. You may return a partial object (only keys that should change) or a full object. @@ -1305,27 +1265,27 @@ Do not use markdown fences, do not add explanations before or after the JSON.""" ], model=llm.get_code_generation_model(), temperature=0.3, - use_json_mode=False + use_json_mode=False, ) - raw = (content or '').strip() - if raw.startswith('```'): - raw = re.sub(r'^```[a-zA-Z]*', '', raw).strip() - if raw.endswith('```'): + raw = (content or "").strip() + if raw.startswith("```"): + raw = re.sub(r"^```[a-zA-Z]*", "", raw).strip() + if raw.endswith("```"): raw = raw[:-3].strip() updates = None try: updates = json.loads(raw) except json.JSONDecodeError: - m = re.search(r'\{[\s\S]*\}', raw) + m = re.search(r"\{[\s\S]*\}", raw) if m: try: updates = json.loads(m.group(0)) except json.JSONDecodeError: updates = None if not isinstance(updates, dict): - return jsonify({'code': '', 'params': None, 'msg': 'AI did not return valid JSON parameters'}) - return jsonify({'code': '', 'params': updates, 'msg': 'success'}) + return jsonify({"code": "", "params": None, "msg": "AI did not return valid JSON parameters"}) + return jsonify({"code": "", "params": updates, "msg": "success"}) system_prompt = """You are a quantitative trading strategy code generator. Generate Python strategy code that follows this framework: @@ -1343,19 +1303,19 @@ Generate Python strategy code that follows this framework: Return ONLY the Python code, no explanations.""" - extra = '' - template_key = payload.get('template_key') - params = payload.get('params') - code_ctx = (payload.get('code') or '').strip() + extra = "" + template_key = payload.get("template_key") + params = payload.get("params") + code_ctx = (payload.get("code") or "").strip() if template_key or params is not None or code_ctx: extra_parts = [] if template_key: extra_parts.append(f"Current template key: {template_key}") if isinstance(params, dict) and params: - extra_parts.append('Current template parameters (JSON):\n' + json.dumps(params, ensure_ascii=False)) + extra_parts.append("Current template parameters (JSON):\n" + json.dumps(params, ensure_ascii=False)) if code_ctx: - extra_parts.append('Current code (may be long):\n' + code_ctx[:12000]) - extra = '\n\n' + '\n\n'.join(extra_parts) + extra_parts.append("Current code (may be long):\n" + code_ctx[:12000]) + extra = "\n\n" + "\n\n".join(extra_parts) user_prompt = prompt.strip() + extra @@ -1366,7 +1326,7 @@ Return ONLY the Python code, no explanations.""" ], model=llm.get_code_generation_model(), temperature=0.7, - use_json_mode=False + use_json_mode=False, ) content = content.strip() @@ -1379,51 +1339,45 @@ Return ONLY the Python code, no explanations.""" content = content.strip() if content: - return jsonify({'code': content, 'msg': 'success', 'params': None}) + return jsonify({"code": content, "msg": "success", "params": None}) else: - return jsonify({'code': '', 'msg': 'AI generation returned empty result', 'params': None}) + return jsonify({"code": "", "msg": "AI generation returned empty result", "params": None}) except Exception as e: logger.error(f"ai_generate_strategy failed: {str(e)}") - return jsonify({'code': '', 'msg': str(e), 'params': None}) + return jsonify({"code": "", "msg": str(e), "params": None}) -@strategy_bp.route('/strategies/performance', methods=['GET']) +@strategy_bp.route("/strategies/performance", methods=["GET"]) @login_required def get_strategy_performance(): """Get strategy performance metrics (aggregated from equity curve and trades).""" try: - strategy_id = request.args.get('id') + strategy_id = request.args.get("id") if not strategy_id: - return jsonify({'code': 0, 'msg': 'Strategy ID required'}) + return jsonify({"code": 0, "msg": "Strategy ID required"}) svc = get_strategy_service() equity_data = svc.get_equity_curve(int(strategy_id)) - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'equity_curve': equity_data - } - }) + return jsonify({"code": 1, "msg": "success", "data": {"equity_curve": equity_data}}) except Exception as e: logger.error(f"get_strategy_performance failed: {str(e)}") - return jsonify({'code': 0, 'msg': str(e)}), 500 + return jsonify({"code": 0, "msg": str(e)}), 500 -@strategy_bp.route('/strategies/logs', methods=['GET']) +@strategy_bp.route("/strategies/logs", methods=["GET"]) @login_required def get_strategy_logs(): """Get strategy running logs.""" try: user_id = g.user_id - strategy_id = request.args.get('id') - limit = int(request.args.get('limit', 200)) + strategy_id = request.args.get("id") + limit = int(request.args.get("limit", 200)) if not strategy_id: - return jsonify({'code': 0, 'msg': 'Strategy ID required'}) + return jsonify({"code": 0, "msg": "Strategy ID required"}) st = get_strategy_service().get_strategy(int(strategy_id), user_id=user_id) if not st: - return jsonify({'code': 0, 'msg': 'Strategy not found'}), 404 + return jsonify({"code": 0, "msg": "Strategy not found"}), 404 with get_db_connection() as db: cur = db.cursor() @@ -1435,7 +1389,7 @@ def get_strategy_logs(): ORDER BY id DESC LIMIT ? """, - (int(strategy_id), limit) + (int(strategy_id), limit), ) rows = cur.fetchall() or [] cur.close() @@ -1445,17 +1399,17 @@ def get_strategy_logs(): if not isinstance(r, dict): continue rr = dict(r) - ts = rr.get('timestamp') - if ts is not None and hasattr(ts, 'isoformat'): - rr['timestamp'] = ts.isoformat() + ts = rr.get("timestamp") + if ts is not None and hasattr(ts, "isoformat"): + rr["timestamp"] = ts.isoformat() out.append(rr) logs = list(reversed(out)) - return jsonify({'code': 1, 'msg': 'success', 'data': logs}) + return jsonify({"code": 1, "msg": "success", "data": logs}) except Exception as e: if PgUndefinedTable is not None and isinstance(e, PgUndefinedTable): - return jsonify({'code': 1, 'msg': 'success', 'data': []}) + return jsonify({"code": 1, "msg": "success", "data": []}) el = str(e).lower() - if 'qd_strategy_logs' in el and ('does not exist' in el or 'no such table' in el): - return jsonify({'code': 1, 'msg': 'success', 'data': []}) + if "qd_strategy_logs" in el and ("does not exist" in el or "no such table" in el): + return jsonify({"code": 1, "msg": "success", "data": []}) logger.error(f"get_strategy_logs failed: {str(e)}") - return jsonify({'code': 0, 'msg': str(e)}), 500 \ No newline at end of file + return jsonify({"code": 0, "msg": str(e)}), 500 diff --git a/backend_api_python/app/routes/user.py b/backend_api_python/app/routes/user.py index 3308219..0bc3634 100644 --- a/backend_api_python/app/routes/user.py +++ b/backend_api_python/app/routes/user.py @@ -4,82 +4,77 @@ User Management API Routes Provides endpoints for user CRUD operations, role management, etc. Only accessible by admin users. """ + import json import re -from flask import Blueprint, request, jsonify, g + +from flask import Blueprint, g, jsonify, request + from app.services.user_service import get_user_service -from app.utils.auth import login_required, admin_required +from app.utils.auth import admin_required, login_required from app.utils.db import get_db_connection from app.utils.logger import get_logger logger = get_logger(__name__) -_PROFILE_TIMEZONE_RE = re.compile(r'^[A-Za-z0-9_/+\-.]+$') +_PROFILE_TIMEZONE_RE = re.compile(r"^[A-Za-z0-9_/+\-.]+$") -user_bp = Blueprint('user_manage', __name__) +user_bp = Blueprint("user_manage", __name__) -@user_bp.route('/list', methods=['GET']) +@user_bp.route("/list", methods=["GET"]) @login_required @admin_required def list_users(): """ List all users (admin only). - + Query params: page: int (default 1) page_size: int (default 20, max 100) search: str (optional, search by username/email/nickname) """ try: - page = request.args.get('page', 1, type=int) - page_size = request.args.get('page_size', 20, type=int) - search = request.args.get('search', '', type=str) + page = request.args.get("page", 1, type=int) + page_size = request.args.get("page_size", 20, type=int) + search = request.args.get("search", "", type=str) page_size = min(100, max(1, page_size)) - + result = get_user_service().list_users(page=page, page_size=page_size, search=search) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': result - }) + + return jsonify({"code": 1, "msg": "success", "data": result}) except Exception as e: logger.error(f"list_users failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/detail', methods=['GET']) +@user_bp.route("/detail", methods=["GET"]) @login_required @admin_required def get_user_detail(): """Get user detail by ID (admin only)""" try: - user_id = request.args.get('id', type=int) + user_id = request.args.get("id", type=int) if not user_id: - return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Missing user id", "data": None}), 400 + user = get_user_service().get_user_by_id(user_id) if not user: - return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': user - }) + return jsonify({"code": 0, "msg": "User not found", "data": None}), 404 + + return jsonify({"code": 1, "msg": "success", "data": user}) except Exception as e: logger.error(f"get_user_detail failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/create', methods=['POST']) +@user_bp.route("/create", methods=["POST"]) @login_required @admin_required def create_user(): """ Create a new user (admin only). - + Request body: username: str (required) password: str (required) @@ -89,31 +84,27 @@ def create_user(): """ try: data = request.get_json() or {} - + user_id = get_user_service().create_user(data) - - return jsonify({ - 'code': 1, - 'msg': 'User created successfully', - 'data': {'id': user_id} - }) + + return jsonify({"code": 1, "msg": "User created successfully", "data": {"id": user_id}}) except ValueError as e: - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400 + return jsonify({"code": 0, "msg": str(e), "data": None}), 400 except Exception as e: logger.error(f"create_user failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/update', methods=['PUT']) +@user_bp.route("/update", methods=["PUT"]) @login_required @admin_required def update_user(): """ Update user information (admin only). - + Query params: id: int (required) - + Request body: email: str (optional) nickname: str (optional) @@ -121,114 +112,107 @@ def update_user(): status: str (optional) """ try: - user_id = request.args.get('id', type=int) + user_id = request.args.get("id", type=int) if not user_id: - return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Missing user id", "data": None}), 400 + data = request.get_json() or {} - + success = get_user_service().update_user(user_id, data) - + if success: - return jsonify({'code': 1, 'msg': 'User updated successfully', 'data': None}) + return jsonify({"code": 1, "msg": "User updated successfully", "data": None}) else: - return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Update failed", "data": None}), 400 except Exception as e: logger.error(f"update_user failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/delete', methods=['DELETE']) +@user_bp.route("/delete", methods=["DELETE"]) @login_required @admin_required def delete_user(): """Delete a user (admin only)""" try: - user_id = request.args.get('id', type=int) + user_id = request.args.get("id", type=int) if not user_id: - return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Missing user id", "data": None}), 400 + # Prevent deleting self - if hasattr(g, 'user_id') and g.user_id == user_id: - return jsonify({'code': 0, 'msg': 'Cannot delete yourself', 'data': None}), 400 - + if hasattr(g, "user_id") and g.user_id == user_id: + return jsonify({"code": 0, "msg": "Cannot delete yourself", "data": None}), 400 + success = get_user_service().delete_user(user_id) - + if success: - return jsonify({'code': 1, 'msg': 'User deleted successfully', 'data': None}) + return jsonify({"code": 1, "msg": "User deleted successfully", "data": None}) else: - return jsonify({'code': 0, 'msg': 'Delete failed', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Delete failed", "data": None}), 400 except Exception as e: logger.error(f"delete_user failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/reset-password', methods=['POST']) +@user_bp.route("/reset-password", methods=["POST"]) @login_required @admin_required def reset_user_password(): """ Reset a user's password (admin only). - + Request body: user_id: int (required) new_password: str (required) """ try: data = request.get_json() or {} - user_id = data.get('user_id') - new_password = data.get('new_password', '') - + user_id = data.get("user_id") + new_password = data.get("new_password", "") + if not user_id: - return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Missing user_id", "data": None}), 400 + if len(new_password) < 6: - return jsonify({'code': 0, 'msg': 'Password must be at least 6 characters', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Password must be at least 6 characters", "data": None}), 400 + success = get_user_service().reset_password(user_id, new_password) - + if success: - return jsonify({'code': 1, 'msg': 'Password reset successfully', 'data': None}) + return jsonify({"code": 1, "msg": "Password reset successfully", "data": None}) else: - return jsonify({'code': 0, 'msg': 'Reset failed', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Reset failed", "data": None}), 400 except ValueError as e: - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400 + return jsonify({"code": 0, "msg": str(e), "data": None}), 400 except Exception as e: logger.error(f"reset_user_password failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/roles', methods=['GET']) +@user_bp.route("/roles", methods=["GET"]) @login_required @admin_required def get_roles(): """Get available roles and their permissions""" service = get_user_service() - + roles = [] for role in service.ROLES: - roles.append({ - 'id': role, - 'name': role.capitalize(), - 'permissions': service.get_user_permissions(role) - }) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': {'roles': roles} - }) + roles.append({"id": role, "name": role.capitalize(), "permissions": service.get_user_permissions(role)}) + + return jsonify({"code": 1, "msg": "success", "data": {"roles": roles}}) # ==================== Billing Management (Admin) ==================== -@user_bp.route('/set-credits', methods=['POST']) + +@user_bp.route("/set-credits", methods=["POST"]) @login_required @admin_required def set_user_credits(): """ Set user credits (admin only). - + Request body: user_id: int (required) credits: int (required) @@ -236,37 +220,37 @@ def set_user_credits(): """ try: from app.services.billing_service import get_billing_service - + data = request.get_json() or {} - user_id = data.get('user_id') - credits = data.get('credits') - remark = data.get('remark', '') - + user_id = data.get("user_id") + credits = data.get("credits") + remark = data.get("remark", "") + if not user_id: - return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Missing user_id", "data": None}), 400 + if credits is None or credits < 0: - return jsonify({'code': 0, 'msg': 'Credits must be a non-negative number', 'data': None}), 400 - - operator_id = getattr(g, 'user_id', None) + return jsonify({"code": 0, "msg": "Credits must be a non-negative number", "data": None}), 400 + + operator_id = getattr(g, "user_id", None) success, result = get_billing_service().set_credits(user_id, int(credits), remark, operator_id) - + if success: - return jsonify({'code': 1, 'msg': 'Credits updated successfully', 'data': {'credits': result}}) + return jsonify({"code": 1, "msg": "Credits updated successfully", "data": {"credits": result}}) else: - return jsonify({'code': 0, 'msg': result, 'data': None}), 400 + return jsonify({"code": 0, "msg": result, "data": None}), 400 except Exception as e: logger.error(f"set_user_credits failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/set-vip', methods=['POST']) +@user_bp.route("/set-vip", methods=["POST"]) @login_required @admin_required def set_user_vip(): """ Set user VIP status (admin only). - + Request body: user_id: int (required) vip_days: int (optional, 0 to cancel VIP, positive number to grant VIP for days) @@ -275,55 +259,58 @@ def set_user_vip(): """ try: from datetime import datetime, timedelta, timezone + from app.services.billing_service import get_billing_service - + data = request.get_json() or {} - user_id = data.get('user_id') - vip_days = data.get('vip_days') - vip_expires_at_str = data.get('vip_expires_at') - remark = data.get('remark', '') - + user_id = data.get("user_id") + vip_days = data.get("vip_days") + vip_expires_at_str = data.get("vip_expires_at") + remark = data.get("remark", "") + if not user_id: - return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Missing user_id", "data": None}), 400 + # Calculate expires_at expires_at = None if vip_expires_at_str: try: - expires_at = datetime.fromisoformat(vip_expires_at_str.replace('Z', '+00:00')) + expires_at = datetime.fromisoformat(vip_expires_at_str.replace("Z", "+00:00")) except ValueError: - return jsonify({'code': 0, 'msg': 'Invalid vip_expires_at format', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Invalid vip_expires_at format", "data": None}), 400 elif vip_days is not None: if vip_days > 0: expires_at = datetime.now(timezone.utc) + timedelta(days=vip_days) else: expires_at = None # Cancel VIP else: - return jsonify({'code': 0, 'msg': 'Provide vip_days or vip_expires_at', 'data': None}), 400 - - operator_id = getattr(g, 'user_id', None) + return jsonify({"code": 0, "msg": "Provide vip_days or vip_expires_at", "data": None}), 400 + + operator_id = getattr(g, "user_id", None) success, result = get_billing_service().set_vip(user_id, expires_at, remark, operator_id) - + if success: - return jsonify({ - 'code': 1, - 'msg': 'VIP status updated successfully', - 'data': {'vip_expires_at': expires_at.isoformat() if expires_at else None} - }) + return jsonify( + { + "code": 1, + "msg": "VIP status updated successfully", + "data": {"vip_expires_at": expires_at.isoformat() if expires_at else None}, + } + ) else: - return jsonify({'code': 0, 'msg': result, 'data': None}), 400 + return jsonify({"code": 0, "msg": result, "data": None}), 400 except Exception as e: logger.error(f"set_user_vip failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/credits-log', methods=['GET']) +@user_bp.route("/credits-log", methods=["GET"]) @login_required @admin_required def get_user_credits_log(): """ Get user credits log (admin only). - + Query params: user_id: int (required) page: int (default 1) @@ -331,171 +318,165 @@ def get_user_credits_log(): """ try: from app.services.billing_service import get_billing_service - - user_id = request.args.get('user_id', type=int) + + user_id = request.args.get("user_id", type=int) if not user_id: - return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 - - page = request.args.get('page', 1, type=int) - page_size = request.args.get('page_size', 20, type=int) + return jsonify({"code": 0, "msg": "Missing user_id", "data": None}), 400 + + page = request.args.get("page", 1, type=int) + page_size = request.args.get("page_size", 20, type=int) page_size = min(100, max(1, page_size)) - + result = get_billing_service().get_credits_log(user_id, page, page_size) - - return jsonify({'code': 1, 'msg': 'success', 'data': result}) + + return jsonify({"code": 1, "msg": "success", "data": result}) except Exception as e: logger.error(f"get_user_credits_log failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # Self-service endpoints (accessible by any logged-in user) -@user_bp.route('/profile', methods=['GET']) + +@user_bp.route("/profile", methods=["GET"]) @login_required def get_profile(): """Get current user's profile with billing info and notification settings""" try: import json + from app.services.billing_service import get_billing_service from app.utils.db import get_db_connection - - user_id = getattr(g, 'user_id', None) + + user_id = getattr(g, "user_id", None) if not user_id: - return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 - + return jsonify({"code": 0, "msg": "Not authenticated", "data": None}), 401 + user = get_user_service().get_user_by_id(user_id) if not user: - return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 - + return jsonify({"code": 0, "msg": "User not found", "data": None}), 404 + # Add permissions - user['permissions'] = get_user_service().get_user_permissions(user.get('role', 'user')) - + user["permissions"] = get_user_service().get_user_permissions(user.get("role", "user")) + # Add billing info billing_info = get_billing_service().get_user_billing_info(user_id) - user['billing'] = billing_info - + user["billing"] = billing_info + # Add notification settings with get_db_connection() as db: cur = db.cursor() cur.execute("SELECT notification_settings FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() - - settings_str = (row.get('notification_settings') if row else '') or '' + + settings_str = (row.get("notification_settings") if row else "") or "" notification_settings = {} if settings_str: try: notification_settings = json.loads(settings_str) except Exception: notification_settings = {} - + # Default values - if 'default_channels' not in notification_settings: - notification_settings['default_channels'] = ['browser'] - - user['notification_settings'] = notification_settings - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': user - }) + if "default_channels" not in notification_settings: + notification_settings["default_channels"] = ["browser"] + + user["notification_settings"] = notification_settings + + return jsonify({"code": 1, "msg": "success", "data": user}) except Exception as e: logger.error(f"get_profile failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/profile/update', methods=['PUT']) +@user_bp.route("/profile/update", methods=["PUT"]) @login_required def update_profile(): """ Update current user's profile (limited fields). - + Request body: nickname: str (optional) avatar: str (optional) timezone: str (optional, IANA id; empty = follow client) - + Note: Email cannot be changed after registration (for security). Only admin can change user email via User Management. """ try: - user_id = getattr(g, 'user_id', None) + user_id = getattr(g, "user_id", None) if not user_id: - return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 - + return jsonify({"code": 0, "msg": "Not authenticated", "data": None}), 401 + data = request.get_json() or {} - + # Only allow updating certain fields for self-service # Email is NOT allowed to be changed (security: bound to account) allowed = {} - for field in ['nickname', 'avatar']: + for field in ["nickname", "avatar"]: if field in data: allowed[field] = data[field] - - if 'timezone' in data: - tz = (data.get('timezone') or '').strip() + + if "timezone" in data: + tz = (data.get("timezone") or "").strip() if tz and (len(tz) > 64 or not _PROFILE_TIMEZONE_RE.match(tz)): - return jsonify({ - 'code': 0, - 'msg': 'Invalid timezone identifier', - 'data': None - }), 400 - allowed['timezone'] = tz - + return jsonify({"code": 0, "msg": "Invalid timezone identifier", "data": None}), 400 + allowed["timezone"] = tz + if not allowed: - return jsonify({'code': 0, 'msg': 'No valid fields to update', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "No valid fields to update", "data": None}), 400 + success = get_user_service().update_user(user_id, allowed) - + if success: - return jsonify({'code': 1, 'msg': 'Profile updated successfully', 'data': None}) + return jsonify({"code": 1, "msg": "Profile updated successfully", "data": None}) else: - return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Update failed", "data": None}), 400 except Exception as e: logger.error(f"update_profile failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/my-credits-log', methods=['GET']) +@user_bp.route("/my-credits-log", methods=["GET"]) @login_required def get_my_credits_log(): """ Get current user's credits log. - + Query params: page: int (default 1) page_size: int (default 20) """ try: from app.services.billing_service import get_billing_service - - user_id = getattr(g, 'user_id', None) + + user_id = getattr(g, "user_id", None) if not user_id: - return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 - - page = request.args.get('page', 1, type=int) - page_size = request.args.get('page_size', 20, type=int) + return jsonify({"code": 0, "msg": "Not authenticated", "data": None}), 401 + + page = request.args.get("page", 1, type=int) + page_size = request.args.get("page_size", 20, type=int) page_size = min(100, max(1, page_size)) - + result = get_billing_service().get_credits_log(user_id, page, page_size) - - return jsonify({'code': 1, 'msg': 'success', 'data': result}) + + return jsonify({"code": 1, "msg": "success", "data": result}) except Exception as e: logger.error(f"get_my_credits_log failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/my-referrals', methods=['GET']) +@user_bp.route("/my-referrals", methods=["GET"]) @login_required def get_my_referrals(): """ Get list of users referred by current user. - + Query params: page: int (default 1) page_size: int (default 20) - + Returns: list: Users referred by current user (id, username, nickname, avatar, created_at) total: Total count of referrals @@ -505,75 +486,77 @@ def get_my_referrals(): """ try: import os + from app.utils.db import get_db_connection - - user_id = getattr(g, 'user_id', None) + + user_id = getattr(g, "user_id", None) if not user_id: - return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 - - page = request.args.get('page', 1, type=int) - page_size = request.args.get('page_size', 20, type=int) + return jsonify({"code": 0, "msg": "Not authenticated", "data": None}), 401 + + page = request.args.get("page", 1, type=int) + page_size = request.args.get("page_size", 20, type=int) page_size = min(100, max(1, page_size)) offset = (page - 1) * page_size - + with get_db_connection() as db: cur = db.cursor() - + # Get total count - cur.execute( - "SELECT COUNT(*) as cnt FROM qd_users WHERE referred_by = ?", - (user_id,) - ) - total = cur.fetchone()['cnt'] - + cur.execute("SELECT COUNT(*) as cnt FROM qd_users WHERE referred_by = ?", (user_id,)) + total = cur.fetchone()["cnt"] + # Get referral list cur.execute( """ - SELECT id, username, nickname, avatar, created_at - FROM qd_users + SELECT id, username, nickname, avatar, created_at + FROM qd_users WHERE referred_by = ? ORDER BY created_at DESC LIMIT ? OFFSET ? """, - (user_id, page_size, offset) + (user_id, page_size, offset), ) rows = cur.fetchall() cur.close() - + referrals = [] for row in rows: - referrals.append({ - 'id': row['id'], - 'username': row['username'], - 'nickname': row['nickname'], - 'avatar': row['avatar'], - 'created_at': row['created_at'].isoformat() if row['created_at'] else None - }) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'list': referrals, - 'total': total, - 'page': page, - 'page_size': page_size, - 'referral_code': str(user_id), - 'referral_bonus': int(os.getenv('CREDITS_REFERRAL_BONUS', '0')), - 'register_bonus': int(os.getenv('CREDITS_REGISTER_BONUS', '0')) + referrals.append( + { + "id": row["id"], + "username": row["username"], + "nickname": row["nickname"], + "avatar": row["avatar"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + } + ) + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "list": referrals, + "total": total, + "page": page, + "page_size": page_size, + "referral_code": str(user_id), + "referral_bonus": int(os.getenv("CREDITS_REFERRAL_BONUS", "0")), + "register_bonus": int(os.getenv("CREDITS_REGISTER_BONUS", "0")), + }, } - }) + ) except Exception as e: logger.error(f"get_my_referrals failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/notification-settings', methods=['GET']) +@user_bp.route("/notification-settings", methods=["GET"]) @login_required def get_notification_settings(): """ Get current user's notification settings. - + Returns: notification_settings: { default_channels: ['browser', 'telegram', ...], @@ -584,52 +567,49 @@ def get_notification_settings(): """ try: import json + from app.utils.db import get_db_connection - - user_id = getattr(g, 'user_id', None) + + user_id = getattr(g, "user_id", None) if not user_id: - return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 - + return jsonify({"code": 0, "msg": "Not authenticated", "data": None}), 401 + with get_db_connection() as db: cur = db.cursor() cur.execute("SELECT notification_settings, email FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() - + if not row: - return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 - + return jsonify({"code": 0, "msg": "User not found", "data": None}), 404 + # Parse notification_settings JSON - settings_str = row.get('notification_settings') or '' + settings_str = row.get("notification_settings") or "" settings = {} if settings_str: try: settings = json.loads(settings_str) except Exception: settings = {} - + # Default values - if 'default_channels' not in settings: - settings['default_channels'] = ['browser'] - if 'email' not in settings: - settings['email'] = row.get('email') or '' - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': settings - }) + if "default_channels" not in settings: + settings["default_channels"] = ["browser"] + if "email" not in settings: + settings["email"] = row.get("email") or "" + + return jsonify({"code": 1, "msg": "success", "data": settings}) except Exception as e: logger.error(f"get_notification_settings failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/notification-settings', methods=['PUT']) +@user_bp.route("/notification-settings", methods=["PUT"]) @login_required def update_notification_settings(): """ Update current user's notification settings. - + Request body: default_channels: list of str (optional, e.g. ['browser', 'telegram']) telegram_bot_token: str (optional, user's own Telegram bot token) @@ -639,60 +619,57 @@ def update_notification_settings(): """ try: import json + from app.utils.db import get_db_connection - - user_id = getattr(g, 'user_id', None) + + user_id = getattr(g, "user_id", None) if not user_id: - return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 - + return jsonify({"code": 0, "msg": "Not authenticated", "data": None}), 401 + data = request.get_json() or {} - + # Validate channels - valid_channels = ['browser', 'email', 'telegram', 'discord', 'webhook', 'phone'] - default_channels = data.get('default_channels', []) + valid_channels = ["browser", "email", "telegram", "discord", "webhook", "phone"] + default_channels = data.get("default_channels", []) if not isinstance(default_channels, list): - default_channels = ['browser'] + default_channels = ["browser"] default_channels = [c for c in default_channels if c in valid_channels] if not default_channels: - default_channels = ['browser'] - + default_channels = ["browser"] + # Build settings object settings = { - 'default_channels': default_channels, - 'telegram_bot_token': str(data.get('telegram_bot_token') or '').strip(), - 'telegram_chat_id': str(data.get('telegram_chat_id') or '').strip(), - 'email': str(data.get('email') or '').strip(), - 'discord_webhook': str(data.get('discord_webhook') or '').strip(), - 'webhook_url': str(data.get('webhook_url') or '').strip(), - 'webhook_token': str(data.get('webhook_token') or '').strip(), - 'phone': str(data.get('phone') or '').strip(), + "default_channels": default_channels, + "telegram_bot_token": str(data.get("telegram_bot_token") or "").strip(), + "telegram_chat_id": str(data.get("telegram_chat_id") or "").strip(), + "email": str(data.get("email") or "").strip(), + "discord_webhook": str(data.get("discord_webhook") or "").strip(), + "webhook_url": str(data.get("webhook_url") or "").strip(), + "webhook_token": str(data.get("webhook_token") or "").strip(), + "phone": str(data.get("phone") or "").strip(), } - + # Remove empty values (but keep default_channels and telegram_bot_token even if partially filled) - settings = {k: v for k, v in settings.items() if v or k == 'default_channels'} - + settings = {k: v for k, v in settings.items() if v or k == "default_channels"} + settings_json = json.dumps(settings, ensure_ascii=False) - + with get_db_connection() as db: cur = db.cursor() cur.execute( "UPDATE qd_users SET notification_settings = ?, updated_at = NOW() WHERE id = ?", - (settings_json, user_id) + (settings_json, user_id), ) db.commit() cur.close() - - return jsonify({ - 'code': 1, - 'msg': 'Notification settings updated', - 'data': settings - }) + + return jsonify({"code": 1, "msg": "Notification settings updated", "data": settings}) except Exception as e: logger.error(f"update_notification_settings failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/notification-settings/test', methods=['POST']) +@user_bp.route("/notification-settings/test", methods=["POST"]) @login_required def test_notification_settings(): """ @@ -701,12 +678,13 @@ def test_notification_settings(): """ try: import json + from app.services.signal_notifier import SignalNotifier from app.utils.db import get_db_connection - user_id = getattr(g, 'user_id', None) + user_id = getattr(g, "user_id", None) if not user_id: - return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 + return jsonify({"code": 0, "msg": "Not authenticated", "data": None}), 401 with get_db_connection() as db: cur = db.cursor() @@ -715,10 +693,10 @@ def test_notification_settings(): cur.close() if not row: - return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 + return jsonify({"code": 0, "msg": "User not found", "data": None}), 404 - settings_str = row.get('notification_settings') or '' - account_email = (row.get('email') or '').strip() + settings_str = row.get("notification_settings") or "" + account_email = (row.get("email") or "").strip() settings = {} if settings_str: try: @@ -726,23 +704,23 @@ def test_notification_settings(): except Exception: settings = {} - channels = settings.get('default_channels') or ['browser'] + channels = settings.get("default_channels") or ["browser"] if not isinstance(channels, list) or not channels: - channels = ['browser'] + channels = ["browser"] - notify_email = (settings.get('email') or '').strip() or account_email + notify_email = (settings.get("email") or "").strip() or account_email targets = { - 'telegram': (settings.get('telegram_chat_id') or '').strip(), - 'telegram_bot_token': (settings.get('telegram_bot_token') or '').strip(), - 'email': notify_email, - 'phone': (settings.get('phone') or '').strip(), - 'discord': (settings.get('discord_webhook') or '').strip(), - 'webhook': (settings.get('webhook_url') or '').strip(), - 'webhook_token': (settings.get('webhook_token') or '').strip(), + "telegram": (settings.get("telegram_chat_id") or "").strip(), + "telegram_bot_token": (settings.get("telegram_bot_token") or "").strip(), + "email": notify_email, + "phone": (settings.get("phone") or "").strip(), + "discord": (settings.get("discord_webhook") or "").strip(), + "webhook": (settings.get("webhook_url") or "").strip(), + "webhook_token": (settings.get("webhook_token") or "").strip(), } - accept = (request.headers.get('Accept-Language') or '') + ' ' + (request.headers.get('X-Locale') or '') - language = 'zh-CN' if 'zh' in accept.lower() else 'en-US' + accept = (request.headers.get("Accept-Language") or "") + " " + (request.headers.get("X-Locale") or "") + language = "zh-CN" if "zh" in accept.lower() else "en-US" notifier = SignalNotifier() results = notifier.send_profile_test_notifications( @@ -752,103 +730,107 @@ def test_notification_settings(): language=language, ) - any_ok = any((v or {}).get('ok') for v in results.values()) - failed = [k for k, v in results.items() if not (v or {}).get('ok')] + any_ok = any((v or {}).get("ok") for v in results.values()) + failed = [k for k, v in results.items() if not (v or {}).get("ok")] if failed: - err_detail = {k: (results.get(k) or {}).get('error', '') for k in failed} - logger.warning("notification_settings test: user_id=%s failed_channels=%s errors=%s", user_id, failed, err_detail) + err_detail = {k: (results.get(k) or {}).get("error", "") for k in failed} + logger.warning( + "notification_settings test: user_id=%s failed_channels=%s errors=%s", user_id, failed, err_detail + ) if not any_ok: - detail = '; '.join(f"{k}: {(results[k] or {}).get('error', '')}" for k in failed) or 'all channels failed' - return jsonify({'code': 0, 'msg': detail, 'data': {'results': results}}) + detail = "; ".join(f"{k}: {(results[k] or {}).get('error', '')}" for k in failed) or "all channels failed" + return jsonify({"code": 0, "msg": detail, "data": {"results": results}}) - msg = 'Test notification sent' + msg = "Test notification sent" if failed: msg = f"Sent OK; failed: {', '.join(failed)}" - return jsonify({'code': 1, 'msg': msg, 'data': {'results': results}}) + return jsonify({"code": 1, "msg": msg, "data": {"results": results}}) except Exception as e: logger.error(f"test_notification_settings failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 -@user_bp.route('/change-password', methods=['POST']) +@user_bp.route("/change-password", methods=["POST"]) @login_required def change_password(): """ Change current user's password. - + Request body: old_password: str (required) new_password: str (required) """ try: - user_id = getattr(g, 'user_id', None) + user_id = getattr(g, "user_id", None) if not user_id: - return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 - + return jsonify({"code": 0, "msg": "Not authenticated", "data": None}), 401 + data = request.get_json() or {} - old_password = data.get('old_password', '') - new_password = data.get('new_password', '') - + old_password = data.get("old_password", "") + new_password = data.get("new_password", "") + if not new_password: - return jsonify({'code': 0, 'msg': 'New password required', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "New password required", "data": None}), 400 + if len(new_password) < 6: - return jsonify({'code': 0, 'msg': 'New password must be at least 6 characters', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "New password must be at least 6 characters", "data": None}), 400 + # Check if user has a password set user_service = get_user_service() user = user_service.get_user_by_id(user_id) if not user: - return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 - + return jsonify({"code": 0, "msg": "User not found", "data": None}), 404 + # Get password_hash to check if user has no password from app.utils.db import get_db_connection + with get_db_connection() as db: cur = db.cursor() cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() - - password_hash = row.get('password_hash', '') if row else '' - has_password = password_hash and password_hash.strip() != '' - + + password_hash = row.get("password_hash", "") if row else "" + has_password = password_hash and password_hash.strip() != "" + # If user has no password, allow setting password without old password if not has_password: if not old_password: # No old password required for users without password success = user_service.reset_password(user_id, new_password) if success: - return jsonify({'code': 1, 'msg': 'Password set successfully', 'data': None}) + return jsonify({"code": 1, "msg": "Password set successfully", "data": None}) else: - return jsonify({'code': 0, 'msg': 'Failed to set password', 'data': None}), 500 + return jsonify({"code": 0, "msg": "Failed to set password", "data": None}), 500 else: # If old_password is provided but user has no password, ignore it success = user_service.reset_password(user_id, new_password) if success: - return jsonify({'code': 1, 'msg': 'Password set successfully', 'data': None}) + return jsonify({"code": 1, "msg": "Password set successfully", "data": None}) else: - return jsonify({'code': 0, 'msg': 'Failed to set password', 'data': None}), 500 + return jsonify({"code": 0, "msg": "Failed to set password", "data": None}), 500 else: # User has existing password, require old password verification if not old_password: - return jsonify({'code': 0, 'msg': 'Old password required', 'data': None}), 400 - + return jsonify({"code": 0, "msg": "Old password required", "data": None}), 400 + success = user_service.change_password(user_id, old_password, new_password) - + if success: - return jsonify({'code': 1, 'msg': 'Password changed successfully', 'data': None}) + return jsonify({"code": 1, "msg": "Password changed successfully", "data": None}) else: - return jsonify({'code': 0, 'msg': 'Old password incorrect', 'data': None}), 400 + return jsonify({"code": 0, "msg": "Old password incorrect", "data": None}), 400 except ValueError as e: - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400 + return jsonify({"code": 0, "msg": str(e), "data": None}), 400 except Exception as e: logger.error(f"change_password failed: {e}") - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # ==================== System Overview (Admin) ==================== + def _safe_json_loads(s, default=None): """Safely parse JSON string.""" if not s: @@ -861,7 +843,7 @@ def _safe_json_loads(s, default=None): return default -@user_bp.route('/system-strategies', methods=['GET']) +@user_bp.route("/system-strategies", methods=["GET"]) @login_required @admin_required def get_system_strategies(): @@ -879,41 +861,41 @@ def get_system_strategies(): sort_order: str (optional, asc or desc; default desc when sort_by set) """ try: - page = request.args.get('page', 1, type=int) - page_size = request.args.get('page_size', 20, type=int) - status_filter = request.args.get('status', '', type=str).strip().lower() - execution_filter = request.args.get('execution_mode', '', type=str).strip().lower() - search = request.args.get('search', '', type=str).strip() - sort_by = request.args.get('sort_by', '', type=str).strip().lower() - sort_order = request.args.get('sort_order', 'desc', type=str).strip().lower() - if sort_order not in ('asc', 'desc'): - sort_order = 'desc' + page = request.args.get("page", 1, type=int) + page_size = request.args.get("page_size", 20, type=int) + status_filter = request.args.get("status", "", type=str).strip().lower() + execution_filter = request.args.get("execution_mode", "", type=str).strip().lower() + search = request.args.get("search", "", type=str).strip() + sort_by = request.args.get("sort_by", "", type=str).strip().lower() + sort_order = request.args.get("sort_order", "desc", type=str).strip().lower() + if sort_order not in ("asc", "desc"): + sort_order = "desc" page_size = min(100, max(1, page_size)) offset = (page - 1) * page_size sort_sql_map = { - 'id': 's.id', - 'updated_at': 's.updated_at', - 'created_at': 's.created_at', - 'initial_capital': 's.initial_capital', - 'strategy_name': 's.strategy_name', - 'symbol': 's.symbol', - 'status': 's.status', - 'execution_mode': 's.execution_mode', - 'leverage': 's.leverage', + "id": "s.id", + "updated_at": "s.updated_at", + "created_at": "s.created_at", + "initial_capital": "s.initial_capital", + "strategy_name": "s.strategy_name", + "symbol": "s.symbol", + "status": "s.status", + "execution_mode": "s.execution_mode", + "leverage": "s.leverage", } sort_expr_map = { - 'total_pnl': ( + "total_pnl": ( "(COALESCE((SELECT SUM(unrealized_pnl) FROM qd_strategy_positions p WHERE p.strategy_id = s.id), 0)" " + COALESCE((SELECT SUM(profit) FROM qd_strategy_trades t WHERE t.strategy_id = s.id), 0))" ), - 'trade_count': '(SELECT COUNT(*) FROM qd_strategy_trades t WHERE t.strategy_id = s.id)', - 'position_count': '(SELECT COUNT(*) FROM qd_strategy_positions p WHERE p.strategy_id = s.id)', - 'total_equity': ( - 'COALESCE((SELECT SUM(equity) FROM qd_strategy_positions p WHERE p.strategy_id = s.id), 0)' + "trade_count": "(SELECT COUNT(*) FROM qd_strategy_trades t WHERE t.strategy_id = s.id)", + "position_count": "(SELECT COUNT(*) FROM qd_strategy_positions p WHERE p.strategy_id = s.id)", + "total_equity": ( + "COALESCE((SELECT SUM(equity) FROM qd_strategy_positions p WHERE p.strategy_id = s.id), 0)" ), } - direction = 'ASC' if sort_order == 'asc' else 'DESC' + direction = "ASC" if sort_order == "asc" else "DESC" with get_db_connection() as db: cur = db.cursor() @@ -922,11 +904,11 @@ def get_system_strategies(): conditions = [] params = [] - if status_filter and status_filter != 'all': + if status_filter and status_filter != "all": conditions.append("s.status = ?") params.append(status_filter) - if execution_filter in ('live', 'signal'): + if execution_filter in ("live", "signal"): conditions.append("s.execution_mode = ?") params.append(execution_filter) @@ -956,11 +938,11 @@ def get_system_strategies(): {where_clause} """ cur.execute(count_sql, tuple(params)) - total = cur.fetchone()['cnt'] + total = cur.fetchone()["cnt"] # Get strategies with user info query_sql = f""" - SELECT + SELECT s.id, s.user_id, s.strategy_name, @@ -991,24 +973,24 @@ def get_system_strategies(): strategies = cur.fetchall() or [] # Collect strategy IDs - strategy_ids = [s['id'] for s in strategies] + strategy_ids = [s["id"] for s in strategies] # Batch load positions for these strategies positions_map = {} if strategy_ids: - placeholders = ','.join(['?'] * len(strategy_ids)) + placeholders = ",".join(["?"] * len(strategy_ids)) cur.execute( f""" - SELECT strategy_id, symbol, side, size, entry_price, current_price, + SELECT strategy_id, symbol, side, size, entry_price, current_price, unrealized_pnl, pnl_percent, equity, updated_at FROM qd_strategy_positions WHERE strategy_id IN ({placeholders}) ORDER BY strategy_id, updated_at DESC """, - tuple(strategy_ids) + tuple(strategy_ids), ) - for pos in (cur.fetchall() or []): - sid = pos['strategy_id'] + for pos in cur.fetchall() or []: + sid = pos["strategy_id"] if sid not in positions_map: positions_map[sid] = [] positions_map[sid].append(dict(pos)) @@ -1016,22 +998,22 @@ def get_system_strategies(): # Batch load recent trade stats (realized PnL per strategy) trade_stats_map = {} if strategy_ids: - placeholders = ','.join(['?'] * len(strategy_ids)) + placeholders = ",".join(["?"] * len(strategy_ids)) cur.execute( f""" - SELECT strategy_id, - COUNT(*) as trade_count, + SELECT strategy_id, + COUNT(*) as trade_count, COALESCE(SUM(profit), 0) as total_realized_pnl FROM qd_strategy_trades WHERE strategy_id IN ({placeholders}) GROUP BY strategy_id """, - tuple(strategy_ids) + tuple(strategy_ids), ) - for row in (cur.fetchall() or []): - trade_stats_map[row['strategy_id']] = { - 'trade_count': row['trade_count'], - 'total_realized_pnl': float(row['total_realized_pnl'] or 0) + for row in cur.fetchall() or []: + trade_stats_map[row["strategy_id"]] = { + "trade_count": row["trade_count"], + "total_realized_pnl": float(row["total_realized_pnl"] or 0), } cur.close() @@ -1039,88 +1021,90 @@ def get_system_strategies(): # Build response items = [] for s in strategies: - sid = s['id'] - indicator_config = _safe_json_loads(s.get('indicator_config'), {}) - trading_config = _safe_json_loads(s.get('trading_config'), {}) - exchange_config = _safe_json_loads(s.get('exchange_config'), {}) + sid = s["id"] + indicator_config = _safe_json_loads(s.get("indicator_config"), {}) + trading_config = _safe_json_loads(s.get("trading_config"), {}) + exchange_config = _safe_json_loads(s.get("exchange_config"), {}) # Extract indicator name - indicator_name = '' + indicator_name = "" if isinstance(indicator_config, dict): - indicator_name = indicator_config.get('indicator_name') or indicator_config.get('name') or '' + indicator_name = indicator_config.get("indicator_name") or indicator_config.get("name") or "" # Extract exchange name - exchange_name = '' + exchange_name = "" if isinstance(exchange_config, dict): - exchange_name = exchange_config.get('exchange_id') or exchange_config.get('exchange') or '' + exchange_name = exchange_config.get("exchange_id") or exchange_config.get("exchange") or "" # Positions data positions = positions_map.get(sid, []) - total_unrealized_pnl = sum(float(p.get('unrealized_pnl') or 0) for p in positions) - total_equity = sum(float(p.get('equity') or 0) for p in positions) + total_unrealized_pnl = sum(float(p.get("unrealized_pnl") or 0) for p in positions) + total_equity = sum(float(p.get("equity") or 0) for p in positions) position_count = len(positions) # Trade stats - trade_stats = trade_stats_map.get(sid, {'trade_count': 0, 'total_realized_pnl': 0}) - total_realized_pnl = trade_stats['total_realized_pnl'] - trade_count = trade_stats['trade_count'] + trade_stats = trade_stats_map.get(sid, {"trade_count": 0, "total_realized_pnl": 0}) + total_realized_pnl = trade_stats["total_realized_pnl"] + trade_count = trade_stats["trade_count"] # Calculate total PnL and ROI - initial_capital = float(s.get('initial_capital') or 0) + initial_capital = float(s.get("initial_capital") or 0) total_pnl = total_unrealized_pnl + total_realized_pnl roi = (total_pnl / initial_capital * 100) if initial_capital > 0 else 0 # Cross-sectional info - cs_type = '' + cs_type = "" symbol_list = [] if isinstance(trading_config, dict): - cs_type = trading_config.get('cs_strategy_type') or 'single' - symbol_list = trading_config.get('symbol_list') or [] + cs_type = trading_config.get("cs_strategy_type") or "single" + symbol_list = trading_config.get("symbol_list") or [] # Format timestamps - created_at = s.get('created_at') - updated_at = s.get('updated_at') - if hasattr(created_at, 'isoformat'): + created_at = s.get("created_at") + updated_at = s.get("updated_at") + if hasattr(created_at, "isoformat"): created_at = created_at.isoformat() - if hasattr(updated_at, 'isoformat'): + if hasattr(updated_at, "isoformat"): updated_at = updated_at.isoformat() # Format position timestamps for p in positions: - if hasattr(p.get('updated_at'), 'isoformat'): - p['updated_at'] = p['updated_at'].isoformat() + if hasattr(p.get("updated_at"), "isoformat"): + p["updated_at"] = p["updated_at"].isoformat() - items.append({ - 'id': sid, - 'user_id': s['user_id'], - 'username': s.get('username') or '', - 'nickname': s.get('nickname') or '', - 'strategy_name': s.get('strategy_name') or '', - 'strategy_type': s.get('strategy_type') or '', - 'cs_strategy_type': cs_type, - 'market_category': s.get('market_category') or '', - 'execution_mode': s.get('execution_mode') or '', - 'status': s.get('status') or 'stopped', - 'symbol': s.get('symbol') or '', - 'symbol_list': symbol_list, - 'timeframe': s.get('timeframe') or '', - 'initial_capital': initial_capital, - 'leverage': int(s.get('leverage') or 1), - 'market_type': s.get('market_type') or '', - 'indicator_name': indicator_name, - 'exchange_name': exchange_name, - 'decide_interval': s.get('decide_interval') or 300, - 'position_count': position_count, - 'total_unrealized_pnl': round(total_unrealized_pnl, 4), - 'total_realized_pnl': round(total_realized_pnl, 4), - 'total_pnl': round(total_pnl, 4), - 'total_equity': round(total_equity, 4), - 'roi': round(roi, 2), - 'trade_count': trade_count, - 'positions': positions, - 'created_at': created_at, - 'updated_at': updated_at - }) + items.append( + { + "id": sid, + "user_id": s["user_id"], + "username": s.get("username") or "", + "nickname": s.get("nickname") or "", + "strategy_name": s.get("strategy_name") or "", + "strategy_type": s.get("strategy_type") or "", + "cs_strategy_type": cs_type, + "market_category": s.get("market_category") or "", + "execution_mode": s.get("execution_mode") or "", + "status": s.get("status") or "stopped", + "symbol": s.get("symbol") or "", + "symbol_list": symbol_list, + "timeframe": s.get("timeframe") or "", + "initial_capital": initial_capital, + "leverage": int(s.get("leverage") or 1), + "market_type": s.get("market_type") or "", + "indicator_name": indicator_name, + "exchange_name": exchange_name, + "decide_interval": s.get("decide_interval") or 300, + "position_count": position_count, + "total_unrealized_pnl": round(total_unrealized_pnl, 4), + "total_realized_pnl": round(total_realized_pnl, 4), + "total_pnl": round(total_pnl, 4), + "total_equity": round(total_equity, 4), + "roi": round(roi, 2), + "trade_count": trade_count, + "positions": positions, + "created_at": created_at, + "updated_at": updated_at, + } + ) # Compute summary stats from all matched strategies (not just current page items). with get_db_connection() as db: @@ -1172,47 +1156,53 @@ def get_system_strategies(): realized_row = cur.fetchone() or {} cur.close() - total_capital = float(agg_row.get('total_capital') or 0) - total_running = int(agg_row.get('running_strategies') or 0) - total_system_pnl = float(unreal_row.get('total_unrealized') or 0) + float(realized_row.get('total_realized') or 0) - live_pnl = float(unreal_row.get('live_unrealized') or 0) + float(realized_row.get('live_realized') or 0) - signal_pnl = float(unreal_row.get('signal_unrealized') or 0) + float(realized_row.get('signal_realized') or 0) + total_capital = float(agg_row.get("total_capital") or 0) + total_running = int(agg_row.get("running_strategies") or 0) + total_system_pnl = float(unreal_row.get("total_unrealized") or 0) + float( + realized_row.get("total_realized") or 0 + ) + live_pnl = float(unreal_row.get("live_unrealized") or 0) + float(realized_row.get("live_realized") or 0) + signal_pnl = float(unreal_row.get("signal_unrealized") or 0) + float(realized_row.get("signal_realized") or 0) - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'summary': { - 'total_strategies': int(agg_row.get('total_strategies') or total), - 'running_strategies': total_running, - 'total_capital': round(total_capital, 2), - 'total_pnl': round(total_system_pnl, 4), - 'total_roi': round((total_system_pnl / total_capital * 100) if total_capital > 0 else 0, 2), - 'live_strategies': int(agg_row.get('live_strategies') or 0), - 'signal_strategies': int(agg_row.get('signal_strategies') or 0), - 'running_live_strategies': int(agg_row.get('running_live_strategies') or 0), - 'running_signal_strategies': int(agg_row.get('running_signal_strategies') or 0), - 'live_capital': round(float(agg_row.get('live_capital') or 0), 2), - 'signal_capital': round(float(agg_row.get('signal_capital') or 0), 2), - 'live_pnl': round(live_pnl, 4), - 'signal_pnl': round(signal_pnl, 4) - } + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "summary": { + "total_strategies": int(agg_row.get("total_strategies") or total), + "running_strategies": total_running, + "total_capital": round(total_capital, 2), + "total_pnl": round(total_system_pnl, 4), + "total_roi": round((total_system_pnl / total_capital * 100) if total_capital > 0 else 0, 2), + "live_strategies": int(agg_row.get("live_strategies") or 0), + "signal_strategies": int(agg_row.get("signal_strategies") or 0), + "running_live_strategies": int(agg_row.get("running_live_strategies") or 0), + "running_signal_strategies": int(agg_row.get("running_signal_strategies") or 0), + "live_capital": round(float(agg_row.get("live_capital") or 0), 2), + "signal_capital": round(float(agg_row.get("signal_capital") or 0), 2), + "live_pnl": round(live_pnl, 4), + "signal_pnl": round(signal_pnl, 4), + }, + }, } - }) + ) except Exception as e: logger.error(f"get_system_strategies failed: {e}") import traceback + logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # ==================== Admin Orders ==================== -@user_bp.route('/admin-orders', methods=['GET']) + +@user_bp.route("/admin-orders", methods=["GET"]) @login_required @admin_required def get_admin_orders(): @@ -1227,10 +1217,10 @@ def get_admin_orders(): search: str (optional, search by username/email) """ try: - page = request.args.get('page', 1, type=int) - page_size = request.args.get('page_size', 20, type=int) - status_filter = request.args.get('status', '', type=str).strip().lower() - search = request.args.get('search', '', type=str).strip() + page = request.args.get("page", 1, type=int) + page_size = request.args.get("page_size", 20, type=int) + status_filter = request.args.get("status", "", type=str).strip().lower() + search = request.args.get("search", "", type=str).strip() page_size = min(100, max(1, page_size)) offset = (page - 1) * page_size @@ -1241,7 +1231,7 @@ def get_admin_orders(): usdt_conditions = [] usdt_params = [] - if status_filter and status_filter != 'all': + if status_filter and status_filter != "all": usdt_conditions.append("o.status = ?") usdt_params.append(status_filter) @@ -1257,15 +1247,15 @@ def get_admin_orders(): # Count cur.execute( f"SELECT COUNT(*) as cnt FROM qd_usdt_orders o LEFT JOIN qd_users u ON u.id = o.user_id {usdt_where}", - tuple(usdt_params) + tuple(usdt_params), ) - usdt_total = cur.fetchone()['cnt'] + usdt_total = cur.fetchone()["cnt"] # --- Membership Orders (mock) --- mock_conditions = [] mock_params = [] - if status_filter and status_filter != 'all': + if status_filter and status_filter != "all": mock_conditions.append("m.status = ?") mock_params.append(status_filter) @@ -1280,9 +1270,9 @@ def get_admin_orders(): cur.execute( f"SELECT COUNT(*) as cnt FROM qd_membership_orders m LEFT JOIN qd_users u ON u.id = m.user_id {mock_where}", - tuple(mock_params) + tuple(mock_params), ) - mock_total = cur.fetchone()['cnt'] + mock_total = cur.fetchone()["cnt"] total = usdt_total + mock_total @@ -1345,7 +1335,7 @@ def get_admin_orders(): # Summary stats cur.execute( - f"""SELECT + """SELECT COUNT(*) AS total_orders, COALESCE(SUM(CASE WHEN status IN ('paid','confirmed') THEN 1 ELSE 0 END), 0) AS paid_orders, COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_orders, @@ -1359,66 +1349,72 @@ def get_admin_orders(): items = [] for row in rows: - created_at = row.get('created_at') - paid_at = row.get('paid_at') - confirmed_at = row.get('confirmed_at') - expires_at = row.get('expires_at') - if hasattr(created_at, 'isoformat'): + created_at = row.get("created_at") + paid_at = row.get("paid_at") + confirmed_at = row.get("confirmed_at") + expires_at = row.get("expires_at") + if hasattr(created_at, "isoformat"): created_at = created_at.isoformat() - if hasattr(paid_at, 'isoformat'): + if hasattr(paid_at, "isoformat"): paid_at = paid_at.isoformat() - if hasattr(confirmed_at, 'isoformat'): + if hasattr(confirmed_at, "isoformat"): confirmed_at = confirmed_at.isoformat() - if hasattr(expires_at, 'isoformat'): + if hasattr(expires_at, "isoformat"): expires_at = expires_at.isoformat() - items.append({ - 'id': row['id'], - 'order_type': row.get('order_type') or '', - 'user_id': row.get('user_id'), - 'username': row.get('username') or '', - 'nickname': row.get('nickname') or '', - 'user_email': row.get('user_email') or '', - 'plan': row.get('plan') or '', - 'amount': float(row.get('amount') or 0), - 'currency': row.get('currency') or '', - 'chain': row.get('chain') or '', - 'address': row.get('address') or '', - 'tx_hash': row.get('tx_hash') or '', - 'status': row.get('status') or '', - 'created_at': created_at, - 'paid_at': paid_at, - 'confirmed_at': confirmed_at, - 'expires_at': expires_at - }) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'summary': { - 'total_orders': int(summary_row.get('total_orders') or 0), - 'paid_orders': int(summary_row.get('paid_orders') or 0), - 'pending_orders': int(summary_row.get('pending_orders') or 0), - 'failed_orders': int(summary_row.get('failed_orders') or 0), - 'total_revenue': round(float(summary_row.get('total_revenue') or 0), 2) + items.append( + { + "id": row["id"], + "order_type": row.get("order_type") or "", + "user_id": row.get("user_id"), + "username": row.get("username") or "", + "nickname": row.get("nickname") or "", + "user_email": row.get("user_email") or "", + "plan": row.get("plan") or "", + "amount": float(row.get("amount") or 0), + "currency": row.get("currency") or "", + "chain": row.get("chain") or "", + "address": row.get("address") or "", + "tx_hash": row.get("tx_hash") or "", + "status": row.get("status") or "", + "created_at": created_at, + "paid_at": paid_at, + "confirmed_at": confirmed_at, + "expires_at": expires_at, } + ) + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "summary": { + "total_orders": int(summary_row.get("total_orders") or 0), + "paid_orders": int(summary_row.get("paid_orders") or 0), + "pending_orders": int(summary_row.get("pending_orders") or 0), + "failed_orders": int(summary_row.get("failed_orders") or 0), + "total_revenue": round(float(summary_row.get("total_revenue") or 0), 2), + }, + }, } - }) + ) except Exception as e: logger.error(f"get_admin_orders failed: {e}") import traceback + logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 # ==================== Admin AI Analysis Stats ==================== -@user_bp.route('/admin-ai-stats', methods=['GET']) + +@user_bp.route("/admin-ai-stats", methods=["GET"]) @login_required @admin_required def get_admin_ai_stats(): @@ -1432,9 +1428,9 @@ def get_admin_ai_stats(): search: str (optional, search by username) """ try: - page = request.args.get('page', 1, type=int) - page_size = request.args.get('page_size', 20, type=int) - search = request.args.get('search', '', type=str).strip() + page = request.args.get("page", 1, type=int) + page_size = request.args.get("page_size", 20, type=int) + search = request.args.get("search", "", type=str).strip() page_size = min(100, max(1, page_size)) offset = (page - 1) * page_size @@ -1488,7 +1484,7 @@ def get_admin_ai_stats(): """ cur.execute(count_sql, tuple(user_params)) count_result = cur.fetchone() - user_total = count_result['cnt'] if count_result else 0 + user_total = count_result["cnt"] if count_result else 0 # Get per-user aggregated stats # Important: Filter by user search criteria AFTER grouping, but we need to apply it in WHERE @@ -1515,11 +1511,11 @@ def get_admin_ai_stats(): user_rows = cur.fetchall() or [] # Get per-user analysis_memory stats (correct/helpful counts) - user_ids = [r['user_id'] for r in user_rows if r.get('user_id')] + user_ids = [r["user_id"] for r in user_rows if r.get("user_id")] memory_stats_map = {} if user_ids: try: - placeholders = ','.join(['?'] * len(user_ids)) + placeholders = ",".join(["?"] * len(user_ids)) cur.execute( f""" SELECT @@ -1533,15 +1529,15 @@ def get_admin_ai_stats(): WHERE user_id IN ({placeholders}) GROUP BY user_id """, - tuple(user_ids) + tuple(user_ids), ) - for row in (cur.fetchall() or []): - memory_stats_map[row['user_id']] = { - 'memory_count': row['memory_count'], - 'correct': row['correct'], - 'incorrect': row['incorrect'], - 'helpful': row['helpful'], - 'not_helpful': row['not_helpful'] + for row in cur.fetchall() or []: + memory_stats_map[row["user_id"]] = { + "memory_count": row["memory_count"], + "correct": row["correct"], + "incorrect": row["incorrect"], + "helpful": row["helpful"], + "not_helpful": row["not_helpful"], } except Exception as mem_err: logger.warning(f"qd_analysis_memory per-user query failed: {mem_err}") @@ -1579,108 +1575,115 @@ def get_admin_ai_stats(): # Build per-user items user_items = [] for row in user_rows: - uid = row.get('user_id') + uid = row.get("user_id") if not uid: # Skip rows with NULL user_id continue - + ms = memory_stats_map.get(uid, {}) - last_at = row.get('last_analysis_at') - first_at = row.get('first_analysis_at') - + last_at = row.get("last_analysis_at") + first_at = row.get("first_analysis_at") + # Convert datetime to ISO format string if needed - if last_at and hasattr(last_at, 'isoformat'): + if last_at and hasattr(last_at, "isoformat"): last_at = last_at.isoformat() elif last_at: last_at = str(last_at) else: last_at = None - - if first_at and hasattr(first_at, 'isoformat'): + + if first_at and hasattr(first_at, "isoformat"): first_at = first_at.isoformat() elif first_at: first_at = str(first_at) else: first_at = None - user_items.append({ - 'user_id': int(uid), - 'username': str(row.get('username') or ''), - 'nickname': str(row.get('nickname') or ''), - 'email': str(row.get('email') or ''), - 'analysis_count': int(row.get('analysis_count') or 0), - 'symbol_count': int(row.get('symbol_count') or 0), - 'market_count': int(row.get('market_count') or 0), - 'correct': int(ms.get('correct', 0)), - 'incorrect': int(ms.get('incorrect', 0)), - 'helpful': int(ms.get('helpful', 0)), - 'not_helpful': int(ms.get('not_helpful', 0)), - 'last_analysis_at': last_at, - 'first_analysis_at': first_at - }) + user_items.append( + { + "user_id": int(uid), + "username": str(row.get("username") or ""), + "nickname": str(row.get("nickname") or ""), + "email": str(row.get("email") or ""), + "analysis_count": int(row.get("analysis_count") or 0), + "symbol_count": int(row.get("symbol_count") or 0), + "market_count": int(row.get("market_count") or 0), + "correct": int(ms.get("correct", 0)), + "incorrect": int(ms.get("incorrect", 0)), + "helpful": int(ms.get("helpful", 0)), + "not_helpful": int(ms.get("not_helpful", 0)), + "last_analysis_at": last_at, + "first_analysis_at": first_at, + } + ) # Build recent records recent_items = [] for row in recent_rows: - user_id = row.get('user_id') + user_id = row.get("user_id") if not user_id: # Skip rows with NULL user_id continue - - created_at = row.get('created_at') - completed_at = row.get('completed_at') - + + created_at = row.get("created_at") + completed_at = row.get("completed_at") + # Convert datetime to ISO format string if needed - if created_at and hasattr(created_at, 'isoformat'): + if created_at and hasattr(created_at, "isoformat"): created_at = created_at.isoformat() elif created_at: created_at = str(created_at) else: created_at = None - - if completed_at and hasattr(completed_at, 'isoformat'): + + if completed_at and hasattr(completed_at, "isoformat"): completed_at = completed_at.isoformat() elif completed_at: completed_at = str(completed_at) else: completed_at = None - recent_items.append({ - 'id': int(row.get('id') or 0), - 'user_id': int(user_id), - 'username': str(row.get('username') or ''), - 'nickname': str(row.get('nickname') or ''), - 'email': str(row.get('email') or ''), - 'market': str(row.get('market') or ''), - 'symbol': str(row.get('symbol') or ''), - 'model': str(row.get('model') or ''), - 'status': str(row.get('status') or ''), - 'created_at': created_at, - 'completed_at': completed_at - }) - - return jsonify({ - 'code': 1, - 'msg': 'success', - 'data': { - 'user_stats': user_items, - 'user_total': user_total, - 'page': page, - 'page_size': page_size, - 'recent': recent_items, - 'summary': { - 'total_analyses': int(task_summary.get('total_tasks') or 0), - 'unique_users': int(task_summary.get('unique_users') or 0), - 'unique_symbols': int(task_summary.get('unique_symbols') or 0), - 'unique_markets': int(task_summary.get('unique_markets') or 0), - 'total_memory': int(memory_summary.get('total_memory') or 0), - 'correct_count': int(memory_summary.get('correct_count') or 0), - 'incorrect_count': int(memory_summary.get('incorrect_count') or 0), - 'helpful_count': int(memory_summary.get('helpful_count') or 0), - 'not_helpful_count': int(memory_summary.get('not_helpful_count') or 0) + recent_items.append( + { + "id": int(row.get("id") or 0), + "user_id": int(user_id), + "username": str(row.get("username") or ""), + "nickname": str(row.get("nickname") or ""), + "email": str(row.get("email") or ""), + "market": str(row.get("market") or ""), + "symbol": str(row.get("symbol") or ""), + "model": str(row.get("model") or ""), + "status": str(row.get("status") or ""), + "created_at": created_at, + "completed_at": completed_at, } + ) + + return jsonify( + { + "code": 1, + "msg": "success", + "data": { + "user_stats": user_items, + "user_total": user_total, + "page": page, + "page_size": page_size, + "recent": recent_items, + "summary": { + "total_analyses": int(task_summary.get("total_tasks") or 0), + "unique_users": int(task_summary.get("unique_users") or 0), + "unique_symbols": int(task_summary.get("unique_symbols") or 0), + "unique_markets": int(task_summary.get("unique_markets") or 0), + "total_memory": int(memory_summary.get("total_memory") or 0), + "correct_count": int(memory_summary.get("correct_count") or 0), + "incorrect_count": int(memory_summary.get("incorrect_count") or 0), + "helpful_count": int(memory_summary.get("helpful_count") or 0), + "not_helpful_count": int(memory_summary.get("not_helpful_count") or 0), + }, + }, } - }) + ) except Exception as e: logger.error(f"get_admin_ai_stats failed: {e}") import traceback + logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 \ No newline at end of file + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 diff --git a/backend_api_python/app/services/__init__.py b/backend_api_python/app/services/__init__.py index 316d1b0..0831bb3 100644 --- a/backend_api_python/app/services/__init__.py +++ b/backend_api_python/app/services/__init__.py @@ -1,10 +1,10 @@ """ business service layer """ -from app.services.kline import KlineService + from app.services.backtest import BacktestService -from app.services.strategy_compiler import StrategyCompiler from app.services.fast_analysis import FastAnalysisService +from app.services.kline import KlineService +from app.services.strategy_compiler import StrategyCompiler -__all__ = ['KlineService', 'BacktestService', 'StrategyCompiler', 'FastAnalysisService'] - +__all__ = ["KlineService", "BacktestService", "StrategyCompiler", "FastAnalysisService"] diff --git a/backend_api_python/app/services/ai_calibration.py b/backend_api_python/app/services/ai_calibration.py index 084b437..82b06fb 100644 --- a/backend_api_python/app/services/ai_calibration.py +++ b/backend_api_python/app/services/ai_calibration.py @@ -23,13 +23,11 @@ from __future__ import annotations import os import time from dataclasses import dataclass -from typing import Dict, Any, List, Optional, Tuple +from typing import Any, Dict, List, Optional +from app.services.analysis_memory import AnalysisMemory, get_analysis_memory from app.utils.db import get_db_connection from app.utils.logger import get_logger -from app.services.analysis_memory import get_analysis_memory, AnalysisMemory -from app.services.market_data_collector import MarketDataCollector - logger = get_logger(__name__) @@ -180,9 +178,7 @@ class AICalibrationService: if validate_before: memory: AnalysisMemory = get_analysis_memory() # Validate anything older than ~7 days (matching your existing approx rules). - validated_stats = memory.validate_unvalidated_older_than( - min_age_days=7, limit=300 - ) + validated_stats = memory.validate_unvalidated_older_than(min_age_days=7, limit=300) validated_count = int(validated_stats.get("validated", 0) or 0) except Exception as e: logger.warning(f"pre-validation failed (skipped): {e}", exc_info=True) @@ -269,7 +265,9 @@ class AICalibrationService: buy_threshold = float(best_abs_thr) sell_threshold = float(-best_abs_thr) cfg = self.get_latest(market) - min_consensus_abs_override = float(cfg.get("min_consensus_abs_override") or DEFAULTS["min_consensus_abs_override"]) + min_consensus_abs_override = float( + cfg.get("min_consensus_abs_override") or DEFAULTS["min_consensus_abs_override"] + ) quality_hold_threshold = float(cfg.get("quality_hold_threshold") or DEFAULTS["quality_hold_threshold"]) try: @@ -338,4 +336,3 @@ def start_ai_calibration_worker() -> None: logger.info("[AI Calibration] No calibration update applied (not enough data).") except Exception as e: logger.error(f"start_ai_calibration_worker failed: {e}", exc_info=True) - diff --git a/backend_api_python/app/services/analysis_memory.py b/backend_api_python/app/services/analysis_memory.py index 5ca81b2..987a898 100644 --- a/backend_api_python/app/services/analysis_memory.py +++ b/backend_api_python/app/services/analysis_memory.py @@ -7,14 +7,12 @@ Features: 2. Retrieve similar historical patterns 3. Track decision outcomes for learning """ -import json -import time -import hashlib -from typing import Dict, Any, List, Optional -from datetime import datetime, timedelta -from app.utils.logger import get_logger +import json +from typing import Any, Dict, List, Optional + from app.utils.db import get_db_connection +from app.utils.logger import get_logger logger = get_logger(__name__) @@ -38,16 +36,16 @@ class AnalysisMemory: Simple but effective memory system for AI analysis. Uses PostgreSQL for persistence. """ - + def __init__(self): self._ensure_table() - + def _ensure_table(self): """Create memory table if not exists, and add missing columns if needed.""" try: with get_db_connection() as db: cur = db.cursor() - + # Create table if it does not exist cur.execute(""" CREATE TABLE IF NOT EXISTS qd_analysis_memory ( @@ -79,50 +77,50 @@ class AnalysisMemory: feedback_at TIMESTAMP ); """) - + # Check and add missing columns (for existing tables) cur.execute(""" DO $$ BEGIN -- Add the user_id column if it does not exist IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_analysis_memory' AND column_name = 'user_id' ) THEN ALTER TABLE qd_analysis_memory ADD COLUMN user_id INT; END IF; - + -- Add the raw_result column if it does not exist IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_analysis_memory' AND column_name = 'raw_result' ) THEN ALTER TABLE qd_analysis_memory ADD COLUMN raw_result JSONB; END IF; IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_analysis_memory' AND column_name = 'consensus_score' ) THEN ALTER TABLE qd_analysis_memory ADD COLUMN consensus_score DECIMAL(24, 8); END IF; IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_analysis_memory' AND column_name = 'consensus_abs' ) THEN ALTER TABLE qd_analysis_memory ADD COLUMN consensus_abs DECIMAL(24, 8); END IF; IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_analysis_memory' AND column_name = 'agreement_ratio' ) THEN ALTER TABLE qd_analysis_memory ADD COLUMN agreement_ratio DECIMAL(10, 6); END IF; IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_analysis_memory' AND column_name = 'quality_multiplier' ) THEN ALTER TABLE qd_analysis_memory ADD COLUMN quality_multiplier DECIMAL(10, 6); @@ -150,43 +148,43 @@ class AnalysisMemory: END IF; END $$; """) - - #Create index + + # Create index cur.execute(""" - CREATE INDEX IF NOT EXISTS idx_analysis_memory_symbol + CREATE INDEX IF NOT EXISTS idx_analysis_memory_symbol ON qd_analysis_memory(market, symbol); - - CREATE INDEX IF NOT EXISTS idx_analysis_memory_created + + CREATE INDEX IF NOT EXISTS idx_analysis_memory_created ON qd_analysis_memory(created_at DESC); - - CREATE INDEX IF NOT EXISTS idx_analysis_memory_validated + + CREATE INDEX IF NOT EXISTS idx_analysis_memory_validated ON qd_analysis_memory(validated_at) WHERE validated_at IS NOT NULL; - + CREATE INDEX IF NOT EXISTS idx_analysis_memory_user ON qd_analysis_memory(user_id); """) - + db.commit() cur.close() logger.debug("Analysis memory table ensured successfully") except Exception as e: logger.warning(f"Memory table creation/update skipped: {e}") - + def store(self, analysis_result: Dict[str, Any], user_id: int = None) -> Optional[int]: """ Store an analysis result for future reference. - + Args: analysis_result: Result from FastAnalysisService.analyze() user_id: User ID who created this analysis - + Returns: Memory ID or None if failed """ try: with get_db_connection() as db: cur = db.cursor() - + # Prepare data market = analysis_result.get("market") symbol = analysis_result.get("symbol") @@ -204,8 +202,9 @@ class AnalysisMemory: consensus_abs = consensus.get("consensus_abs") agreement_ratio = consensus.get("agreement_ratio") quality_multiplier = consensus.get("quality_multiplier") - - cur.execute(""" + + cur.execute( + """ INSERT INTO qd_analysis_memory ( user_id, market, symbol, decision, confidence, price_at_analysis, summary, reasons, scores, indicators_snapshot, raw_result, @@ -214,43 +213,59 @@ class AnalysisMemory: ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) RETURNING id - """, ( - user_id, market, symbol, decision, confidence, - price, summary, reasons, scores, indicators, raw, - consensus_score, consensus_abs, agreement_ratio, quality_multiplier, - "completed", "", - )) - + """, + ( + user_id, + market, + symbol, + decision, + confidence, + price, + summary, + reasons, + scores, + indicators, + raw, + consensus_score, + consensus_abs, + agreement_ratio, + quality_multiplier, + "completed", + "", + ), + ) + # Use the lastrowid attribute to get the ID (execute has already processed RETURNING internally) memory_id = cur.lastrowid db.commit() cur.close() - + logger.info(f"Stored analysis memory #{memory_id} for {symbol} by user {user_id}") return memory_id - + except Exception as e: logger.error(f"Failed to store analysis memory: {e}", exc_info=True) return None - + def get_recent(self, market: str, symbol: str, days: int = 7, limit: int = 5) -> List[Dict]: """ Get recent analysis history for a symbol. - + Args: market: Market type symbol: Symbol days: Look back period limit: Max results - + Returns: List of historical analyses """ try: with get_db_connection() as db: cur = db.cursor() - cur.execute(f""" - SELECT + cur.execute( + f""" + SELECT id, decision, confidence, price_at_analysis, summary, reasons, scores, created_at, validated_at, was_correct, actual_return_pct, @@ -260,31 +275,35 @@ class AnalysisMemory: AND created_at > NOW() - INTERVAL '{int(days)} days' ORDER BY created_at DESC LIMIT %s - """, (market, symbol, limit)) - + """, + (market, symbol, limit), + ) + rows = cur.fetchall() or [] cur.close() - + results = [] for row in rows: - results.append({ - "id": row['id'], - "decision": row['decision'], - "confidence": row['confidence'], - "price": float(row['price_at_analysis']) if row['price_at_analysis'] else None, - "summary": row['summary'], - "reasons": _safe_json_parse(row['reasons'], []), - "scores": _safe_json_parse(row['scores'], {}), - "status": row.get('task_status') or 'completed', - "error_message": row.get('task_error') or '', - "created_at": row['created_at'].isoformat() if row['created_at'] else None, - "updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None, - "was_correct": row['was_correct'], - "actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None, - }) - + results.append( + { + "id": row["id"], + "decision": row["decision"], + "confidence": row["confidence"], + "price": float(row["price_at_analysis"]) if row["price_at_analysis"] else None, + "summary": row["summary"], + "reasons": _safe_json_parse(row["reasons"], []), + "scores": _safe_json_parse(row["scores"], {}), + "status": row.get("task_status") or "completed", + "error_message": row.get("task_error") or "", + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None, + "was_correct": row["was_correct"], + "actual_return_pct": float(row["actual_return_pct"]) if row["actual_return_pct"] else None, + } + ) + return results - + except Exception as e: logger.error(f"Failed to get recent memories: {e}") return [] @@ -292,34 +311,35 @@ class AnalysisMemory: def get_all_history(self, user_id: int = None, page: int = 1, page_size: int = 20) -> Dict: """ Get all analysis history with pagination. - + Args: user_id: User ID filter (required to show only user's own history) page: Page number (1-indexed) page_size: Items per page - + Returns: Dict with items list and total count """ try: offset = (page - 1) * page_size - + with get_db_connection() as db: cur = db.cursor() - + # Build WHERE clause based on user_id where_clause = "WHERE user_id = %s" if user_id else "" params_count = (user_id,) if user_id else () - + # Get total count cur.execute(f"SELECT COUNT(*) as cnt FROM qd_analysis_memory {where_clause}", params_count) total_row = cur.fetchone() - total = total_row['cnt'] if total_row else 0 - + total = total_row["cnt"] if total_row else 0 + # Get paginated results params = (user_id, page_size, offset) if user_id else (page_size, offset) - cur.execute(f""" - SELECT + cur.execute( + f""" + SELECT id, market, symbol, decision, confidence, price_at_analysis, summary, reasons, scores, indicators_snapshot, raw_result, created_at, validated_at, was_correct, actual_return_pct, @@ -328,40 +348,39 @@ class AnalysisMemory: {where_clause} ORDER BY created_at DESC LIMIT %s OFFSET %s - """, params) - + """, + params, + ) + rows = cur.fetchall() or [] cur.close() - + items = [] for row in rows: - items.append({ - "id": row['id'], - "market": row['market'], - "symbol": row['symbol'], - "decision": row['decision'], - "confidence": row['confidence'], - "price": float(row['price_at_analysis']) if row['price_at_analysis'] else None, - "summary": row['summary'], - "reasons": _safe_json_parse(row['reasons'], []), - "scores": _safe_json_parse(row['scores'], {}), - "indicators": _safe_json_parse(row['indicators_snapshot'], {}), - "full_result": _safe_json_parse(row['raw_result'], None), - "status": row.get('task_status') or 'completed', - "error_message": row.get('task_error') or '', - "created_at": row['created_at'].isoformat() if row['created_at'] else None, - "updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None, - "was_correct": row['was_correct'], - "actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None, - }) - - return { - "items": items, - "total": total, - "page": page, - "page_size": page_size - } - + items.append( + { + "id": row["id"], + "market": row["market"], + "symbol": row["symbol"], + "decision": row["decision"], + "confidence": row["confidence"], + "price": float(row["price_at_analysis"]) if row["price_at_analysis"] else None, + "summary": row["summary"], + "reasons": _safe_json_parse(row["reasons"], []), + "scores": _safe_json_parse(row["scores"], {}), + "indicators": _safe_json_parse(row["indicators_snapshot"], {}), + "full_result": _safe_json_parse(row["raw_result"], None), + "status": row.get("task_status") or "completed", + "error_message": row.get("task_error") or "", + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None, + "was_correct": row["was_correct"], + "actual_return_pct": float(row["actual_return_pct"]) if row["actual_return_pct"] else None, + } + ) + + return {"items": items, "total": total, "page": page, "page_size": page_size} + except Exception as e: logger.error(f"Failed to get all history: {e}") return {"items": [], "total": 0, "page": page, "page_size": page_size} @@ -369,11 +388,11 @@ class AnalysisMemory: def delete_history(self, memory_id: int, user_id: int = None) -> bool: """ Delete a history record by ID. - + Args: memory_id: The ID of the analysis memory to delete user_id: User ID to ensure user can only delete their own records - + Returns: True if deleted successfully, False otherwise """ @@ -393,8 +412,9 @@ class AnalysisMemory: logger.error(f"Failed to delete memory {memory_id}: {e}") return False - def create_pending_task(self, market: str, symbol: str, language: str, model: str, timeframe: str, - user_id: int = None) -> Optional[int]: + def create_pending_task( + self, market: str, symbol: str, language: str, model: str, timeframe: str, user_id: int = None + ) -> Optional[int]: """Create a processing record in history before long-running analysis starts.""" try: with get_db_connection() as db: @@ -403,15 +423,18 @@ class AnalysisMemory: reasons = json.dumps([]) scores = json.dumps({}) indicators = json.dumps({}) - raw = json.dumps({ - "market": market, - "symbol": symbol, - "language": language, - "model": model, - "timeframe": timeframe, - "task_status": "processing", - }) - cur.execute(""" + raw = json.dumps( + { + "market": market, + "symbol": symbol, + "language": language, + "model": model, + "timeframe": timeframe, + "task_status": "processing", + } + ) + cur.execute( + """ INSERT INTO qd_analysis_memory ( user_id, market, symbol, decision, confidence, summary, reasons, scores, indicators_snapshot, raw_result, @@ -420,11 +443,22 @@ class AnalysisMemory: %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) RETURNING id - """, ( - user_id, market, symbol, "HOLD", 0, - summary, reasons, scores, indicators, raw, - "processing", "", - )) + """, + ( + user_id, + market, + symbol, + "HOLD", + 0, + summary, + reasons, + scores, + indicators, + raw, + "processing", + "", + ), + ) # PostgresCursor.execute() will consume the RETURNING result in advance from fetchone() during INSERT. # So don’t use cur.fetchone() here, just get lastrowid. memory_id = cur.lastrowid @@ -441,7 +475,8 @@ class AnalysisMemory: consensus = result.get("consensus") or {} with get_db_connection() as db: cur = db.cursor() - cur.execute(""" + cur.execute( + """ UPDATE qd_analysis_memory SET decision = %s, confidence = %s, @@ -459,23 +494,25 @@ class AnalysisMemory: task_error = %s, updated_at = NOW() WHERE id = %s - """, ( - result.get("decision"), - result.get("confidence"), - result.get("market_data", {}).get("current_price"), - result.get("summary"), - json.dumps(result.get("reasons", [])), - json.dumps(result.get("scores", {})), - json.dumps(result.get("indicators", {})), - json.dumps(result), - consensus.get("consensus_score"), - consensus.get("consensus_abs"), - consensus.get("agreement_ratio"), - consensus.get("quality_multiplier"), - "completed" if not result.get("error") else "failed", - str(result.get("error") or ""), - int(memory_id), - )) + """, + ( + result.get("decision"), + result.get("confidence"), + result.get("market_data", {}).get("current_price"), + result.get("summary"), + json.dumps(result.get("reasons", [])), + json.dumps(result.get("scores", {})), + json.dumps(result.get("indicators", {})), + json.dumps(result), + consensus.get("consensus_score"), + consensus.get("consensus_abs"), + consensus.get("agreement_ratio"), + consensus.get("quality_multiplier"), + "completed" if not result.get("error") else "failed", + str(result.get("error") or ""), + int(memory_id), + ), + ) ok = cur.rowcount > 0 db.commit() cur.close() @@ -489,18 +526,21 @@ class AnalysisMemory: try: with get_db_connection() as db: cur = db.cursor() - cur.execute(""" + cur.execute( + """ UPDATE qd_analysis_memory SET task_status = 'failed', task_error = %s, summary = %s, updated_at = NOW() WHERE id = %s - """, ( - str(error_message or "analysis failed"), - f"Analysis failed: {str(error_message or '')}", - int(memory_id), - )) + """, + ( + str(error_message or "analysis failed"), + f"Analysis failed: {str(error_message or '')}", + int(memory_id), + ), + ) ok = cur.rowcount > 0 db.commit() cur.close() @@ -508,12 +548,11 @@ class AnalysisMemory: except Exception as e: logger.error(f"Failed to mark task failed {memory_id}: {e}") return False - - def get_similar_patterns(self, market: str, symbol: str, - current_indicators: Dict, limit: int = 3) -> List[Dict]: + + def get_similar_patterns(self, market: str, symbol: str, current_indicators: Dict, limit: int = 3) -> List[Dict]: """ Find historical analyses with similar technical patterns. - + Multi-indicator weighted similarity: - RSI: ±15 range, weighted 0.3 - MACD signal: exact match, weighted 0.3 @@ -526,11 +565,12 @@ class AnalysisMemory: macd_signal = str(current_indicators.get("macd", {}).get("signal") or "neutral").lower() ma_trend = str(current_indicators.get("moving_averages", {}).get("trend") or "sideways").lower() vol_level = str(current_indicators.get("volatility", {}).get("level") or "normal").lower() - + with get_db_connection() as db: cur = db.cursor() - cur.execute(""" - SELECT + cur.execute( + """ + SELECT id, decision, confidence, price_at_analysis, summary, reasons, indicators_snapshot, created_at, was_correct, actual_return_pct @@ -540,44 +580,55 @@ class AnalysisMemory: AND was_correct IS NOT NULL ORDER BY validated_at DESC NULLS LAST, created_at DESC LIMIT %s - """, (market, symbol, limit * 5)) - + """, + (market, symbol, limit * 5), + ) + rows = cur.fetchall() or [] cur.close() - + scored = [] for row in rows: - ind = _safe_json_parse(row['indicators_snapshot'], {}) + ind = _safe_json_parse(row["indicators_snapshot"], {}) hist_rsi = float(ind.get("rsi", {}).get("value") or 50) hist_macd = str(ind.get("macd", {}).get("signal") or "neutral").lower() hist_ma = str(ind.get("moving_averages", {}).get("trend") or "sideways").lower() hist_vol = str(ind.get("volatility", {}).get("level") or "normal").lower() - + rsi_diff = abs(hist_rsi - rsi) rsi_score = max(0, 1 - rsi_diff / 30) * 0.3 macd_score = 0.3 if hist_macd == macd_signal else 0 ma_score = 0.25 if hist_ma == ma_trend else 0 - vol_score = 0.15 if hist_vol == vol_level else (0.08 if _vol_bands_similar(vol_level, hist_vol) else 0) - + vol_score = ( + 0.15 if hist_vol == vol_level else (0.08 if _vol_bands_similar(vol_level, hist_vol) else 0) + ) + sim = rsi_score + macd_score + ma_score + vol_score if sim < 0.25: continue - - bonus = 0.1 if row['was_correct'] else 0 - scored.append((sim + bonus, { - "id": row['id'], - "decision": row['decision'], - "confidence": row['confidence'], - "price": float(row['price_at_analysis']) if row['price_at_analysis'] else None, - "summary": row['summary'], - "was_correct": row['was_correct'], - "actual_return_pct": float(row['actual_return_pct']) if row['actual_return_pct'] else None, - "similarity_score": round(sim + bonus, 3), - })) - + + bonus = 0.1 if row["was_correct"] else 0 + scored.append( + ( + sim + bonus, + { + "id": row["id"], + "decision": row["decision"], + "confidence": row["confidence"], + "price": float(row["price_at_analysis"]) if row["price_at_analysis"] else None, + "summary": row["summary"], + "was_correct": row["was_correct"], + "actual_return_pct": float(row["actual_return_pct"]) + if row["actual_return_pct"] + else None, + "similarity_score": round(sim + bonus, 3), + }, + ) + ) + scored.sort(key=lambda x: -x[0]) return [p[1] for p in scored[:limit]] - + except Exception as e: logger.error(f"Failed to get similar patterns: {e}") return [] @@ -585,7 +636,7 @@ class AnalysisMemory: def record_feedback(self, memory_id: int, feedback: str) -> bool: """ Record user feedback on an analysis. - + Args: memory_id: Analysis memory ID feedback: 'helpful' | 'not_helpful' | 'accurate' | 'inaccurate' @@ -593,43 +644,47 @@ class AnalysisMemory: try: with get_db_connection() as db: cur = db.cursor() - cur.execute(""" + cur.execute( + """ UPDATE qd_analysis_memory SET user_feedback = %s, feedback_at = NOW() WHERE id = %s - """, (feedback, memory_id)) + """, + (feedback, memory_id), + ) db.commit() cur.close() return True except Exception as e: logger.error(f"Failed to record feedback: {e}") return False - + def validate_past_decisions(self, days_ago: int = 7) -> Dict[str, Any]: """ Validate historical decisions by comparing with actual price movements. Run this periodically (e.g., daily) to build learning data. - + Args: days_ago: Validate decisions from N days ago - + Returns: Validation statistics """ from app.services.market_data_collector import MarketDataCollector + collector = MarketDataCollector() - + stats = { "validated": 0, "correct": 0, "incorrect": 0, "errors": 0, } - + try: with get_db_connection() as db: cur = db.cursor() - + # Get unvalidated decisions from N days ago cur.execute(f""" SELECT id, market, symbol, decision, price_at_analysis @@ -639,62 +694,65 @@ class AnalysisMemory: AND created_at > NOW() - INTERVAL '{int(days_ago + 1)} days' LIMIT 50 """) - + rows = cur.fetchall() or [] - + for row in rows: try: - price_data = collector._get_price(row['market'], row['symbol']) - current_price = float(price_data.get('price', 0)) if price_data else None + price_data = collector._get_price(row["market"], row["symbol"]) + current_price = float(price_data.get("price", 0)) if price_data else None if not current_price or current_price <= 0: continue - analysis_price = float(row['price_at_analysis']) - + analysis_price = float(row["price_at_analysis"]) + if analysis_price <= 0: continue - + # Calculate return return_pct = ((current_price - analysis_price) / analysis_price) * 100 - + # Determine if decision was correct - decision = row['decision'] + decision = row["decision"] was_correct = False - - if decision == 'BUY' and return_pct > 2: # 2% threshold + + if decision == "BUY" and return_pct > 2: # 2% threshold was_correct = True - elif decision == 'SELL' and return_pct < -2: + elif decision == "SELL" and return_pct < -2: was_correct = True - elif decision == 'HOLD' and abs(return_pct) <= 5: + elif decision == "HOLD" and abs(return_pct) <= 5: was_correct = True - + # Update record - cur.execute(""" + cur.execute( + """ UPDATE qd_analysis_memory SET validated_at = NOW(), actual_return_pct = %s, was_correct = %s WHERE id = %s - """, (return_pct, was_correct, row['id'])) - + """, + (return_pct, was_correct, row["id"]), + ) + stats["validated"] += 1 if was_correct: stats["correct"] += 1 else: stats["incorrect"] += 1 - + except Exception as e: logger.warning(f"Failed to validate memory {row['id']}: {e}") stats["errors"] += 1 - + db.commit() cur.close() - + except Exception as e: logger.error(f"Validation batch failed: {e}") - + accuracy = (stats["correct"] / stats["validated"] * 100) if stats["validated"] > 0 else 0 stats["accuracy_pct"] = round(accuracy, 2) - + logger.info(f"Validation completed: {stats}") return stats @@ -706,6 +764,7 @@ class AnalysisMemory: This is used by offline AI calibration so the system can tune itself automatically. """ from app.services.market_data_collector import MarketDataCollector + collector = MarketDataCollector() stats = { @@ -776,7 +835,7 @@ class AnalysisMemory: logger.error(f"validate_unvalidated_older_than failed: {e}", exc_info=True) return stats - + def get_confidence_accuracy_by_bucket( self, market: str = None, symbol: str = None, days: int = 90 ) -> Dict[str, float]: @@ -798,11 +857,14 @@ class AnalysisMemory: params.append(symbol) where.append(f"created_at > NOW() - INTERVAL '{int(days)} days'") params = tuple(params) if params else () - cur.execute(f""" + cur.execute( + f""" SELECT confidence, was_correct FROM qd_analysis_memory - WHERE {' AND '.join(where)} - """, params) + WHERE {" AND ".join(where)} + """, + params, + ) rows = cur.fetchall() or [] cur.close() @@ -819,9 +881,7 @@ class AnalysisMemory: logger.warning(f"get_confidence_accuracy_by_bucket failed: {e}") return {} - def get_adjusted_confidence( - self, raw_confidence: int, market: str = None, symbol: str = None - ) -> int: + def get_adjusted_confidence(self, raw_confidence: int, market: str = None, symbol: str = None) -> int: """ Adjust confidence based on historical accuracy in that bucket. If model is overconfident (low actual accuracy), dampen. Underconfident -> boost slightly. @@ -845,35 +905,35 @@ class AnalysisMemory: adjusted = int(raw_confidence * factor) return max(1, min(99, adjusted)) - def get_performance_stats(self, market: str = None, symbol: str = None, - days: int = 30) -> Dict[str, Any]: + def get_performance_stats(self, market: str = None, symbol: str = None, days: int = 30) -> Dict[str, Any]: """ Get AI performance statistics. - + Returns: Performance metrics for display """ try: with get_db_connection() as db: cur = db.cursor() - + where_clauses = ["validated_at IS NOT NULL"] params = [] - + if market: where_clauses.append("market = %s") params.append(market) if symbol: where_clauses.append("symbol = %s") params.append(symbol) - + # Use f-string for interval since psycopg2 doesn't support placeholder in INTERVAL where_clauses.append(f"created_at > NOW() - INTERVAL '{int(days)} days'") - + where_sql = " AND ".join(where_clauses) - - cur.execute(f""" - SELECT + + cur.execute( + f""" + SELECT COUNT(*) as total, SUM(CASE WHEN was_correct = true THEN 1 ELSE 0 END) as correct, AVG(actual_return_pct) as avg_return, @@ -884,38 +944,42 @@ class AnalysisMemory: SUM(CASE WHEN user_feedback IS NOT NULL THEN 1 ELSE 0 END) as feedback_count FROM qd_analysis_memory WHERE {where_sql} - """, tuple(params) if params else None) - + """, + tuple(params) if params else None, + ) + row = cur.fetchone() cur.close() - - if not row or not row['total']: + + if not row or not row["total"]: return { "total_analyses": 0, "accuracy_pct": 0, "avg_return_pct": 0, "user_satisfaction_pct": 0, } - - total = row['total'] - correct = row['correct'] or 0 - + + total = row["total"] + correct = row["correct"] or 0 + return { "total_analyses": total, "accuracy_pct": round((correct / total * 100) if total > 0 else 0, 2), - "avg_return_pct": round(float(row['avg_return'] or 0), 2), + "avg_return_pct": round(float(row["avg_return"] or 0), 2), "decision_distribution": { - "buy": row['buy_count'] or 0, - "sell": row['sell_count'] or 0, - "hold": row['hold_count'] or 0, + "buy": row["buy_count"] or 0, + "sell": row["sell_count"] or 0, + "hold": row["hold_count"] or 0, }, "user_satisfaction_pct": round( - (row['helpful_count'] / row['feedback_count'] * 100) - if row['feedback_count'] and row['feedback_count'] > 0 else 0, 2 + (row["helpful_count"] / row["feedback_count"] * 100) + if row["feedback_count"] and row["feedback_count"] > 0 + else 0, + 2, ), "period_days": days, } - + except Exception as e: logger.error(f"Failed to get performance stats: {e}") return { @@ -940,6 +1004,7 @@ def _vol_bands_similar(a: str, b: str) -> bool: # Singleton _memory_instance = None + def get_analysis_memory() -> AnalysisMemory: """Get singleton AnalysisMemory instance.""" global _memory_instance diff --git a/backend_api_python/app/services/backtest.py b/backend_api_python/app/services/backtest.py index a73aed8..8fa0ceb 100644 --- a/backend_api_python/app/services/backtest.py +++ b/backend_api_python/app/services/backtest.py @@ -1,45 +1,51 @@ """ Backtest Service """ + import hashlib import json import math import traceback from datetime import datetime, timedelta -from types import SimpleNamespace -from typing import Dict, List, Any, Optional +from typing import Any, Dict, List, Optional -import pandas as pd import numpy as np +import pandas as pd from app.data_sources import DataSourceFactory -from app.utils.logger import get_logger +from app.services.indicator_params import IndicatorCaller, IndicatorParamsParser from app.utils.db import get_db_connection -from app.services.indicator_params import IndicatorParamsParser, IndicatorCaller +from app.utils.logger import get_logger logger = get_logger(__name__) class BacktestService: """Backtest Service""" - + # Timeframe in seconds TIMEFRAME_SECONDS = { - '1m': 60, '5m': 300, '15m': 900, '30m': 1800, - '1H': 3600, '4H': 14400, '1D': 86400, '1W': 604800 + "1m": 60, + "5m": 300, + "15m": 900, + "30m": 1800, + "1H": 3600, + "4H": 14400, + "1D": 86400, + "1W": 604800, } - + # Multi-timeframe backtest threshold configuration # 1m backtest: max 15 days (~21,600 candles) - reduced for performance # 5m backtest: max 1 year (~105,120 candles) MTF_CONFIG = { - 'max_1m_days': 15, # Max days for 1-minute backtest (reduced from 30 for performance) - 'max_5m_days': 365, # Max days for 5-minute backtest - 'default_exec_tf': '1m', # Default execution timeframe - 'fallback_exec_tf': '5m', # Fallback execution timeframe + "max_1m_days": 15, # Max days for 1-minute backtest (reduced from 30 for performance) + "max_5m_days": 365, # Max days for 5-minute backtest + "default_exec_tf": "1m", # Default execution timeframe + "fallback_exec_tf": "5m", # Fallback execution timeframe } - ENGINE_VERSION = 'strategy-backtest-v1' + ENGINE_VERSION = "strategy-backtest-v1" def __init__(self): self._storage_schema_ready = False @@ -50,11 +56,17 @@ class BacktestService: try: with get_db_connection() as db: cur = db.cursor() - cur.execute("ALTER TABLE qd_backtest_runs ADD COLUMN IF NOT EXISTS run_type VARCHAR(50) DEFAULT 'indicator'") + cur.execute( + "ALTER TABLE qd_backtest_runs ADD COLUMN IF NOT EXISTS run_type VARCHAR(50) DEFAULT 'indicator'" + ) cur.execute("ALTER TABLE qd_backtest_runs ADD COLUMN IF NOT EXISTS strategy_id INTEGER") - cur.execute("ALTER TABLE qd_backtest_runs ADD COLUMN IF NOT EXISTS strategy_name VARCHAR(255) DEFAULT ''") + cur.execute( + "ALTER TABLE qd_backtest_runs ADD COLUMN IF NOT EXISTS strategy_name VARCHAR(255) DEFAULT ''" + ) cur.execute("ALTER TABLE qd_backtest_runs ADD COLUMN IF NOT EXISTS config_snapshot TEXT DEFAULT ''") - cur.execute("ALTER TABLE qd_backtest_runs ADD COLUMN IF NOT EXISTS engine_version VARCHAR(50) DEFAULT ''") + cur.execute( + "ALTER TABLE qd_backtest_runs ADD COLUMN IF NOT EXISTS engine_version VARCHAR(50) DEFAULT ''" + ) cur.execute("ALTER TABLE qd_backtest_runs ADD COLUMN IF NOT EXISTS code_hash VARCHAR(128) DEFAULT ''") cur.execute("CREATE INDEX IF NOT EXISTS idx_backtest_runs_strategy_id ON qd_backtest_runs(strategy_id)") cur.execute("CREATE INDEX IF NOT EXISTS idx_backtest_runs_run_type ON qd_backtest_runs(run_type)") @@ -92,7 +104,9 @@ class BacktestService: ) """ ) - cur.execute("CREATE INDEX IF NOT EXISTS idx_backtest_equity_points_run_id ON qd_backtest_equity_points(run_id)") + cur.execute( + "CREATE INDEX IF NOT EXISTS idx_backtest_equity_points_run_id ON qd_backtest_equity_points(run_id)" + ) db.commit() cur.close() self._storage_schema_ready = True @@ -100,22 +114,22 @@ class BacktestService: logger.warning("Failed to ensure backtest storage schema", exc_info=True) def _detect_trade_side(self, trade_type: str) -> str: - ty = str(trade_type or '').strip().lower() - if 'long' in ty: - return 'long' - if 'short' in ty: - return 'short' - return '' - + ty = str(trade_type or "").strip().lower() + if "long" in ty: + return "long" + if "short" in ty: + return "short" + return "" + @staticmethod def _infer_candle_path(open_: float, high: float, low: float, close: float) -> List[float]: """ Infer the price path within a candle. - + Determines the order of price movement based on open/close relationship: - Bullish candle (close >= open): Open -> Low -> High -> Close (dip then rally) - Bearish candle (close < open): Open -> High -> Low -> Close (rally then dip) - + Returns: Price path list [price1, price2, price3, price4] """ @@ -125,61 +139,61 @@ class BacktestService: else: # Bearish: rally first then dip return [open_, high, low, close] - - def get_execution_timeframe(self, start_date: datetime, end_date: datetime, market: str = 'crypto') -> tuple: + + def get_execution_timeframe(self, start_date: datetime, end_date: datetime, market: str = "crypto") -> tuple: """ Automatically select execution timeframe based on backtest date range. - + Args: start_date: Start date end_date: End date market: Market type - + Returns: (execution_timeframe, precision_info) - execution_timeframe: '1m' or '5m' - precision_info: Precision info dict for frontend display """ days_diff = (end_date - start_date).days - + # Only crypto market supports high-precision backtest - if market.lower() not in ['crypto', 'cryptocurrency']: + if market.lower() not in ["crypto", "cryptocurrency"]: return None, { - 'enabled': False, - 'reason': 'only_crypto', - 'message': 'High-precision backtest only supports cryptocurrency market' + "enabled": False, + "reason": "only_crypto", + "message": "High-precision backtest only supports cryptocurrency market", } - - if days_diff <= self.MTF_CONFIG['max_1m_days']: + + if days_diff <= self.MTF_CONFIG["max_1m_days"]: # Within 15 days: use 1-minute precision estimated_candles = days_diff * 24 * 60 - return '1m', { - 'enabled': True, - 'timeframe': '1m', - 'days': days_diff, - 'estimated_candles': estimated_candles, - 'precision': 'high', - 'message': f'Using 1-minute precision backtest (~{estimated_candles:,} candles)' + return "1m", { + "enabled": True, + "timeframe": "1m", + "days": days_diff, + "estimated_candles": estimated_candles, + "precision": "high", + "message": f"Using 1-minute precision backtest (~{estimated_candles:,} candles)", } - elif days_diff <= self.MTF_CONFIG['max_5m_days']: + elif days_diff <= self.MTF_CONFIG["max_5m_days"]: # 15 days to 1 year: use 5-minute precision estimated_candles = days_diff * 24 * 12 - return '5m', { - 'enabled': True, - 'timeframe': '5m', - 'days': days_diff, - 'estimated_candles': estimated_candles, - 'precision': 'medium', - 'message': f'Range exceeds {self.MTF_CONFIG["max_1m_days"]} days, using 5-minute precision (~{estimated_candles:,} candles)' + return "5m", { + "enabled": True, + "timeframe": "5m", + "days": days_diff, + "estimated_candles": estimated_candles, + "precision": "medium", + "message": f"Range exceeds {self.MTF_CONFIG['max_1m_days']} days, using 5-minute precision (~{estimated_candles:,} candles)", } else: # Over 1 year: high-precision backtest not supported return None, { - 'enabled': False, - 'reason': 'too_long', - 'days': days_diff, - 'max_days': self.MTF_CONFIG['max_5m_days'], - 'message': f'Backtest range {days_diff} days exceeds max limit {self.MTF_CONFIG["max_5m_days"]} days' + "enabled": False, + "reason": "too_long", + "days": days_diff, + "max_days": self.MTF_CONFIG["max_5m_days"], + "message": f"Backtest range {days_diff} days exceeds max limit {self.MTF_CONFIG['max_5m_days']} days", } def _liquidation_loss(self, capital: Any) -> float: @@ -205,14 +219,14 @@ class BacktestService: trade_direction: str, strategy_config: Optional[Dict[str, Any]] = None, config_snapshot: Optional[Dict[str, Any]] = None, - status: str = 'success', - error_message: str = '', + status: str = "success", + error_message: str = "", result: Optional[Dict[str, Any]] = None, indicator_id: Optional[int] = None, strategy_id: Optional[int] = None, - strategy_name: str = '', - run_type: str = 'indicator', - code: str = '', + strategy_name: str = "", + run_type: str = "indicator", + code: str = "", ) -> Optional[int]: self.ensure_storage_schema() run_id = None @@ -231,31 +245,31 @@ class BacktestService: int(user_id or 1), int(indicator_id) if indicator_id is not None else None, int(strategy_id) if strategy_id is not None else None, - str(strategy_name or ''), - str(run_type or 'indicator'), - str(market or ''), - str(symbol or ''), - str(timeframe or ''), - str(start_date_str or ''), - str(end_date_str or ''), + str(strategy_name or ""), + str(run_type or "indicator"), + str(market or ""), + str(symbol or ""), + str(timeframe or ""), + str(start_date_str or ""), + str(end_date_str or ""), float(initial_capital or 0), float(commission or 0), float(slippage or 0), int(leverage or 1), - str(trade_direction or 'long'), + str(trade_direction or "long"), json.dumps(strategy_config or {}, ensure_ascii=False), json.dumps(config_snapshot or {}, ensure_ascii=False), self.ENGINE_VERSION, - hashlib.sha256(str(code or '').encode('utf-8')).hexdigest() if code else '', - str(status or 'success'), - str(error_message or ''), - json.dumps(result or {}, ensure_ascii=False) if result else '' - ) + hashlib.sha256(str(code or "").encode("utf-8")).hexdigest() if code else "", + str(status or "success"), + str(error_message or ""), + json.dumps(result or {}, ensure_ascii=False) if result else "", + ), ) run_id = cur.lastrowid - if run_id and status == 'success' and isinstance(result, dict): - for idx, trade in enumerate((result.get('trades') or []), start=1): + if run_id and status == "success" and isinstance(result, dict): + for idx, trade in enumerate((result.get("trades") or []), start=1): cur.execute( """ INSERT INTO qd_backtest_trades @@ -267,19 +281,19 @@ class BacktestService: int(user_id or 1), int(strategy_id) if strategy_id is not None else None, idx, - str(trade.get('time') or ''), - str(trade.get('type') or ''), - self._detect_trade_side(trade.get('type')), - float(trade.get('price') or 0), - float(trade.get('amount') or 0), - float(trade.get('profit') or 0), - float(trade.get('balance') or 0), - str(trade.get('reason') or trade.get('close_reason') or ''), + str(trade.get("time") or ""), + str(trade.get("type") or ""), + self._detect_trade_side(trade.get("type")), + float(trade.get("price") or 0), + float(trade.get("amount") or 0), + float(trade.get("profit") or 0), + float(trade.get("balance") or 0), + str(trade.get("reason") or trade.get("close_reason") or ""), json.dumps(trade or {}, ensure_ascii=False), - ) + ), ) - for idx, point in enumerate((result.get('equityCurve') or []), start=1): + for idx, point in enumerate((result.get("equityCurve") or []), start=1): cur.execute( """ INSERT INTO qd_backtest_equity_points @@ -289,9 +303,9 @@ class BacktestService: ( int(run_id), idx, - str(point.get('time') or ''), - float(point.get('value') or 0), - ) + str(point.get("time") or ""), + float(point.get("value") or 0), + ), ) db.commit() @@ -309,9 +323,9 @@ class BacktestService: indicator_id: Optional[int] = None, strategy_id: Optional[int] = None, run_type: Optional[str] = None, - symbol: str = '', - market: str = '', - timeframe: str = '', + symbol: str = "", + market: str = "", + timeframe: str = "", ) -> List[Dict[str, Any]]: self.ensure_storage_schema() where = ["user_id = ?"] @@ -379,27 +393,27 @@ class BacktestService: def _hydrate_run_row(self, row: Dict[str, Any], include_result: bool = True) -> Dict[str, Any]: item = dict(row or {}) try: - item['strategy_config'] = json.loads(item.get('strategy_config') or '{}') + item["strategy_config"] = json.loads(item.get("strategy_config") or "{}") except Exception: - item['strategy_config'] = {} + item["strategy_config"] = {} try: - item['config_snapshot'] = json.loads(item.get('config_snapshot') or '{}') + item["config_snapshot"] = json.loads(item.get("config_snapshot") or "{}") except Exception: - item['config_snapshot'] = {} + item["config_snapshot"] = {} try: - result = json.loads(item.get('result_json') or '{}') + result = json.loads(item.get("result_json") or "{}") except Exception: result = {} - item['total_return'] = result.get('totalReturn') - item['annual_return'] = result.get('annualReturn') - item['win_rate'] = result.get('winRate') - item['total_trades'] = result.get('totalTrades') + item["total_return"] = result.get("totalReturn") + item["annual_return"] = result.get("annualReturn") + item["win_rate"] = result.get("winRate") + item["total_trades"] = result.get("totalTrades") if include_result: - item['result'] = result - item.pop('result_json', None) + item["result"] = result + item.pop("result_json", None) return item - + def run_multi_timeframe( self, indicator_code: str, @@ -412,7 +426,7 @@ class BacktestService: commission: float = 0.001, slippage: float = 0.0, leverage: int = 1, - trade_direction: str = 'long', + trade_direction: str = "long", strategy_config: Optional[Dict[str, Any]] = None, enable_mtf: bool = True, indicator_params: Optional[Dict[str, Any]] = None, @@ -421,10 +435,10 @@ class BacktestService: ) -> Dict[str, Any]: """ Multi-timeframe backtest. - - Uses strategy timeframe for signal generation and execution timeframe (1m/5m) + + Uses strategy timeframe for signal generation and execution timeframe (1m/5m) for precise trade simulation. - + Args: indicator_code: Indicator code market: Market type @@ -439,38 +453,38 @@ class BacktestService: trade_direction: Trade direction strategy_config: Strategy configuration enable_mtf: Whether to enable multi-timeframe backtest - + Returns: Backtest result with precision info """ # Get execution timeframe exec_tf, precision_info = self.get_execution_timeframe(start_date, end_date, market) cfg = strategy_config or {} - exec_cfg = cfg.get('execution') or {} - scale_cfg = cfg.get('scale') or {} - signal_timing = str(exec_cfg.get('signalTiming') or 'next_bar_open').strip().lower() - enabled_scale_keys = ['trendAdd', 'dcaAdd', 'trendReduce', 'adverseReduce'] - has_scale_rules = any(bool((scale_cfg.get(key) or {}).get('enabled')) for key in enabled_scale_keys) - + exec_cfg = cfg.get("execution") or {} + scale_cfg = cfg.get("scale") or {} + signal_timing = str(exec_cfg.get("signalTiming") or "next_bar_open").strip().lower() + enabled_scale_keys = ["trendAdd", "dcaAdd", "trendReduce", "adverseReduce"] + has_scale_rules = any(bool((scale_cfg.get(key) or {}).get("enabled")) for key in enabled_scale_keys) + # Skip MTF when: disabled, not supported, or signal tf <= exec tf (no precision gain) signal_tf_seconds = self.TIMEFRAME_SECONDS.get(timeframe, 86400) exec_tf_seconds = self.TIMEFRAME_SECONDS.get(exec_tf, 300) if exec_tf else signal_tf_seconds skip_mtf = ( not enable_mtf - or not precision_info.get('enabled') + or not precision_info.get("enabled") or signal_tf_seconds <= exec_tf_seconds or has_scale_rules - or signal_timing not in ['next_bar_open', 'next_open', 'nextopen', 'next'] + or signal_timing not in ["next_bar_open", "next_open", "nextopen", "next"] ) - + if skip_mtf: fallback_reason = None if has_scale_rules: - fallback_reason = 'scale_rules_not_supported_in_mtf' - elif signal_timing not in ['next_bar_open', 'next_open', 'nextopen', 'next']: - fallback_reason = 'signal_timing_not_supported_in_mtf' + fallback_reason = "scale_rules_not_supported_in_mtf" + elif signal_timing not in ["next_bar_open", "next_open", "nextopen", "next"]: + fallback_reason = "signal_timing_not_supported_in_mtf" elif signal_tf_seconds <= exec_tf_seconds: - fallback_reason = 'no_precision_gain' + fallback_reason = "no_precision_gain" logger.info( f"Using standard backtest: tf={timeframe} " f"(MTF skipped, reason={fallback_reason}, signal_tf_s={signal_tf_seconds}, exec_tf_s={exec_tf_seconds})" @@ -492,46 +506,52 @@ class BacktestService: user_id=user_id, indicator_id=indicator_id, ) - result['precision_info'] = precision_info or { - 'enabled': False, - 'timeframe': timeframe, - 'precision': 'standard', - 'message': 'Using standard candle backtest' + result["precision_info"] = precision_info or { + "enabled": False, + "timeframe": timeframe, + "precision": "standard", + "message": "Using standard candle backtest", } if fallback_reason: - result['precision_info']['fallback_reason'] = fallback_reason - if fallback_reason == 'scale_rules_not_supported_in_mtf': - result['precision_info']['message'] = 'Using standard backtest because scale rules are not fully supported in MTF mode' - elif fallback_reason == 'signal_timing_not_supported_in_mtf': - result['precision_info']['message'] = 'Using standard backtest because this execution timing is not fully supported in MTF mode' - ea = result.get('executionAssumptions') or {} - ea['mtfRequested'] = bool(enable_mtf) - ea['mtfActive'] = False + result["precision_info"]["fallback_reason"] = fallback_reason + if fallback_reason == "scale_rules_not_supported_in_mtf": + result["precision_info"]["message"] = ( + "Using standard backtest because scale rules are not fully supported in MTF mode" + ) + elif fallback_reason == "signal_timing_not_supported_in_mtf": + result["precision_info"]["message"] = ( + "Using standard backtest because this execution timing is not fully supported in MTF mode" + ) + ea = result.get("executionAssumptions") or {} + ea["mtfRequested"] = bool(enable_mtf) + ea["mtfActive"] = False if fallback_reason: - ea['mtfFallbackReason'] = fallback_reason - result['executionAssumptions'] = ea + ea["mtfFallbackReason"] = fallback_reason + result["executionAssumptions"] = ea return result - - logger.info(f"Multi-timeframe backtest: strategy_tf={timeframe}, exec_tf={exec_tf}, range={start_date} ~ {end_date}") - + + logger.info( + f"Multi-timeframe backtest: strategy_tf={timeframe}, exec_tf={exec_tf}, range={start_date} ~ {end_date}" + ) + # 1. Fetch strategy timeframe candles (for signal generation) df_signal = self._fetch_kline_data(market, symbol, timeframe, start_date, end_date) if df_signal.empty: raise ValueError("No candle data available in the backtest date range") - + # 2. Execute indicator code to get signals backtest_params = { - 'leverage': leverage, - 'initial_capital': initial_capital, - 'commission': commission, - 'trade_direction': trade_direction, - 'indicator_params': indicator_params or {}, - 'user_id': user_id, - 'indicator_id': indicator_id, + "leverage": leverage, + "initial_capital": initial_capital, + "commission": commission, + "trade_direction": trade_direction, + "indicator_params": indicator_params or {}, + "user_id": user_id, + "indicator_id": indicator_id, } signals = self._execute_indicator(indicator_code, df_signal, backtest_params) logger.info(f"Signals generated: {list(signals.keys()) if isinstance(signals, dict) else type(signals)}") - + # 3. Fetch execution timeframe candles (for precise trade simulation) logger.info(f"Fetching execution timeframe data: {exec_tf} for {market}:{symbol}") df_exec = self._fetch_kline_data(market, symbol, exec_tf, start_date, end_date) @@ -555,63 +575,67 @@ class BacktestService: user_id=user_id, indicator_id=indicator_id, ) - result['precision_info'] = { - 'enabled': False, - 'reason': 'data_unavailable', - 'message': f'Cannot fetch {exec_tf} data, using standard backtest' + result["precision_info"] = { + "enabled": False, + "reason": "data_unavailable", + "message": f"Cannot fetch {exec_tf} data, using standard backtest", } - ea = result.get('executionAssumptions') or {} - ea['mtfRequested'] = bool(enable_mtf) - ea['mtfActive'] = False - ea['mtfFallbackReason'] = 'data_unavailable' - result['executionAssumptions'] = ea + ea = result.get("executionAssumptions") or {} + ea["mtfRequested"] = bool(enable_mtf) + ea["mtfActive"] = False + ea["mtfFallbackReason"] = "data_unavailable" + result["executionAssumptions"] = ea return result - + logger.info(f"Data fetched: signal_candles={len(df_signal)}, exec_candles={len(df_exec)}") - + # 4. Use execution timeframe for precise trade simulation try: logger.info("Starting MTF trading simulation...") equity_curve, trades, total_commission = self._simulate_trading_mtf( - df_signal=df_signal, - df_exec=df_exec, - signals=signals, - initial_capital=initial_capital, - commission=commission, - slippage=slippage, - leverage=leverage, - trade_direction=trade_direction, - strategy_config=strategy_config, + df_signal=df_signal, + df_exec=df_exec, + signals=signals, + initial_capital=initial_capital, + commission=commission, + slippage=slippage, + leverage=leverage, + trade_direction=trade_direction, + strategy_config=strategy_config, signal_timeframe=timeframe, - exec_timeframe=exec_tf + exec_timeframe=exec_tf, ) logger.info(f"MTF simulation completed: {len(trades)} trades executed") except Exception as e: logger.error(f"MTF simulation failed: {str(e)}") logger.error(traceback.format_exc()) raise - + # 5. Calculate metrics try: - logger.info(f"Calculating metrics: equity_curve_len={len(equity_curve)}, trades_len={len(trades)}, initial_capital={initial_capital}") - metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission) + logger.info( + f"Calculating metrics: equity_curve_len={len(equity_curve)}, trades_len={len(trades)}, initial_capital={initial_capital}" + ) + metrics = self._calculate_metrics( + equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission + ) logger.info(f"Metrics calculated successfully: {list(metrics.keys())}") except Exception as e: logger.error(f"Failed to calculate metrics: {str(e)}") logger.error(traceback.format_exc()) raise - + # 6. Format result try: logger.info("Formatting backtest result...") result = self._format_result(metrics, equity_curve, trades) - result['precision_info'] = precision_info - result['execution_timeframe'] = exec_tf - result['signal_candles'] = len(df_signal) - result['execution_candles'] = len(df_exec) - result['executionAssumptions'] = self._execution_assumptions( + result["precision_info"] = precision_info + result["execution_timeframe"] = exec_tf + result["signal_candles"] = len(df_signal) + result["execution_candles"] = len(df_exec) + result["executionAssumptions"] = self._execution_assumptions( strategy_config, - simulation_mode='mtf', + simulation_mode="mtf", signal_timeframe=timeframe, execution_timeframe=exec_tf, mtf_requested=True, @@ -622,9 +646,9 @@ class BacktestService: logger.error(f"Failed to format result: {str(e)}") logger.error(traceback.format_exc()) raise - + return result - + def _simulate_trading_mtf( self, df_signal: pd.DataFrame, @@ -637,54 +661,56 @@ class BacktestService: trade_direction: str, strategy_config: Optional[Dict[str, Any]], signal_timeframe: str, - exec_timeframe: str + exec_timeframe: str, ) -> tuple: """ Multi-timeframe trading simulation. - - Simulates trades candle by candle on execution timeframe, + + Simulates trades candle by candle on execution timeframe, using inferred candle price path to determine trigger order. """ try: - logger.info(f"Entering _simulate_trading_mtf: df_signal={len(df_signal)}, df_exec={len(df_exec)}, signals_type={type(signals)}") + logger.info( + f"Entering _simulate_trading_mtf: df_signal={len(df_signal)}, df_exec={len(df_exec)}, signals_type={type(signals)}" + ) except Exception as e: logger.error(f"Error in _simulate_trading_mtf entry logging: {e}") - + equity_curve = [] trades = [] total_commission_paid = 0.0 is_liquidated = False min_capital_to_trade = 1.0 - + capital = initial_capital position = 0 entry_price = 0.0 position_type = None # 'long' or 'short' - + # Parse strategy config cfg = strategy_config or {} - risk_cfg = cfg.get('risk') or {} - stop_loss_pct = float(risk_cfg.get('stopLossPct') or 0.0) - take_profit_pct = float(risk_cfg.get('takeProfitPct') or 0.0) - trailing_cfg = risk_cfg.get('trailing') or {} - trailing_enabled = bool(trailing_cfg.get('enabled')) - trailing_pct = float(trailing_cfg.get('pct') or 0.0) - trailing_activation_pct = float(trailing_cfg.get('activationPct') or 0.0) - + risk_cfg = cfg.get("risk") or {} + stop_loss_pct = float(risk_cfg.get("stopLossPct") or 0.0) + take_profit_pct = float(risk_cfg.get("takeProfitPct") or 0.0) + trailing_cfg = risk_cfg.get("trailing") or {} + trailing_enabled = bool(trailing_cfg.get("enabled")) + trailing_pct = float(trailing_cfg.get("pct") or 0.0) + trailing_activation_pct = float(trailing_cfg.get("activationPct") or 0.0) + lev = max(int(leverage or 1), 1) stop_loss_pct_eff = stop_loss_pct / lev if stop_loss_pct > 0 else 0 take_profit_pct_eff = take_profit_pct / lev if take_profit_pct > 0 else 0 trailing_pct_eff = trailing_pct / lev if trailing_pct > 0 else 0 trailing_activation_pct_eff = trailing_activation_pct / lev if trailing_activation_pct > 0 else 0 - + # If trailing stop enabled but no activation threshold set, use take profit threshold if trailing_enabled and trailing_pct_eff > 0: if trailing_activation_pct_eff <= 0 and take_profit_pct_eff > 0: trailing_activation_pct_eff = take_profit_pct_eff - + # Entry percentage - pos_cfg = cfg.get('position') or {} - raw_entry_pct = pos_cfg.get('entryPct') + pos_cfg = cfg.get("position") or {} + raw_entry_pct = pos_cfg.get("entryPct") # If entryPct is None, 0, or not provided, default to 1.0 (100%) if raw_entry_pct is None or raw_entry_pct == 0: entry_pct_cfg = 1.0 @@ -693,22 +719,24 @@ class BacktestService: if entry_pct_cfg > 1: entry_pct_cfg = entry_pct_cfg / 100.0 entry_pct_cfg = max(0.01, min(entry_pct_cfg, 1.0)) # Minimum 1% to avoid 0 position - - logger.info(f"Trading params: capital={capital}, leverage={lev}, entry_pct={entry_pct_cfg}, strategy_config={cfg}") - + + logger.info( + f"Trading params: capital={capital}, leverage={lev}, entry_pct={entry_pct_cfg}, strategy_config={cfg}" + ) + highest_since_entry = None lowest_since_entry = None - + # Normalize signal format if not isinstance(signals, dict): raise ValueError("signals must be a dict") - + # Debug: check signal index compatibility signal_keys = list(signals.keys()) logger.info(f"Signal keys: {signal_keys}") if signal_keys: first_key = signal_keys[0] - if hasattr(signals[first_key], 'index'): + if hasattr(signals[first_key], "index"): sig_index = signals[first_key].index df_index = df_signal.index logger.info(f"Signal index len={len(sig_index)}, df_signal index len={len(df_index)}") @@ -716,48 +744,53 @@ class BacktestService: logger.info(f"Signal index first={sig_index[0]}, df_signal index first={df_index[0]}") # Check if indices match if not sig_index.equals(df_index): - logger.warning("Signal index does NOT match df_signal index! This may cause signal lookup failures.") - - # Check if trade_direction is 'both' mode - is_both_mode = str(trade_direction or 'both').lower() == 'both' - - if all(k in signals for k in ['open_long', 'close_long', 'open_short', 'close_short']): + logger.warning( + "Signal index does NOT match df_signal index! This may cause signal lookup failures." + ) + + if all(k in signals for k in ["open_long", "close_long", "open_short", "close_short"]): norm_signals = signals - norm_signals['_both_mode'] = False # Explicit 4-signal mode, not both mode - elif all(k in signals for k in ['buy', 'sell']): + norm_signals["_both_mode"] = False # Explicit 4-signal mode, not both mode + elif all(k in signals for k in ["buy", "sell"]): # Ensure signals have the same index as df_signal - buy_series = signals['buy'] - sell_series = signals['sell'] - + buy_series = signals["buy"] + sell_series = signals["sell"] + # Reindex to match df_signal.index (fill missing with False) if not buy_series.index.equals(df_signal.index): - logger.warning(f"Buy signal index mismatch! Signal index: {buy_series.index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}") + logger.warning( + f"Buy signal index mismatch! Signal index: {buy_series.index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}" + ) buy_series = buy_series.reindex(df_signal.index, fill_value=False) if not sell_series.index.equals(df_signal.index): - logger.warning(f"Sell signal index mismatch! Signal index: {sell_series.index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}") + logger.warning( + f"Sell signal index mismatch! Signal index: {sell_series.index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}" + ) sell_series = sell_series.reindex(df_signal.index, fill_value=False) - + buy = buy_series.fillna(False).astype(bool) sell = sell_series.fillna(False).astype(bool) - + # Debug: log signal statistics buy_count = buy.sum() sell_count = sell.sum() logger.info(f"Signal statistics: buy={buy_count}, sell={sell_count}, total_candles={len(df_signal)}") - - td = str(trade_direction or 'both').lower() + + td = str(trade_direction or "both").lower() logger.info(f"Trade direction: {td} (original: {trade_direction})") - if td == 'long': + if td == "long": norm_signals = { - 'open_long': buy, 'close_long': sell, - 'open_short': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), - 'close_short': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), + "open_long": buy, + "close_long": sell, + "open_short": pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), + "close_short": pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), } - elif td == 'short': + elif td == "short": norm_signals = { - 'open_long': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), - 'close_long': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), - 'open_short': sell, 'close_short': buy, + "open_long": pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), + "close_long": pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), + "open_short": sell, + "close_short": buy, } else: # Both mode: buy signal triggers long entry (close short if any, then open long) @@ -765,32 +798,36 @@ class BacktestService: # We use special signal types 'enter_long' and 'enter_short' to indicate # that the signal should auto-close opposing position before opening norm_signals = { - 'open_long': buy, 'close_long': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), - 'open_short': sell, 'close_short': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), - '_both_mode': True # Flag to indicate both mode for special handling + "open_long": buy, + "close_long": pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), + "open_short": sell, + "close_short": pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool), + "_both_mode": True, # Flag to indicate both mode for special handling } else: raise ValueError("Invalid signal format") - + logger.info("Signal normalization completed, starting signal queue building...") - + # Map signals to execution timeframe # Strategy timeframe seconds (e.g. 1H=3600, 1D=86400) signal_tf_seconds = self.TIMEFRAME_SECONDS.get(signal_timeframe, 3600) exec_tf_seconds = self.TIMEFRAME_SECONDS.get(exec_timeframe, 60) - - logger.info(f"Signal timeframe: {signal_timeframe} ({signal_tf_seconds}s), Exec timeframe: {exec_timeframe} ({exec_tf_seconds}s)") - + + logger.info( + f"Signal timeframe: {signal_timeframe} ({signal_tf_seconds}s), Exec timeframe: {exec_timeframe} ({exec_tf_seconds}s)" + ) + # Preprocessing: create signal queue sorted by effective time # Each signal executes at the open of the next execution candle after its candle closes logger.info("Initializing signal queue...") signal_queue = [] # [(effective_time, signal_type, signal_bar_time), ...] - + # Debug: check signal values - debug_signal_counts = {'open_long': 0, 'close_long': 0, 'open_short': 0, 'close_short': 0} - + debug_signal_counts = {"open_long": 0, "close_long": 0, "open_short": 0, "close_short": 0} + # Verify all norm_signals have matching index - for sig_type in ['open_long', 'close_long', 'open_short', 'close_short']: + for sig_type in ["open_long", "close_long", "open_short", "close_short"]: if not norm_signals[sig_type].index.equals(df_signal.index): logger.error(f"Critical: {sig_type} signal index does not match df_signal.index!") logger.error(f" Signal index: {norm_signals[sig_type].index[:5].tolist()}") @@ -798,46 +835,48 @@ class BacktestService: # Reindex to fix norm_signals[sig_type] = norm_signals[sig_type].reindex(df_signal.index, fill_value=False) logger.warning(f" Fixed by reindexing {sig_type}") - + for sig_time in df_signal.index: # Signal candle end time = start time + period sig_end = sig_time + timedelta(seconds=signal_tf_seconds) - + # Check if this signal candle has signals # All signals should now have matching index, so we can safely use .loc[] try: - ol = bool(norm_signals['open_long'].loc[sig_time]) - cl = bool(norm_signals['close_long'].loc[sig_time]) - os = bool(norm_signals['open_short'].loc[sig_time]) - cs = bool(norm_signals['close_short'].loc[sig_time]) + ol = bool(norm_signals["open_long"].loc[sig_time]) + cl = bool(norm_signals["close_long"].loc[sig_time]) + os = bool(norm_signals["open_short"].loc[sig_time]) + cs = bool(norm_signals["close_short"].loc[sig_time]) except (KeyError, IndexError) as e: - logger.warning(f"Error accessing signal at {sig_time}: {e}, signal index: {norm_signals['open_long'].index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}") + logger.warning( + f"Error accessing signal at {sig_time}: {e}, signal index: {norm_signals['open_long'].index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}" + ) continue except Exception as e: logger.warning(f"Unexpected error accessing signal at {sig_time}: {e}") continue - + if ol: - signal_queue.append((sig_end, 'open_long', sig_time)) - debug_signal_counts['open_long'] += 1 + signal_queue.append((sig_end, "open_long", sig_time)) + debug_signal_counts["open_long"] += 1 if cl: - signal_queue.append((sig_end, 'close_long', sig_time)) - debug_signal_counts['close_long'] += 1 + signal_queue.append((sig_end, "close_long", sig_time)) + debug_signal_counts["close_long"] += 1 if os: - signal_queue.append((sig_end, 'open_short', sig_time)) - debug_signal_counts['open_short'] += 1 + signal_queue.append((sig_end, "open_short", sig_time)) + debug_signal_counts["open_short"] += 1 if cs: - signal_queue.append((sig_end, 'close_short', sig_time)) - debug_signal_counts['close_short'] += 1 - + signal_queue.append((sig_end, "close_short", sig_time)) + debug_signal_counts["close_short"] += 1 + logger.info(f"Debug signal counts from queue building: {debug_signal_counts}") - + # If no signals found, log detailed diagnostic info if len(signal_queue) == 0: logger.warning("No signals found in signal queue! Diagnostic info:") logger.warning(f" df_signal length: {len(df_signal)}") logger.warning(f" df_signal index range: {df_signal.index[0]} to {df_signal.index[-1]}") - for sig_type in ['open_long', 'close_long', 'open_short', 'close_short']: + for sig_type in ["open_long", "close_long", "open_short", "close_short"]: sig_series = norm_signals[sig_type] true_count = sig_series.sum() logger.warning(f" {sig_type}: {true_count} True values out of {len(sig_series)}") @@ -845,32 +884,34 @@ class BacktestService: true_indices = sig_series[sig_series].index.tolist()[:5] logger.warning(f" First few True indices: {true_indices}") # Check if signals might be in wrong format - if 'buy' in signals or 'sell' in signals: + if "buy" in signals or "sell" in signals: logger.warning(" Original signals had 'buy'/'sell' keys - check if conversion was correct") - + # Sort by effective time signal_queue.sort(key=lambda x: x[0]) signal_queue_idx = 0 # Current signal queue pointer - + logger.info(f"Signal queue built: total {len(signal_queue)} signals") if signal_queue: logger.info(f"First signal: {signal_queue[0][1]} @ {signal_queue[0][0]} (from {signal_queue[0][2]})") logger.info(f"Last signal: {signal_queue[-1][1]} @ {signal_queue[-1][0]} (from {signal_queue[-1][2]})") else: - logger.error("Signal queue is empty! Backtest will fail. Check indicator code to ensure it generates buy/sell signals.") - + logger.error( + "Signal queue is empty! Backtest will fail. Check indicator code to ensure it generates buy/sell signals." + ) + # Count signals by type signal_counts = {} for _, sig_type, _ in signal_queue: signal_counts[sig_type] = signal_counts.get(sig_type, 0) + 1 logger.info(f"Signal counts: {signal_counts}") - + # Log first few signal details for debugging if signal_queue: - logger.info(f"First 3 signals details:") + logger.info("First 3 signals details:") for idx, (sig_time, sig_type, sig_bar_time) in enumerate(signal_queue[:3]): - logger.info(f" Signal {idx+1}: {sig_type} @ effective_time={sig_time}, from_bar={sig_bar_time}") - + logger.info(f" Signal {idx + 1}: {sig_type} @ effective_time={sig_time}, from_bar={sig_bar_time}") + # Log execution data range if len(df_exec) > 0: exec_start = df_exec.index[0] @@ -878,79 +919,87 @@ class BacktestService: logger.info(f"Exec data range: {exec_start} ~ {exec_end}") # Check first few candles for data validity first_row = df_exec.iloc[0] - logger.info(f"First exec candle: open={first_row['open']}, high={first_row['high']}, low={first_row['low']}, close={first_row['close']}") - + logger.info( + f"First exec candle: open={first_row['open']}, high={first_row['high']}, low={first_row['low']}, close={first_row['close']}" + ) + # Current pending signal to execute pending_signal = None # ('open_long', 'close_long', 'open_short', 'close_short') pending_signal_time = None # Signal effective time executed_trades_count = 0 # Debug counter - + # Progress logging for large datasets total_exec_candles = len(df_exec) progress_log_interval = max(1000, total_exec_candles // 10) # Log every 10% or every 1000 candles - - logger.info(f"Starting execution loop: {total_exec_candles} candles to process, {len(signal_queue)} signals in queue") - + + logger.info( + f"Starting execution loop: {total_exec_candles} candles to process, {len(signal_queue)} signals in queue" + ) + for i, (timestamp, row) in enumerate(df_exec.iterrows()): # Progress logging if i > 0 and i % progress_log_interval == 0: progress_pct = (i / total_exec_candles) * 100 - logger.info(f"Execution progress: {i}/{total_exec_candles} ({progress_pct:.1f}%), trades={executed_trades_count}, position={position}") + logger.info( + f"Execution progress: {i}/{total_exec_candles} ({progress_pct:.1f}%), trades={executed_trades_count}, position={position}" + ) # After the liquidation, the backtest will be stopped directly and the results will be output. if is_liquidated: break - + if position == 0 and capital < min_capital_to_trade: is_liquidated = True capital = 0 - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": 0}) continue - - open_ = row['open'] - high = row['high'] - low = row['low'] - close = row['close'] - + + open_ = row["open"] + high = row["high"] + low = row["low"] + close = row["close"] + # Use inferred candle price path to determine trigger order price_path = self._infer_candle_path(open_, high, low, close) - + # Check if new signal becomes effective # Signal executes at the first execution candle open after its candle closes while signal_queue_idx < len(signal_queue): sig_effective_time, sig_type, sig_bar_time = signal_queue[signal_queue_idx] - + # Debug: log first few signal checks if i < 10 and signal_queue_idx < len(signal_queue): - logger.debug(f"[i={i}] Checking signal #{signal_queue_idx}: {sig_type} @ {sig_effective_time}, exec_time={timestamp}, position={position}") - + logger.debug( + f"[i={i}] Checking signal #{signal_queue_idx}: {sig_type} @ {sig_effective_time}, exec_time={timestamp}, position={position}" + ) + # If current exec candle time >= signal effective time, signal can execute if timestamp >= sig_effective_time: # Check if signal can execute (based on current position) # In both mode, open_long can execute even with short position (will auto-close first) # Similarly, open_short can execute even with long position can_execute = False - both_mode_active = norm_signals.get('_both_mode', False) - - if sig_type == 'open_long': + both_mode_active = norm_signals.get("_both_mode", False) + + if sig_type == "open_long": if position == 0: can_execute = True elif both_mode_active and position < 0: # Both mode: have short position, will close short then open long can_execute = True - elif sig_type == 'close_long' and position > 0: + elif sig_type == "close_long" and position > 0: can_execute = True - elif sig_type == 'open_short': + elif sig_type == "open_short": if position == 0: can_execute = True elif both_mode_active and position > 0: # Both mode: have long position, will close long then open short can_execute = True - elif sig_type == 'close_short' and position < 0: + elif sig_type == "close_short" and position < 0: can_execute = True - + if can_execute: pending_signal = sig_type - pending_signal_time = sig_effective_time + pending_signal_time = sig_effective_time # noqa signal_queue_idx += 1 if executed_trades_count < 3: logger.info(f"Signal ready: {sig_type} @ {timestamp} (effective_time={sig_effective_time})") @@ -961,21 +1010,21 @@ class BacktestService: else: # Not yet at signal effective time break - + # Check trigger conditions along price path for path_price in price_path: if is_liquidated: break - + # 1. Check stop-loss/take-profit/trailing stop (highest priority) - if position != 0 and position_type in ['long', 'short']: + if position != 0 and position_type in ["long", "short"]: triggered = False - - if position_type == 'long' and position > 0: + + if position_type == "long" and position > 0: if highest_since_entry is None: highest_since_entry = entry_price highest_since_entry = max(highest_since_entry, path_price) - + # Stop loss if stop_loss_pct_eff > 0: sl_price = entry_price * (1 - stop_loss_pct_eff) @@ -988,20 +1037,22 @@ class BacktestService: capital = 0 is_liquidated = True total_commission_paid += commission_fee - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long_stop', - 'price': round(exec_price, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long_stop", + "price": round(exec_price, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None highest_since_entry = None lowest_since_entry = None triggered = True - + # Trailing stop if not triggered and trailing_enabled and trailing_pct_eff > 0: trail_active = True @@ -1015,20 +1066,22 @@ class BacktestService: profit = (exec_price - entry_price) * position - commission_fee capital += profit total_commission_paid += commission_fee - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long_trailing', - 'price': round(exec_price, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long_trailing", + "price": round(exec_price, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None highest_since_entry = None lowest_since_entry = None triggered = True - + # Fixed take profit (disabled when trailing stop is enabled) if not triggered and not trailing_enabled and take_profit_pct_eff > 0: tp_price = entry_price * (1 + take_profit_pct_eff) @@ -1038,26 +1091,28 @@ class BacktestService: profit = (exec_price - entry_price) * position - commission_fee capital += profit total_commission_paid += commission_fee - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long_profit', - 'price': round(exec_price, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long_profit", + "price": round(exec_price, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None highest_since_entry = None lowest_since_entry = None triggered = True - - elif position_type == 'short' and position < 0: + + elif position_type == "short" and position < 0: shares = abs(position) if lowest_since_entry is None: lowest_since_entry = entry_price lowest_since_entry = min(lowest_since_entry, path_price) - + # Stop loss if stop_loss_pct_eff > 0: sl_price = entry_price * (1 + stop_loss_pct_eff) @@ -1069,31 +1124,35 @@ class BacktestService: liquidation_loss = self._liquidation_loss(capital) capital = 0 is_liquidated = True - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": liquidation_loss, + "balance": 0, + } + ) else: capital += profit total_commission_paid += commission_fee - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short_stop', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short_stop", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None highest_since_entry = None lowest_since_entry = None triggered = True - + # Trailing stop if not triggered and trailing_enabled and trailing_pct_eff > 0: trail_active = True @@ -1109,31 +1168,35 @@ class BacktestService: liquidation_loss = self._liquidation_loss(capital) capital = 0 is_liquidated = True - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": liquidation_loss, + "balance": 0, + } + ) else: capital += profit total_commission_paid += commission_fee - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short_trailing', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short_trailing", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None highest_since_entry = None lowest_since_entry = None triggered = True - + # Fixed take profit if not triggered and not trailing_enabled and take_profit_pct_eff > 0: tp_price = entry_price * (1 - take_profit_pct_eff) @@ -1143,34 +1206,38 @@ class BacktestService: profit = (entry_price - exec_price) * shares - commission_fee capital += profit total_commission_paid += commission_fee - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short_profit', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short_profit", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None highest_since_entry = None lowest_since_entry = None triggered = True - + if triggered: pending_signal = None continue - + # 2. Execute pending signal (at open price) if pending_signal and path_price == open_: - both_mode_active = norm_signals.get('_both_mode', False) + both_mode_active = norm_signals.get("_both_mode", False) if executed_trades_count < 10: - logger.info(f"Executing pending signal: {pending_signal} @ {timestamp}, path_price={path_price}, open={open_}, position={position}") - + logger.info( + f"Executing pending signal: {pending_signal} @ {timestamp}, path_price={path_price}, open={open_}, position={position}" + ) + # open_long: In both mode, first close short if any, then open long - if pending_signal == 'open_long' and (position == 0 or (both_mode_active and position < 0)): + if pending_signal == "open_long" and (position == 0 or (both_mode_active and position < 0)): exec_price = open_ * (1 + slippage) - + # If in both mode and have short position, close it first if both_mode_active and position < 0: shares_to_close = abs(position) @@ -1181,26 +1248,30 @@ class BacktestService: if capital < 0: capital = 0 total_commission_paid += close_commission - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short', - 'price': round(close_price, 4), - 'amount': round(shares_to_close, 4), - 'profit': round(close_profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short", + "price": round(close_price, 4), + "amount": round(shares_to_close, 4), + "profit": round(close_profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None executed_trades_count += 1 if executed_trades_count <= 10: - logger.info(f"Trade #{executed_trades_count}: close_short (before open_long) @ {timestamp}, price={close_price:.4f}, profit={close_profit:.2f}") + logger.info( + f"Trade #{executed_trades_count}: close_short (before open_long) @ {timestamp}, price={close_price:.4f}, profit={close_profit:.2f}" + ) # Check whether the position is liquidated if capital < min_capital_to_trade: is_liquidated = True capital = 0 pending_signal = None continue - + # Now open long use_capital = capital * entry_pct_cfg if exec_price > 0: @@ -1214,23 +1285,27 @@ class BacktestService: total_commission_paid += commission_fee position = shares entry_price = exec_price - position_type = 'long' + position_type = "long" highest_since_entry = exec_price lowest_since_entry = exec_price - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'open_long', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "open_long", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) executed_trades_count += 1 if executed_trades_count <= 10: - logger.info(f"Trade #{executed_trades_count}: open_long @ {timestamp}, price={exec_price:.4f}, shares={shares:.4f}") + logger.info( + f"Trade #{executed_trades_count}: open_long @ {timestamp}, price={exec_price:.4f}, shares={shares:.4f}" + ) pending_signal = None - - elif pending_signal == 'close_long' and position > 0: + + elif pending_signal == "close_long" and position > 0: exec_price = open_ * (1 - slippage) commission_fee = position * exec_price * commission profit = (exec_price - entry_price) * position - commission_fee @@ -1238,14 +1313,16 @@ class BacktestService: if capital < 0: capital = 0 total_commission_paid += commission_fee - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long', - 'price': round(exec_price, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long", + "price": round(exec_price, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None highest_since_entry = None @@ -1255,11 +1332,11 @@ class BacktestService: if capital < min_capital_to_trade: is_liquidated = True capital = 0 - + # open_short: In both mode, first close long if any, then open short - elif pending_signal == 'open_short' and (position == 0 or (both_mode_active and position > 0)): + elif pending_signal == "open_short" and (position == 0 or (both_mode_active and position > 0)): exec_price = open_ * (1 - slippage) - + # If in both mode and have long position, close it first if both_mode_active and position > 0: close_price = open_ * (1 - slippage) @@ -1269,26 +1346,30 @@ class BacktestService: if capital < 0: capital = 0 total_commission_paid += close_commission - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long', - 'price': round(close_price, 4), - 'amount': round(position, 4), - 'profit': round(close_profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long", + "price": round(close_price, 4), + "amount": round(position, 4), + "profit": round(close_profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None executed_trades_count += 1 if executed_trades_count <= 10: - logger.info(f"Trade #{executed_trades_count}: close_long (before open_short) @ {timestamp}, price={close_price:.4f}, profit={close_profit:.2f}") + logger.info( + f"Trade #{executed_trades_count}: close_long (before open_short) @ {timestamp}, price={close_price:.4f}, profit={close_profit:.2f}" + ) # Check whether the position is liquidated if capital < min_capital_to_trade: is_liquidated = True capital = 0 pending_signal = None continue - + # Now open short use_capital = capital * entry_pct_cfg if exec_price > 0: @@ -1302,23 +1383,27 @@ class BacktestService: total_commission_paid += commission_fee position = -shares entry_price = exec_price - position_type = 'short' + position_type = "short" highest_since_entry = exec_price lowest_since_entry = exec_price - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'open_short', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "open_short", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) executed_trades_count += 1 if executed_trades_count <= 10: - logger.info(f"Trade #{executed_trades_count}: open_short @ {timestamp}, price={exec_price:.4f}, shares={shares:.4f}") + logger.info( + f"Trade #{executed_trades_count}: open_short @ {timestamp}, price={exec_price:.4f}, shares={shares:.4f}" + ) pending_signal = None - - elif pending_signal == 'close_short' and position < 0: + + elif pending_signal == "close_short" and position < 0: shares = abs(position) exec_price = open_ * (1 + slippage) commission_fee = shares * exec_price * commission @@ -1327,14 +1412,16 @@ class BacktestService: if capital < 0: capital = 0 total_commission_paid += commission_fee - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None highest_since_entry = None @@ -1344,7 +1431,7 @@ class BacktestService: if capital < min_capital_to_trade: is_liquidated = True capital = 0 - + # Calculate current equity if position > 0: unrealized = (close - entry_price) * position @@ -1355,33 +1442,40 @@ class BacktestService: current_equity = capital + unrealized else: current_equity = capital - - equity_curve.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'value': round(max(0, current_equity), 2) - }) - + + equity_curve.append( + {"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": round(max(0, current_equity), 2)} + ) + # Summary log - logger.info(f"MTF simulation complete: executed_trades={executed_trades_count}, total_trades_recorded={len(trades)}, final_capital={capital:.2f}, final_position={position}") + logger.info( + f"MTF simulation complete: executed_trades={executed_trades_count}, total_trades_recorded={len(trades)}, final_capital={capital:.2f}, final_position={position}" + ) if len(trades) == 0: if len(signal_queue) == 0: - logger.error(f"No trades executed because signal queue is empty! This usually means:") + logger.error("No trades executed because signal queue is empty! This usually means:") logger.error(" 1. Indicator code did not generate any buy/sell signals") logger.error(" 2. Signal index mismatch between indicator output and df_signal") logger.error(" 3. All signal values are False") - raise ValueError("No signals generated by indicator code. Please check your indicator code to ensure it sets df['buy'] and/or df['sell'] columns with boolean values.") + raise ValueError( + "No signals generated by indicator code. Please check your indicator code to ensure it sets df['buy'] and/or df['sell'] columns with boolean values." + ) else: - logger.error(f"No trades executed despite {len(signal_queue)} signals in queue. signal_queue_idx={signal_queue_idx}") + logger.error( + f"No trades executed despite {len(signal_queue)} signals in queue. signal_queue_idx={signal_queue_idx}" + ) logger.error(f" Signal queue processed: {signal_queue_idx}/{len(signal_queue)}") logger.error(f" Final position: {position}, Final capital: {capital:.2f}") logger.error(" This may indicate:") logger.error(" 1. Signal timing issues (signal effective time doesn't match execution timeframe)") logger.error(" 2. Position state conflicts (signals skipped due to position state)") logger.error(" 3. Capital insufficient for trading") - logger.error(f" First few signals: {signal_queue[:min(5, len(signal_queue))]}") + logger.error(f" First few signals: {signal_queue[: min(5, len(signal_queue))]}") logger.error(f" Exec data range: {df_exec.index[0]} to {df_exec.index[-1]}") - raise ValueError(f"No trades executed despite {len(signal_queue)} signals. Check signal timing and position state logic.") - + raise ValueError( + f"No trades executed despite {len(signal_queue)} signals. Check signal timing and position state logic." + ) + return equity_curve, trades, total_commission_paid def run_strategy_snapshot( @@ -1393,22 +1487,22 @@ class BacktestService: if not snapshot: raise ValueError("strategy snapshot is required") - code = snapshot.get('code') or '' - market = snapshot.get('market') or 'Crypto' - symbol = snapshot.get('symbol') or '' - timeframe = snapshot.get('timeframe') or '1D' - initial_capital = float(snapshot.get('initial_capital') or 10000) - commission = float(snapshot.get('commission') or 0) - slippage = float(snapshot.get('slippage') or 0) - leverage = int(snapshot.get('leverage') or 1) - trade_direction = str(snapshot.get('trade_direction') or 'long') - strategy_config = snapshot.get('strategy_config') or {} - indicator_params = snapshot.get('indicator_params') or {} - indicator_id = snapshot.get('indicator_id') - user_id = int(snapshot.get('user_id') or 1) - run_type = str(snapshot.get('run_type') or 'strategy_indicator') + code = snapshot.get("code") or "" + market = snapshot.get("market") or "Crypto" + symbol = snapshot.get("symbol") or "" + timeframe = snapshot.get("timeframe") or "1D" + initial_capital = float(snapshot.get("initial_capital") or 10000) + commission = float(snapshot.get("commission") or 0) + slippage = float(snapshot.get("slippage") or 0) + leverage = int(snapshot.get("leverage") or 1) + trade_direction = str(snapshot.get("trade_direction") or "long") + strategy_config = snapshot.get("strategy_config") or {} + indicator_params = snapshot.get("indicator_params") or {} + indicator_id = snapshot.get("indicator_id") + user_id = int(snapshot.get("user_id") or 1) + run_type = str(snapshot.get("run_type") or "strategy_indicator") - if run_type == 'strategy_script': + if run_type == "strategy_script": return self._run_script_strategy( code=code, market=market, @@ -1424,7 +1518,7 @@ class BacktestService: strategy_config=strategy_config, ) - if bool(snapshot.get('enable_mtf')) and str(market).lower() in ['crypto', 'cryptocurrency']: + if bool(snapshot.get("enable_mtf")) and str(market).lower() in ["crypto", "cryptocurrency"]: result = self.run_multi_timeframe( indicator_code=code, market=market, @@ -1461,11 +1555,11 @@ class BacktestService: user_id=user_id, indicator_id=indicator_id, ) - result['precision_info'] = { - 'enabled': False, - 'timeframe': timeframe, - 'precision': 'standard', - 'message': 'Using standard strategy backtest' + result["precision_info"] = { + "enabled": False, + "timeframe": timeframe, + "precision": "standard", + "message": "Using standard strategy backtest", } return result @@ -1489,37 +1583,37 @@ class BacktestService: if df.empty: raise ValueError("No candle data available in the backtest date range") - signals = self._execute_script_strategy(code, df, { - 'initial_capital': initial_capital, - 'leverage': leverage, - 'trade_direction': trade_direction, - 'strategy_config': strategy_config or {}, - }) + signals = self._execute_script_strategy( + code, + df, + { + "initial_capital": initial_capital, + "leverage": leverage, + "trade_direction": trade_direction, + "strategy_config": strategy_config or {}, + }, + ) equity_curve, trades, total_commission = self._simulate_trading( df, signals, initial_capital, commission, slippage, leverage, trade_direction, strategy_config ) - metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission) + metrics = self._calculate_metrics( + equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission + ) result = self._format_result(metrics, equity_curve, trades) - result['precision_info'] = { - 'enabled': False, - 'timeframe': timeframe, - 'precision': 'standard', - 'message': 'Using standard strategy script backtest' + result["precision_info"] = { + "enabled": False, + "timeframe": timeframe, + "precision": "standard", + "message": "Using standard strategy script backtest", } - result['executionAssumptions'] = self._execution_assumptions( + result["executionAssumptions"] = self._execution_assumptions( strategy_config, - simulation_mode='standard', + simulation_mode="standard", signal_timeframe=timeframe, ) return result - - def run_code_strategy( - self, - code: str, - symbol: str, - timeframe: str, - limit: int = 1000 - ) -> Dict[str, Any]: + + def run_code_strategy(self, code: str, symbol: str, timeframe: str, limit: int = 1000) -> Dict[str, Any]: """ Run strategy code and return the 'output' variable defined in code. Used for signal bot preview functionality. @@ -1528,41 +1622,45 @@ class BacktestService: end_date = datetime.now() tf_seconds = self.TIMEFRAME_SECONDS.get(timeframe, 3600) start_date = end_date - timedelta(seconds=tf_seconds * limit) - + # 2. Fetch data (assuming market='crypto', can be optimized later) - df = self._fetch_kline_data('crypto', symbol, timeframe, start_date, end_date) - + df = self._fetch_kline_data("crypto", symbol, timeframe, start_date, end_date) + if df.empty: return {"error": "No data found"} # 3. Prepare execution environment local_vars = { - 'df': df.copy(), - 'np': np, - 'pd': pd, - 'output': {} # Default empty output + "df": df.copy(), + "np": np, + "pd": pd, + "output": {}, # Default empty output } - + # 4. Execute code try: import builtins + def safe_import(name, *args, **kwargs): - allowed = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time'] - if name in allowed or name.split('.')[0] in allowed: + allowed = ["numpy", "pandas", "math", "json", "datetime", "time"] + if name in allowed or name.split(".")[0] in allowed: return builtins.__import__(name, *args, **kwargs) raise ImportError(f"Import not allowed: {name}") - - safe_builtins = {k: getattr(builtins, k) for k in dir(builtins) - if not k.startswith('_') and k not in ['eval', 'exec', 'compile', 'open', 'input', 'exit']} - safe_builtins['__import__'] = safe_import - + + safe_builtins = { + k: getattr(builtins, k) + for k in dir(builtins) + if not k.startswith("_") and k not in ["eval", "exec", "compile", "open", "input", "exit"] + } + safe_builtins["__import__"] = safe_import + exec_env = local_vars.copy() - exec_env['__builtins__'] = safe_builtins - + exec_env["__builtins__"] = safe_builtins + exec(code, exec_env) - - return exec_env.get('output', {}) - + + return exec_env.get("output", {}) + except Exception as e: logger.error(f"Strategy execution failed: {e}") logger.error(traceback.format_exc()) @@ -1580,7 +1678,7 @@ class BacktestService: commission: float = 0.001, slippage: float = 0.0, # Ideal backtest environment, no slippage leverage: int = 1, - trade_direction: str = 'long', + trade_direction: str = "long", strategy_config: Optional[Dict[str, Any]] = None, indicator_params: Optional[Dict[str, Any]] = None, user_id: int = 1, @@ -1588,7 +1686,7 @@ class BacktestService: ) -> Dict[str, Any]: """ Run backtest. - + Args: indicator_code: Indicator code market: Market type @@ -1599,121 +1697,118 @@ class BacktestService: initial_capital: Initial capital commission: Commission rate slippage: Slippage - + Returns: Backtest result """ - + # 1. Fetch candle data df = self._fetch_kline_data(market, symbol, timeframe, start_date, end_date) if df.empty: raise ValueError("No candle data available in the backtest date range") - - + # 2. Execute indicator code to get signals (pass backtest params) backtest_params = { - 'leverage': leverage, - 'initial_capital': initial_capital, - 'commission': commission, - 'trade_direction': trade_direction, - 'indicator_params': indicator_params or {}, - 'user_id': user_id, - 'indicator_id': indicator_id, + "leverage": leverage, + "initial_capital": initial_capital, + "commission": commission, + "trade_direction": trade_direction, + "indicator_params": indicator_params or {}, + "user_id": user_id, + "indicator_id": indicator_id, } signals = self._execute_indicator(indicator_code, df, backtest_params) - + # 3. Simulate trading equity_curve, trades, total_commission = self._simulate_trading( df, signals, initial_capital, commission, slippage, leverage, trade_direction, strategy_config ) - + # 4. Calculate metrics - metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission) - + metrics = self._calculate_metrics( + equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission + ) + # 5. Format result result = self._format_result(metrics, equity_curve, trades) - result['executionAssumptions'] = self._execution_assumptions( + result["executionAssumptions"] = self._execution_assumptions( strategy_config, - simulation_mode='standard', + simulation_mode="standard", signal_timeframe=timeframe, ) return result - + def _fetch_kline_data( - self, - market: str, - symbol: str, - timeframe: str, - start_date: datetime, - end_date: datetime + self, market: str, symbol: str, timeframe: str, start_date: datetime, end_date: datetime ) -> pd.DataFrame: """Fetch candle data and convert to DataFrame""" # Calculate required candle count total_seconds = (end_date - start_date).total_seconds() tf_seconds = self.TIMEFRAME_SECONDS.get(timeframe, 86400) limit = math.ceil(total_seconds / tf_seconds) + 200 - + # Calculate before_time (end date + 1 day) before_time = int((end_date + timedelta(days=1)).timestamp()) - - + # Fetch data kline_data = DataSourceFactory.get_kline( - market=market, - symbol=symbol, - timeframe=timeframe, - limit=limit, - before_time=before_time + market=market, symbol=symbol, timeframe=timeframe, limit=limit, before_time=before_time ) - + if not kline_data: - logger.warning(f"No candle data retrieved for {market}:{symbol}, timeframe={timeframe}, limit={limit}, before_time={before_time}") + logger.warning( + f"No candle data retrieved for {market}:{symbol}, timeframe={timeframe}, limit={limit}, before_time={before_time}" + ) return pd.DataFrame() - + logger.info(f"Retrieved {len(kline_data)} candles for {market}:{symbol}, timeframe={timeframe}") - + # Convert to DataFrame try: df = pd.DataFrame(kline_data) if df.empty: - logger.warning(f"DataFrame is empty after conversion") + logger.warning("DataFrame is empty after conversion") return pd.DataFrame() - + # Handle time column - could be seconds or milliseconds - if 'time' not in df.columns: + if "time" not in df.columns: logger.error(f"Missing 'time' column in kline data. Columns: {df.columns.tolist()}") return pd.DataFrame() - + # Try seconds first, if fails try milliseconds try: - df['time'] = pd.to_datetime(df['time'], unit='s') + df["time"] = pd.to_datetime(df["time"], unit="s") except (ValueError, OverflowError): # If seconds fails, try milliseconds try: - df['time'] = pd.to_datetime(df['time'], unit='ms') + df["time"] = pd.to_datetime(df["time"], unit="ms") except (ValueError, OverflowError): # If both fail, try direct conversion - df['time'] = pd.to_datetime(df['time']) - - df = df.set_index('time') - + df["time"] = pd.to_datetime(df["time"]) + + df = df.set_index("time") + if df.empty: - logger.warning(f"DataFrame is empty after setting time index") + logger.warning("DataFrame is empty after setting time index") return pd.DataFrame() - + # Log data range before filtering data_start = df.index.min() data_end = df.index.max() logger.info(f"Kline data range: {data_start} to {data_end}, requested range: {start_date} to {end_date}") - + # Check if requested range is within available data if data_start > start_date: - logger.warning(f"Requested start date {start_date} is before available data start {data_start}. " - f"Using available start date instead.") + logger.warning( + f"Requested start date {start_date} is before available data start {data_start}. " + f"Using available start date instead." + ) if data_end < end_date: - logger.warning(f"Requested end date {end_date} is after available data end {data_end}. " - f"Using available end date instead. This may affect backtest results.") - + logger.warning( + f"Requested end date {end_date} is after available data end {data_end}. " + f"Using available end date instead. This may affect backtest results." + ) + # Filter date range (use available data range if requested range is outside) # If data ends before requested end_date, use the most recent data up to the requested limit if data_end < end_date: @@ -1731,30 +1826,36 @@ class BacktestService: df_filtered = df.copy() effective_start = data_start effective_end = data_end - logger.warning(f"Available data ({len(df)} candles) is less than requested ({requested_candles} candles). " - f"Using all available data from {effective_start} to {effective_end}") + logger.warning( + f"Available data ({len(df)} candles) is less than requested ({requested_candles} candles). " + f"Using all available data from {effective_start} to {effective_end}" + ) else: # Normal case: filter by requested date range effective_start = max(start_date, data_start) effective_end = min(end_date, data_end) df_filtered = df[(df.index >= effective_start) & (df.index <= effective_end)].copy() - + if df_filtered.empty: - logger.error(f"After filtering date range ({effective_start} to {effective_end}), no data remains. " - f"Available data range: {data_start} to {data_end}, requested: {start_date} to {end_date}") + logger.error( + f"After filtering date range ({effective_start} to {effective_end}), no data remains. " + f"Available data range: {data_start} to {data_end}, requested: {start_date} to {end_date}" + ) return pd.DataFrame() - - logger.info(f"After filtering: {len(df_filtered)} candles remain for backtest (effective range: {effective_start} to {effective_end})") + + logger.info( + f"After filtering: {len(df_filtered)} candles remain for backtest (effective range: {effective_start} to {effective_end})" + ) return df_filtered - + except Exception as e: logger.error(f"Error processing kline data: {str(e)}") logger.error(traceback.format_exc()) return pd.DataFrame() - + def _execute_indicator(self, code: str, df: pd.DataFrame, backtest_params: dict = None): """Execute indicator code to get signals. - + Args: code: Indicator code df: Candle data @@ -1764,180 +1865,199 @@ class BacktestService: # - Preferred (simple): df['buy'], df['sell'] as boolean # - Backtest/internal (4-way): df['open_long'], df['close_long'], df['open_short'], df['close_short'] as boolean signals = pd.Series(0, index=df.index) - + try: # Reset DatetimeIndex to integer so user code can use df.at[0, ...] or df.iloc[0, ...] df_for_exec = df.copy() if isinstance(df_for_exec.index, pd.DatetimeIndex): df_for_exec = df_for_exec.reset_index(drop=False) - if 'time' not in df_for_exec.columns: - df_for_exec.rename(columns={df_for_exec.columns[0]: 'time'}, inplace=True) + if "time" not in df_for_exec.columns: + df_for_exec.rename(columns={df_for_exec.columns[0]: "time"}, inplace=True) local_vars = { - 'df': df_for_exec, - 'open': df_for_exec['open'], - 'high': df_for_exec['high'], - 'low': df_for_exec['low'], - 'close': df_for_exec['close'], - 'volume': df_for_exec['volume'], - 'signals': pd.Series(0, index=df_for_exec.index), - 'np': np, - 'pd': pd, + "df": df_for_exec, + "open": df_for_exec["open"], + "high": df_for_exec["high"], + "low": df_for_exec["low"], + "close": df_for_exec["close"], + "volume": df_for_exec["volume"], + "signals": pd.Series(0, index=df_for_exec.index), + "np": np, + "pd": pd, } - + # Add backtest params to execution environment (if provided) if backtest_params: - local_vars['backtest_params'] = backtest_params - local_vars['leverage'] = backtest_params.get('leverage', 1) - local_vars['initial_capital'] = backtest_params.get('initial_capital', 10000) - local_vars['commission'] = backtest_params.get('commission', 0.0002) - local_vars['trade_direction'] = backtest_params.get('trade_direction', 'both') - + local_vars["backtest_params"] = backtest_params + local_vars["leverage"] = backtest_params.get("leverage", 1) + local_vars["initial_capital"] = backtest_params.get("initial_capital", 10000) + local_vars["commission"] = backtest_params.get("commission", 0.0002) + local_vars["trade_direction"] = backtest_params.get("trade_direction", "both") + # === Indicator parameter support === # Get the indicator parameters set by the user from backtest_params - user_indicator_params = (backtest_params or {}).get('indicator_params', {}) + user_indicator_params = (backtest_params or {}).get("indicator_params", {}) # Parse the parameters declared in the indicator code declared_params = IndicatorParamsParser.parse_params(code) # Merge parameters (user values ​​take precedence, otherwise default values ​​are used) merged_params = IndicatorParamsParser.merge_params(declared_params, user_indicator_params) - local_vars['params'] = merged_params - + local_vars["params"] = merged_params + # === Indicator caller support === - user_id = (backtest_params or {}).get('user_id', 1) - indicator_id = (backtest_params or {}).get('indicator_id') + user_id = (backtest_params or {}).get("user_id", 1) + indicator_id = (backtest_params or {}).get("indicator_id") indicator_caller = IndicatorCaller(user_id, indicator_id) - local_vars['call_indicator'] = indicator_caller.call_indicator - + local_vars["call_indicator"] = indicator_caller.call_indicator + # Add technical indicator functions local_vars.update(self._get_indicator_functions()) - + # Add safe builtins (keep full builtins to support lambda etc.) # but remove dangerous functions like eval, exec, open etc. import builtins - + # Create restricted __import__ that only allows safe modules def safe_import(name, *args, **kwargs): """Only allow importing numpy, pandas, math, json etc.""" - allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time'] - if name in allowed_modules or name.split('.')[0] in allowed_modules: + allowed_modules = ["numpy", "pandas", "math", "json", "datetime", "time"] + if name in allowed_modules or name.split(".")[0] in allowed_modules: return builtins.__import__(name, *args, **kwargs) raise ImportError(f"Import not allowed: {name}") - - safe_builtins = {k: getattr(builtins, k) for k in dir(builtins) - if not k.startswith('_') and k not in [ - 'eval', 'exec', 'compile', 'open', 'input', - 'help', 'exit', 'quit', - 'copyright', 'credits', 'license' - ]} - + + safe_builtins = { + k: getattr(builtins, k) + for k in dir(builtins) + if not k.startswith("_") + and k + not in [ + "eval", + "exec", + "compile", + "open", + "input", + "help", + "exit", + "quit", + "copyright", + "credits", + "license", + ] + } + # Add restricted __import__ - safe_builtins['__import__'] = safe_import - + safe_builtins["__import__"] = safe_import + # Create unified execution environment (globals and locals use same dict) # This allows functions to access np, pd etc. exec_env = local_vars.copy() - exec_env['__builtins__'] = safe_builtins - + exec_env["__builtins__"] = safe_builtins + # Pre-execute import statements to ensure np and pd are available pre_import_code = """ import numpy as np import pandas as pd """ exec(pre_import_code, exec_env) - + # Security check: validate code doesn't contain dangerous operations from app.utils.safe_exec import validate_code_safety + is_safe, error_msg = validate_code_safety(code) if not is_safe: logger.error(f"Backtest code security check failed: {error_msg}") raise ValueError(f"Code contains unsafe operations: {error_msg}") - + # Execute user code safely (with timeout) from app.utils.safe_exec import safe_exec_code + exec_result = safe_exec_code( code=code, exec_globals=exec_env, exec_locals=exec_env, - timeout=60 # Backtest allows longer time (60 seconds) + timeout=60, # Backtest allows longer time (60 seconds) ) - - if not exec_result['success']: + + if not exec_result["success"]: raise RuntimeError(f"Code execution failed: {exec_result['error']}") - + # Get the executed df, restore DatetimeIndex for signal alignment - executed_df = exec_env.get('df', df) + executed_df = exec_env.get("df", df) if isinstance(df.index, pd.DatetimeIndex) and not isinstance(executed_df.index, pd.DatetimeIndex): - if 'time' in executed_df.columns: - executed_df = executed_df.set_index('time') + if "time" in executed_df.columns: + executed_df = executed_df.set_index("time") elif len(executed_df) == len(df): executed_df.index = df.index # Validation: if chart signals are provided, df['buy']/df['sell'] must exist for backtest normalization. # This keeps indicator scripts simple and consistent (chart=buy/sell, execution=normalized in backend). - output_obj = exec_env.get('output') - has_output_signals = isinstance(output_obj, dict) and isinstance(output_obj.get('signals'), list) and len(output_obj.get('signals')) > 0 - if has_output_signals and not all(col in executed_df.columns for col in ['buy', 'sell']): + output_obj = exec_env.get("output") + has_output_signals = ( + isinstance(output_obj, dict) + and isinstance(output_obj.get("signals"), list) + and len(output_obj.get("signals")) > 0 + ) + if has_output_signals and not all(col in executed_df.columns for col in ["buy", "sell"]): raise ValueError( "Invalid indicator script: output['signals'] is provided, but df['buy'] and df['sell'] are missing. " "Please set df['buy'] and df['sell'] as boolean columns (len == len(df))." ) - + # Extract signals from executed df - if all(col in executed_df.columns for col in ['open_long', 'close_long', 'open_short', 'close_short']): - + if all(col in executed_df.columns for col in ["open_long", "close_long", "open_short", "close_short"]): signals = { - 'open_long': executed_df['open_long'].fillna(False).astype(bool), - 'close_long': executed_df['close_long'].fillna(False).astype(bool), - 'open_short': executed_df['open_short'].fillna(False).astype(bool), - 'close_short': executed_df['close_short'].fillna(False).astype(bool) + "open_long": executed_df["open_long"].fillna(False).astype(bool), + "close_long": executed_df["close_long"].fillna(False).astype(bool), + "open_short": executed_df["open_short"].fillna(False).astype(bool), + "close_short": executed_df["close_short"].fillna(False).astype(bool), } - + # Convention: backtest uses 4-way signals only. # Position sizing, TP/SL, trailing, etc must be handled by strategy_config / strategy logic. - elif all(col in executed_df.columns for col in ['buy', 'sell']): + elif all(col in executed_df.columns for col in ["buy", "sell"]): # Simple buy/sell signals (recommended for indicator authors) - buy_series = executed_df['buy'].fillna(False).astype(bool) - sell_series = executed_df['sell'].fillna(False).astype(bool) - + buy_series = executed_df["buy"].fillna(False).astype(bool) + sell_series = executed_df["sell"].fillna(False).astype(bool) + # Ensure signals have the same index as df if not buy_series.index.equals(df.index): - logger.warning(f"Buy signal index mismatch in _execute_indicator! Reindexing...") + logger.warning("Buy signal index mismatch in _execute_indicator! Reindexing...") buy_series = buy_series.reindex(df.index, fill_value=False) if not sell_series.index.equals(df.index): - logger.warning(f"Sell signal index mismatch in _execute_indicator! Reindexing...") + logger.warning("Sell signal index mismatch in _execute_indicator! Reindexing...") sell_series = sell_series.reindex(df.index, fill_value=False) - + # Debug: log signal statistics buy_count = buy_series.sum() sell_count = sell_series.sum() - logger.info(f"Indicator execution: buy signals={buy_count}, sell signals={sell_count}, total_candles={len(df)}") - - signals = { - 'buy': buy_series, - 'sell': sell_series - } - + logger.info( + f"Indicator execution: buy signals={buy_count}, sell signals={sell_count}, total_candles={len(df)}" + ) + + signals = {"buy": buy_series, "sell": sell_series} + else: raise ValueError( "Indicator must define either 4-way columns " "(df['open_long'], df['close_long'], df['open_short'], df['close_short']) " "or simple columns (df['buy'], df['sell'])." ) - + except Exception as e: logger.error(f"Indicator code execution error: {e}") logger.error(traceback.format_exc()) - + return signals - def _execute_script_strategy(self, code: str, df: pd.DataFrame, runtime: Optional[Dict[str, Any]] = None) -> Dict[str, pd.Series]: + def _execute_script_strategy( + self, code: str, df: pd.DataFrame, runtime: Optional[Dict[str, Any]] = None + ) -> Dict[str, pd.Series]: runtime = runtime or {} if not code or not str(code).strip(): raise ValueError("Strategy script is empty") df_exec = df.copy().reset_index(drop=False) - if 'time' not in df_exec.columns: - df_exec.rename(columns={df_exec.columns[0]: 'time'}, inplace=True) + if "time" not in df_exec.columns: + df_exec.rename(columns={df_exec.columns[0]: "time"}, inplace=True) open_long = pd.Series(False, index=df.index) close_long = pd.Series(False, index=df.index) @@ -1965,13 +2085,13 @@ import pandas as pd raise AttributeError(name) from exc def __bool__(self) -> bool: - return bool(self.get('side')) and float(self.get('size') or 0) > 0 + return bool(self.get("side")) and float(self.get("size") or 0) > 0 def __int__(self) -> int: - return int(self.get('direction') or 0) + return int(self.get("direction") or 0) def __float__(self) -> float: - return float(self.get('direction') or 0) + return float(self.get("direction") or 0) def __eq__(self, other: Any) -> bool: try: @@ -1993,40 +2113,46 @@ import pandas as pd def clear_position(self) -> None: self.clear() - self.update({ - 'side': '', - 'size': 0.0, - 'entry_price': 0.0, - 'direction': 0, - 'amount': 0.0, - }) + self.update( + { + "side": "", + "size": 0.0, + "entry_price": 0.0, + "direction": 0, + "amount": 0.0, + } + ) def open_position(self, side: str, entry_price: float, amount: float) -> None: - direction = 1 if side == 'long' else (-1 if side == 'short' else 0) + direction = 1 if side == "long" else (-1 if side == "short" else 0) size = float(amount or 0.0) price = float(entry_price or 0.0) self.clear() - self.update({ - 'side': side, - 'size': size, - 'entry_price': price, - 'direction': direction, - 'amount': size, - }) + self.update( + { + "side": side, + "size": size, + "entry_price": price, + "direction": direction, + "amount": size, + } + ) def add_position(self, entry_price: float, amount: float) -> None: extra = float(amount or 0.0) if extra <= 0: return - current_size = float(self.get('size') or 0.0) - current_price = float(self.get('entry_price') or 0.0) + current_size = float(self.get("size") or 0.0) + current_price = float(self.get("entry_price") or 0.0) next_size = current_size + extra next_price = float(entry_price or current_price or 0.0) if current_size > 0 and current_price > 0 and next_size > 0: - next_price = ((current_price * current_size) + (float(entry_price or current_price) * extra)) / next_size - self['size'] = next_size - self['amount'] = next_size - self['entry_price'] = next_price + next_price = ( + (current_price * current_size) + (float(entry_price or current_price) * extra) + ) / next_size + self["size"] = next_size + self["amount"] = next_size + self["entry_price"] = next_price class ScriptBacktestContext: def __init__(self, bars_df: pd.DataFrame, initial_balance: float): @@ -2047,93 +2173,96 @@ import pandas as pd def bars(self, n: int = 1): start = max(0, self.current_index - int(n) + 1) out = [] - for _, row in self._bars_df.iloc[start:self.current_index + 1].iterrows(): - out.append(ScriptBar( - open=float(row.get('open') or 0), - high=float(row.get('high') or 0), - low=float(row.get('low') or 0), - close=float(row.get('close') or 0), - volume=float(row.get('volume') or 0), - timestamp=row.get('time') - )) + for _, row in self._bars_df.iloc[start : self.current_index + 1].iterrows(): + out.append( + ScriptBar( + open=float(row.get("open") or 0), + high=float(row.get("high") or 0), + low=float(row.get("low") or 0), + close=float(row.get("close") or 0), + volume=float(row.get("volume") or 0), + timestamp=row.get("time"), + ) + ) return out def log(self, message: Any): self._logs.append(str(message)) def buy(self, price: Any = None, amount: Any = None): - self._orders.append({'action': 'buy', 'price': price, 'amount': amount}) + self._orders.append({"action": "buy", "price": price, "amount": amount}) def sell(self, price: Any = None, amount: Any = None): - self._orders.append({'action': 'sell', 'price': price, 'amount': amount}) + self._orders.append({"action": "sell", "price": price, "amount": amount}) def close_position(self): - self._orders.append({'action': 'close'}) + self._orders.append({"action": "close"}) try: import builtins def safe_import(name, *args, **kwargs): - allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time'] - if name in allowed_modules or name.split('.')[0] in allowed_modules: + allowed_modules = ["numpy", "pandas", "math", "json", "datetime", "time"] + if name in allowed_modules or name.split(".")[0] in allowed_modules: return builtins.__import__(name, *args, **kwargs) raise ImportError(f"Import not allowed: {name}") - safe_builtins = {k: getattr(builtins, k) for k in dir(builtins) - if not k.startswith('_') and k not in ['eval', 'exec', 'compile', 'open', 'input', 'help', 'exit', 'quit']} - safe_builtins['__import__'] = safe_import + safe_builtins = { + k: getattr(builtins, k) + for k in dir(builtins) + if not k.startswith("_") + and k not in ["eval", "exec", "compile", "open", "input", "help", "exit", "quit"] + } + safe_builtins["__import__"] = safe_import - ctx = ScriptBacktestContext(df_exec, float(runtime.get('initial_capital') or 10000)) + ctx = ScriptBacktestContext(df_exec, float(runtime.get("initial_capital") or 10000)) exec_env = { - '__builtins__': safe_builtins, - 'np': np, - 'pd': pd, + "__builtins__": safe_builtins, + "np": np, + "pd": pd, } from app.utils.safe_exec import validate_code_safety + is_safe, error_msg = validate_code_safety(code) if not is_safe: raise ValueError(f"Code contains unsafe operations: {error_msg}") from app.utils.safe_exec import safe_exec_code - exec_result = safe_exec_code( - code=code, - exec_globals=exec_env, - exec_locals=exec_env, - timeout=60 - ) - if not exec_result['success']: + + exec_result = safe_exec_code(code=code, exec_globals=exec_env, exec_locals=exec_env, timeout=60) + if not exec_result["success"]: raise RuntimeError(f"Code execution failed: {exec_result['error']}") - on_init = exec_env.get('on_init') - on_bar = exec_env.get('on_bar') + on_init = exec_env.get("on_init") + on_bar = exec_env.get("on_bar") if not callable(on_bar): raise ValueError("Strategy script must define on_bar(ctx, bar)") if callable(on_init): on_init(ctx) - trade_direction = str(runtime.get('trade_direction') or 'both').lower() - if trade_direction not in ('long', 'short', 'both'): - trade_direction = 'both' + trade_direction = str(runtime.get("trade_direction") or "both").lower() + if trade_direction not in ("long", "short", "both"): + trade_direction = "both" for i, row in df_exec.iterrows(): ctx.current_index = int(i) ctx._orders = [] bar = ScriptBar( - open=float(row.get('open') or 0), - high=float(row.get('high') or 0), - low=float(row.get('low') or 0), - close=float(row.get('close') or 0), - volume=float(row.get('volume') or 0), - timestamp=row.get('time') + open=float(row.get("open") or 0), + high=float(row.get("high") or 0), + low=float(row.get("low") or 0), + close=float(row.get("close") or 0), + volume=float(row.get("volume") or 0), + timestamp=row.get("time"), ) on_bar(ctx, bar) for order in ctx._orders: - action = str(order.get('action') or '').lower() - order_price = float(order.get('price') or bar['close'] or 0) - order_amount = float(order.get('amount') or 0) - if action == 'close': + action = str(order.get("action") or "").lower() + order_price = float(order.get("price") or bar["close"] or 0) + order_amount = float(order.get("amount") or 0) + if action == "close": if ctx.position > 0: close_long.iloc[i] = True ctx.position.clear_position() @@ -2142,59 +2271,60 @@ import pandas as pd ctx.position.clear_position() continue - if action == 'buy': + if action == "buy": if ctx.position < 0: close_short.iloc[i] = True ctx.position.clear_position() - if trade_direction in ('long', 'both'): + if trade_direction in ("long", "both"): if ctx.position == 0: open_long.iloc[i] = True - ctx.position.open_position('long', order_price, order_amount) + ctx.position.open_position("long", order_price, order_amount) else: add_long.iloc[i] = True ctx.position.add_position(order_price, order_amount) continue - if action == 'sell': + if action == "sell": if ctx.position > 0: close_long.iloc[i] = True ctx.position.clear_position() - if trade_direction in ('short', 'both'): + if trade_direction in ("short", "both"): if ctx.position == 0: open_short.iloc[i] = True - ctx.position.open_position('short', order_price, order_amount) + ctx.position.open_position("short", order_price, order_amount) else: add_short.iloc[i] = True ctx.position.add_position(order_price, order_amount) return { - 'open_long': open_long, - 'close_long': close_long, - 'open_short': open_short, - 'close_short': close_short, - 'add_long': add_long, - 'add_short': add_short, + "open_long": open_long, + "close_long": close_long, + "open_short": open_short, + "close_short": close_short, + "add_long": add_long, + "add_short": add_short, } except Exception as e: logger.error(f"Strategy script execution error: {e}") logger.error(traceback.format_exc()) raise - + def _get_indicator_functions(self) -> Dict: """Get technical indicator functions""" + def SMA(series, period): return series.rolling(window=period).mean() - + def EMA(series, period): return series.ewm(span=period, adjust=False).mean() - + def RSI(series, period=14): delta = series.diff() gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() rs = gain / loss return 100 - (100 / (1 + rs)) - + def MACD(series, fast=12, slow=26, signal=9): exp1 = series.ewm(span=fast, adjust=False).mean() exp2 = series.ewm(span=slow, adjust=False).mean() @@ -2202,38 +2332,38 @@ import pandas as pd macd_signal = macd.ewm(span=signal, adjust=False).mean() macd_hist = macd - macd_signal return macd, macd_signal, macd_hist - + def BOLL(series, period=20, std_dev=2): middle = series.rolling(window=period).mean() std = series.rolling(window=period).std() upper = middle + std_dev * std lower = middle - std_dev * std return upper, middle, lower - + def ATR(high, low, close, period=14): tr1 = high - low tr2 = abs(high - close.shift()) tr3 = abs(low - close.shift()) tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) return tr.rolling(window=period).mean() - + def CROSSOVER(series1, series2): return (series1 > series2) & (series1.shift(1) <= series2.shift(1)) - + def CROSSUNDER(series1, series2): return (series1 < series2) & (series1.shift(1) >= series2.shift(1)) - + return { - 'SMA': SMA, - 'EMA': EMA, - 'RSI': RSI, - 'MACD': MACD, - 'BOLL': BOLL, - 'ATR': ATR, - 'CROSSOVER': CROSSOVER, - 'CROSSUNDER': CROSSUNDER, + "SMA": SMA, + "EMA": EMA, + "RSI": RSI, + "MACD": MACD, + "BOLL": BOLL, + "ATR": ATR, + "CROSSOVER": CROSSOVER, + "CROSSUNDER": CROSSUNDER, } - + def _simulate_trading( self, df: pd.DataFrame, @@ -2242,12 +2372,12 @@ import pandas as pd commission: float, slippage: float, leverage: int = 1, - trade_direction: str = 'long', - strategy_config: Optional[Dict[str, Any]] = None + trade_direction: str = "long", + strategy_config: Optional[Dict[str, Any]] = None, ) -> tuple: """ Simulate trading. - + Args: signals: Signals, can be pd.Series (old format) or dict (new 4-way format) trade_direction: Trade direction @@ -2259,51 +2389,53 @@ import pandas as pd if not isinstance(signals, dict): raise ValueError("signals must be a dict (either 4-way or buy/sell).") - if all(k in signals for k in ['open_long', 'close_long', 'open_short', 'close_short']): + if all(k in signals for k in ["open_long", "close_long", "open_short", "close_short"]): norm = signals - elif all(k in signals for k in ['buy', 'sell']): - buy = signals['buy'].fillna(False).astype(bool) - sell = signals['sell'].fillna(False).astype(bool) + elif all(k in signals for k in ["buy", "sell"]): + buy = signals["buy"].fillna(False).astype(bool) + sell = signals["sell"].fillna(False).astype(bool) - td = (trade_direction or 'both') + td = trade_direction or "both" td = str(td).lower() - if td not in ['long', 'short', 'both']: - td = 'both' + if td not in ["long", "short", "both"]: + td = "both" # Mapping rules: # - long: buy=open_long, sell=close_long # - short: sell=open_short, buy=close_short # - both: buy=open_long+close_short, sell=open_short+close_long - if td == 'long': + if td == "long": norm = { - 'open_long': buy, - 'close_long': sell, - 'open_short': pd.Series([False] * len(df), index=df.index), - 'close_short': pd.Series([False] * len(df), index=df.index), + "open_long": buy, + "close_long": sell, + "open_short": pd.Series([False] * len(df), index=df.index), + "close_short": pd.Series([False] * len(df), index=df.index), } - elif td == 'short': + elif td == "short": norm = { - 'open_long': pd.Series([False] * len(df), index=df.index), - 'close_long': pd.Series([False] * len(df), index=df.index), - 'open_short': sell, - 'close_short': buy, - '_both_mode': False, + "open_long": pd.Series([False] * len(df), index=df.index), + "close_long": pd.Series([False] * len(df), index=df.index), + "open_short": sell, + "close_short": buy, + "_both_mode": False, } else: # Both mode: buy signal opens long (auto-close short first) # sell signal opens short (auto-close long first) norm = { - 'open_long': buy, - 'close_long': pd.Series([False] * len(df), index=df.index), # Disabled, handled by open_short - 'open_short': sell, - 'close_short': pd.Series([False] * len(df), index=df.index), # Disabled, handled by open_long - '_both_mode': True, # Flag to indicate auto-close opposing position + "open_long": buy, + "close_long": pd.Series([False] * len(df), index=df.index), # Disabled, handled by open_short + "open_short": sell, + "close_short": pd.Series([False] * len(df), index=df.index), # Disabled, handled by open_long + "_both_mode": True, # Flag to indicate auto-close opposing position } else: raise ValueError("signals dict must contain either 4-way keys or buy/sell keys.") - return self._simulate_trading_new_format(df, norm, initial_capital, commission, slippage, leverage, trade_direction, strategy_config) - + return self._simulate_trading_new_format( + df, norm, initial_capital, commission, slippage, leverage, trade_direction, strategy_config + ) + def _simulate_trading_new_format( self, df: pd.DataFrame, @@ -2312,12 +2444,12 @@ import pandas as pd commission: float, slippage: float, leverage: int = 1, - trade_direction: str = 'both', - strategy_config: Optional[Dict[str, Any]] = None + trade_direction: str = "both", + strategy_config: Optional[Dict[str, Any]] = None, ) -> tuple: """ Simulate trading with 4-way signal format (supports position management and scaling). - + Args: trade_direction: Trade direction ('long', 'short', 'both') """ @@ -2327,30 +2459,29 @@ import pandas as pd is_liquidated = False liquidation_price = 0 min_capital_to_trade = 1.0 # Below this balance, consider wiped out, no new orders - + capital = initial_capital position = 0 # Positive=long, Negative=short entry_price = 0 # Average entry price position_type = None # 'long' or 'short' - + # Position management related - has_position_management = 'add_long' in signals and 'add_short' in signals - position_batches = [] # Store each position batch: [{'price': xxx, 'amount': xxx}, ...] + has_position_management = "add_long" in signals and "add_short" in signals # --- Strategy config: signals + parameters = strategy (sent from BacktestModal as strategyConfig) --- cfg = strategy_config or {} - exec_cfg = cfg.get('execution') or {} + exec_cfg = cfg.get("execution") or {} # Signal confirmation / execution timing: # - bar_close: execute on the same bar close (more aggressive) # - next_bar_open: execute on next bar open after signal is confirmed on bar close (recommended, closer to live) - signal_timing = str(exec_cfg.get('signalTiming') or 'next_bar_open').strip().lower() - risk_cfg = cfg.get('risk') or {} - stop_loss_pct = float(risk_cfg.get('stopLossPct') or 0.0) - take_profit_pct = float(risk_cfg.get('takeProfitPct') or 0.0) - trailing_cfg = risk_cfg.get('trailing') or {} - trailing_enabled = bool(trailing_cfg.get('enabled')) - trailing_pct = float(trailing_cfg.get('pct') or 0.0) - trailing_activation_pct = float(trailing_cfg.get('activationPct') or 0.0) + signal_timing = str(exec_cfg.get("signalTiming") or "next_bar_open").strip().lower() + risk_cfg = cfg.get("risk") or {} + stop_loss_pct = float(risk_cfg.get("stopLossPct") or 0.0) + take_profit_pct = float(risk_cfg.get("takeProfitPct") or 0.0) + trailing_cfg = risk_cfg.get("trailing") or {} + trailing_enabled = bool(trailing_cfg.get("enabled")) + trailing_pct = float(trailing_cfg.get("pct") or 0.0) + trailing_activation_pct = float(trailing_cfg.get("activationPct") or 0.0) # Risk percentages are defined on margin PnL; convert to price move thresholds by leverage. lev = max(int(leverage or 1), 1) @@ -2375,43 +2506,43 @@ import pandas as pd trailing_pct_eff = trailing_pct / lev trailing_activation_pct_eff = trailing_activation_pct / lev - pos_cfg = cfg.get('position') or {} - entry_pct_cfg = float(pos_cfg.get('entryPct') or 1.0) # expected 0~1 + pos_cfg = cfg.get("position") or {} + entry_pct_cfg = float(pos_cfg.get("entryPct") or 1.0) # expected 0~1 # Accept both 0~1 and 0~100 inputs (some clients may send percent units). if entry_pct_cfg > 1: entry_pct_cfg = entry_pct_cfg / 100.0 entry_pct_cfg = max(0.0, min(entry_pct_cfg, 1.0)) - scale_cfg = cfg.get('scale') or {} - trend_add_cfg = scale_cfg.get('trendAdd') or {} - dca_add_cfg = scale_cfg.get('dcaAdd') or {} - trend_reduce_cfg = scale_cfg.get('trendReduce') or {} - adverse_reduce_cfg = scale_cfg.get('adverseReduce') or {} + scale_cfg = cfg.get("scale") or {} + trend_add_cfg = scale_cfg.get("trendAdd") or {} + dca_add_cfg = scale_cfg.get("dcaAdd") or {} + trend_reduce_cfg = scale_cfg.get("trendReduce") or {} + adverse_reduce_cfg = scale_cfg.get("adverseReduce") or {} - trend_add_enabled = bool(trend_add_cfg.get('enabled')) - trend_add_step_pct = float(trend_add_cfg.get('stepPct') or 0.0) - trend_add_size_pct = float(trend_add_cfg.get('sizePct') or 0.0) - trend_add_max_times = int(trend_add_cfg.get('maxTimes') or 0) + trend_add_enabled = bool(trend_add_cfg.get("enabled")) + trend_add_step_pct = float(trend_add_cfg.get("stepPct") or 0.0) + trend_add_size_pct = float(trend_add_cfg.get("sizePct") or 0.0) + trend_add_max_times = int(trend_add_cfg.get("maxTimes") or 0) - dca_add_enabled = bool(dca_add_cfg.get('enabled')) - dca_add_step_pct = float(dca_add_cfg.get('stepPct') or 0.0) - dca_add_size_pct = float(dca_add_cfg.get('sizePct') or 0.0) - dca_add_max_times = int(dca_add_cfg.get('maxTimes') or 0) + dca_add_enabled = bool(dca_add_cfg.get("enabled")) + dca_add_step_pct = float(dca_add_cfg.get("stepPct") or 0.0) + dca_add_size_pct = float(dca_add_cfg.get("sizePct") or 0.0) + dca_add_max_times = int(dca_add_cfg.get("maxTimes") or 0) # Prevent logical conflict: trend scale-in and mean-reversion scale-in should not run together. # Otherwise both may trigger in the same candle (high/low both hit), causing double scaling unexpectedly. if trend_add_enabled and dca_add_enabled: dca_add_enabled = False - trend_reduce_enabled = bool(trend_reduce_cfg.get('enabled')) - trend_reduce_step_pct = float(trend_reduce_cfg.get('stepPct') or 0.0) - trend_reduce_size_pct = float(trend_reduce_cfg.get('sizePct') or 0.0) - trend_reduce_max_times = int(trend_reduce_cfg.get('maxTimes') or 0) + trend_reduce_enabled = bool(trend_reduce_cfg.get("enabled")) + trend_reduce_step_pct = float(trend_reduce_cfg.get("stepPct") or 0.0) + trend_reduce_size_pct = float(trend_reduce_cfg.get("sizePct") or 0.0) + trend_reduce_max_times = int(trend_reduce_cfg.get("maxTimes") or 0) - adverse_reduce_enabled = bool(adverse_reduce_cfg.get('enabled')) - adverse_reduce_step_pct = float(adverse_reduce_cfg.get('stepPct') or 0.0) - adverse_reduce_size_pct = float(adverse_reduce_cfg.get('sizePct') or 0.0) - adverse_reduce_max_times = int(adverse_reduce_cfg.get('maxTimes') or 0) + adverse_reduce_enabled = bool(adverse_reduce_cfg.get("enabled")) + adverse_reduce_step_pct = float(adverse_reduce_cfg.get("stepPct") or 0.0) + adverse_reduce_size_pct = float(adverse_reduce_cfg.get("sizePct") or 0.0) + adverse_reduce_max_times = int(adverse_reduce_cfg.get("maxTimes") or 0) # Trigger pct as post-leverage margin threshold: divide by leverage for price trigger # e.g. 10x + 5% trigger means ~0.5% price movement @@ -2431,57 +2562,57 @@ import pandas as pd last_dca_add_anchor = None last_trend_reduce_anchor = None last_adverse_reduce_anchor = None - + # Convert signals to arrays - open_long_arr = signals['open_long'].values - close_long_arr = signals['close_long'].values - open_short_arr = signals['open_short'].values - close_short_arr = signals['close_short'].values + open_long_arr = signals["open_long"].values + close_long_arr = signals["close_long"].values + open_short_arr = signals["open_short"].values + close_short_arr = signals["close_short"].values # Apply execution timing to avoid look-ahead bias: # If signals are computed using bar close, realistic execution is next bar open. - if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: + if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"]: open_long_arr = np.insert(open_long_arr[:-1], 0, False) close_long_arr = np.insert(close_long_arr[:-1], 0, False) open_short_arr = np.insert(open_short_arr[:-1], 0, False) close_short_arr = np.insert(close_short_arr[:-1], 0, False) - + # Filter signals by trade direction - if trade_direction == 'long': + if trade_direction == "long": # Long only: disable all short signals open_short_arr = np.zeros(len(df), dtype=bool) close_short_arr = np.zeros(len(df), dtype=bool) - elif trade_direction == 'short': + elif trade_direction == "short": # Short only: disable all long signals open_long_arr = np.zeros(len(df), dtype=bool) close_long_arr = np.zeros(len(df), dtype=bool) else: pass - + # Add position signals if has_position_management: - add_long_arr = signals['add_long'].values - add_short_arr = signals['add_short'].values - position_size_arr = signals.get('position_size', pd.Series([0.0] * len(df))).values - + add_long_arr = signals["add_long"].values + add_short_arr = signals["add_short"].values + position_size_arr = signals.get("position_size", pd.Series([0.0] * len(df))).values + # Filter add signals by trade direction - if trade_direction == 'long': + if trade_direction == "long": add_short_arr = np.zeros(len(df), dtype=bool) - elif trade_direction == 'short': + elif trade_direction == "short": add_long_arr = np.zeros(len(df), dtype=bool) - + # Entry trigger price (if indicator provides) - open_long_price_arr = signals.get('open_long_price', pd.Series([0.0] * len(df))).values - open_short_price_arr = signals.get('open_short_price', pd.Series([0.0] * len(df))).values - + open_long_price_arr = signals.get("open_long_price", pd.Series([0.0] * len(df))).values + open_short_price_arr = signals.get("open_short_price", pd.Series([0.0] * len(df))).values + # Exit target price (if indicator provides) - close_long_price_arr = signals.get('close_long_price', pd.Series([0.0] * len(df))).values - close_short_price_arr = signals.get('close_short_price', pd.Series([0.0] * len(df))).values - + close_long_price_arr = signals.get("close_long_price", pd.Series([0.0] * len(df))).values + close_short_price_arr = signals.get("close_short_price", pd.Series([0.0] * len(df))).values + # Add position price (if indicator provides) - add_long_price_arr = signals.get('add_long_price', pd.Series([0.0] * len(df))).values - add_short_price_arr = signals.get('add_short_price', pd.Series([0.0] * len(df))).values - + add_long_price_arr = signals.get("add_long_price", pd.Series([0.0] * len(df))).values + add_short_price_arr = signals.get("add_short_price", pd.Series([0.0] * len(df))).values + for i, (timestamp, row) in enumerate(df.iterrows()): # After the liquidation, the backtest will be stopped directly and the results will be output. if is_liquidated: @@ -2492,32 +2623,34 @@ import pandas as pd is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(float(row.get('close', 0) or 0), 4), - 'amount': 0, - 'profit': liquidation_loss, - 'balance': 0 - }) - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(float(row.get("close", 0) or 0), 4), + "amount": 0, + "profit": liquidation_loss, + "balance": 0, + } + ) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": 0}) break # Stop directly - + # Use OHLC to evaluate triggers. - high = row['high'] - low = row['low'] - close = row['close'] - open_ = row.get('open', close) - + high = row["high"] + low = row["low"] + close = row["close"] + open_ = row.get("open", close) + # Default execution price depends on timing mode # - bar_close: close # - next_bar_open: open (this bar is the next bar for a prior signal) - exec_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else close + exec_price = open_ if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"] else close # --- Risk controls: SL / TP / trailing exit (highest priority) --- - if position != 0 and position_type in ['long', 'short']: + if position != 0 and position_type in ["long", "short"]: # Update extreme prices for trailing stop - if position_type == 'long': + if position_type == "long": if highest_since_entry is None: highest_since_entry = entry_price if lowest_since_entry is None: @@ -2536,16 +2669,16 @@ import pandas as pd # Backtest is candle-level, cannot determine exact trigger order; using priority: # StopLoss > TrailingStop > TakeProfit candidates = [] # [(trade_type, trigger_price)] - if position_type == 'long' and position > 0: + if position_type == "long" and position > 0: if stop_loss_pct_eff > 0: sl_price = entry_price * (1 - stop_loss_pct_eff) if low <= sl_price: - candidates.append(('close_long_stop', sl_price)) + candidates.append(("close_long_stop", sl_price)) # Fixed take-profit exit is disabled when trailing is enabled (see conflict rule above). if (not trailing_enabled) and take_profit_pct_eff > 0: tp_price = entry_price * (1 + take_profit_pct_eff) if high >= tp_price: - candidates.append(('close_long_profit', tp_price)) + candidates.append(("close_long_profit", tp_price)) if trailing_enabled and trailing_pct_eff > 0 and highest_since_entry is not None: trail_active = True if trailing_activation_pct_eff > 0: @@ -2553,11 +2686,11 @@ import pandas as pd if trail_active: tr_price = highest_since_entry * (1 - trailing_pct_eff) if low <= tr_price: - candidates.append(('close_long_trailing', tr_price)) + candidates.append(("close_long_trailing", tr_price)) if candidates: # Select by priority: SL > Trailing > TP - pri = {'close_long_stop': 0, 'close_long_trailing': 1, 'close_long_profit': 2} + pri = {"close_long_stop": 0, "close_long_trailing": 1, "close_long_profit": 2} trade_type, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), x[1]))[0] exec_price_close = trigger_price * (1 - slippage) commission_fee_close = position * exec_price_close * commission @@ -2566,14 +2699,16 @@ import pandas as pd capital += profit total_commission_paid += commission_fee_close - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': trade_type, - 'price': round(exec_price_close, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": trade_type, + "price": round(exec_price_close, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None @@ -2581,22 +2716,24 @@ import pandas as pd highest_since_entry = None lowest_since_entry = None trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = ( + last_adverse_reduce_anchor + ) = None - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": round(capital, 2)}) continue - if position_type == 'short' and position < 0: + if position_type == "short" and position < 0: shares = abs(position) if stop_loss_pct_eff > 0: sl_price = entry_price * (1 + stop_loss_pct_eff) if high >= sl_price: - candidates.append(('close_short_stop', sl_price)) + candidates.append(("close_short_stop", sl_price)) # Fixed take-profit exit is disabled when trailing is enabled (see conflict rule above). if (not trailing_enabled) and take_profit_pct_eff > 0: tp_price = entry_price * (1 - take_profit_pct_eff) if low <= tp_price: - candidates.append(('close_short_profit', tp_price)) + candidates.append(("close_short_profit", tp_price)) if trailing_enabled and trailing_pct_eff > 0 and lowest_since_entry is not None: trail_active = True if trailing_activation_pct_eff > 0: @@ -2604,11 +2741,11 @@ import pandas as pd if trail_active: tr_price = lowest_since_entry * (1 + trailing_pct_eff) if high >= tr_price: - candidates.append(('close_short_trailing', tr_price)) + candidates.append(("close_short_trailing", tr_price)) if candidates: # Select by priority: SL > Trailing > TP - pri = {'close_short_stop': 0, 'close_short_trailing': 1, 'close_short_profit': 2} + pri = {"close_short_stop": 0, "close_short_trailing": 1, "close_short_profit": 2} trade_type, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), -x[1]))[0] exec_price_close = trigger_price * (1 + slippage) commission_fee_close = shares * exec_price_close * commission @@ -2619,31 +2756,35 @@ import pandas as pd liquidation_loss = self._liquidation_loss(capital) capital = 0 is_liquidated = True - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price_close, 4), - 'amount': round(shares, 4), - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price_close, 4), + "amount": round(shares, 4), + "profit": liquidation_loss, + "balance": 0, + } + ) position = 0 position_type = None liquidation_price = 0 - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": 0}) continue capital += profit total_commission_paid += commission_fee_close - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': trade_type, - 'price': round(exec_price_close, 4), - 'amount': round(shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": trade_type, + "price": round(exec_price_close, 4), + "amount": round(shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None @@ -2651,15 +2792,17 @@ import pandas as pd highest_since_entry = None lowest_since_entry = None trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = ( + last_adverse_reduce_anchor + ) = None - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": round(capital, 2)}) continue - + # Handle exit signals (priority, SL/TP) if position > 0 and close_long_arr[i]: # Close long: use indicator price or close - if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: + if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"]: target_price = open_ else: target_price = close_long_price_arr[i] if close_long_price_arr[i] > 0 else close @@ -2673,42 +2816,48 @@ import pandas as pd # This is a "signal close" (not a forced stop-loss/take-profit/trailing exit). # Do NOT label it as *_stop/*_profit based on PnL sign, otherwise it looks like a stop-loss happened # even when risk controls are disabled (stopLossPct/takeProfitPct == 0). - trade_type = 'close_long' + trade_type = "close_long" + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": trade_type, + "price": round(exec_price, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': trade_type, - 'price': round(exec_price, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) - position = 0 position_type = None liquidation_price = 0 highest_since_entry = None lowest_since_entry = None trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = ( + None + ) # Stop if balance too low after exit if capital < min_capital_to_trade: is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': 0, - 'profit': liquidation_loss, - 'balance': 0 - }) - + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": 0, + "profit": liquidation_loss, + "balance": 0, + } + ) + elif position < 0 and close_short_arr[i]: # Close short: use indicator price or close - if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: + if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"]: target_price = open_ else: target_price = close_short_price_arr[i] if close_short_price_arr[i] > 0 else close @@ -2716,61 +2865,69 @@ import pandas as pd shares = abs(position) commission_fee = shares * exec_price * commission profit = (entry_price - exec_price) * shares - commission_fee - + if capital + profit <= 0: - logger.warning(f"Insufficient funds when closing short - liquidation") + logger.warning("Insufficient funds when closing short - liquidation") liquidation_loss = self._liquidation_loss(capital) capital = 0 is_liquidated = True - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": liquidation_loss, + "balance": 0, + } + ) position = 0 position_type = None - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": 0}) continue - + capital += profit total_commission_paid += commission_fee # Signal close (not forced TP/SL/trailing). - trade_type = 'close_short' + trade_type = "close_short" + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": trade_type, + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': trade_type, - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) - position = 0 position_type = None liquidation_price = 0 highest_since_entry = None lowest_since_entry = None trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = ( + None + ) if capital < min_capital_to_trade: is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': 0, - 'profit': liquidation_loss, - 'balance': 0 - }) - + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": 0, + "profit": liquidation_loss, + "balance": 0, + } + ) + # If this candle has a main strategy signal (open/close long/short), # we must NOT apply any scale-in/scale-out actions on the same candle. main_signal_on_bar = bool(open_long_arr[i] or open_short_arr[i] or close_long_arr[i] or close_short_arr[i]) @@ -2781,11 +2938,21 @@ import pandas as pd # - Mean-reversion DCA: long triggers when price falls stepPct from anchor; short triggers when price rises stepPct from anchor # - Trend reduce: long reduces on rise; short reduces on fall # - Adverse reduce: long reduces on fall; short reduces on rise - if (not main_signal_on_bar) and position != 0 and position_type in ['long', 'short'] and capital >= min_capital_to_trade: + if ( + (not main_signal_on_bar) + and position != 0 + and position_type in ["long", "short"] + and capital >= min_capital_to_trade + ): # Long - if position_type == 'long' and position > 0: + if position_type == "long" and position > 0: # Trend scale-in (trigger on higher price) - if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times): + if ( + trend_add_enabled + and trend_add_step_pct_eff > 0 + and trend_add_size_pct > 0 + and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times) + ): anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price trigger = anchor * (1 + trend_add_step_pct_eff) if high >= trigger: @@ -2809,17 +2976,24 @@ import pandas as pd trend_add_times += 1 last_trend_add_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'add_long', - 'price': round(exec_price_add, 4), - 'amount': round(shares_add, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "add_long", + "price": round(exec_price_add, 4), + "amount": round(shares_add, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) # Mean-reversion DCA (trigger on lower price) - if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times): + if ( + dca_add_enabled + and dca_add_step_pct_eff > 0 + and dca_add_size_pct > 0 + and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times) + ): anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price trigger = anchor * (1 - dca_add_step_pct_eff) if low <= trigger: @@ -2842,17 +3016,24 @@ import pandas as pd dca_add_times += 1 last_dca_add_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'add_long', - 'price': round(exec_price_add, 4), - 'amount': round(shares_add, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "add_long", + "price": round(exec_price_add, 4), + "amount": round(shares_add, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) # Trend reduce (trigger on higher price) - if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times): + if ( + trend_reduce_enabled + and trend_reduce_step_pct_eff > 0 + and trend_reduce_size_pct > 0 + and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times) + ): anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price trigger = anchor * (1 + trend_reduce_step_pct_eff) if high >= trigger: @@ -2875,17 +3056,26 @@ import pandas as pd trend_reduce_times += 1 last_trend_reduce_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'reduce_long', - 'price': round(exec_price_reduce, 4), - 'amount': round(reduce_shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "reduce_long", + "price": round(exec_price_reduce, 4), + "amount": round(reduce_shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) # Adverse reduce (trigger on lower price) - if position_type == 'long' and position > 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times): + if ( + position_type == "long" + and position > 0 + and adverse_reduce_enabled + and adverse_reduce_step_pct_eff > 0 + and adverse_reduce_size_pct > 0 + and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times) + ): anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price trigger = anchor * (1 - adverse_reduce_step_pct_eff) if low <= trigger: @@ -2908,21 +3098,28 @@ import pandas as pd adverse_reduce_times += 1 last_adverse_reduce_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'reduce_long', - 'price': round(exec_price_reduce, 4), - 'amount': round(reduce_shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "reduce_long", + "price": round(exec_price_reduce, 4), + "amount": round(reduce_shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) # Short - if position_type == 'short' and position < 0: + if position_type == "short" and position < 0: shares_total = abs(position) # Trend scale-in (trigger on lower price) - if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times): + if ( + trend_add_enabled + and trend_add_step_pct_eff > 0 + and trend_add_size_pct > 0 + and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times) + ): anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price trigger = anchor * (1 - trend_add_step_pct_eff) if low <= trigger: @@ -2946,17 +3143,24 @@ import pandas as pd trend_add_times += 1 last_trend_add_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'add_short', - 'price': round(exec_price_add, 4), - 'amount': round(shares_add, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "add_short", + "price": round(exec_price_add, 4), + "amount": round(shares_add, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) # Mean-reversion DCA (trigger on higher price) - if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times): + if ( + dca_add_enabled + and dca_add_step_pct_eff > 0 + and dca_add_size_pct > 0 + and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times) + ): anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price trigger = anchor * (1 + dca_add_step_pct_eff) if high >= trigger: @@ -2980,17 +3184,24 @@ import pandas as pd dca_add_times += 1 last_dca_add_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'add_short', - 'price': round(exec_price_add, 4), - 'amount': round(shares_add, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "add_short", + "price": round(exec_price_add, 4), + "amount": round(shares_add, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) # Trend reduce (trigger on lower price) - if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times): + if ( + trend_reduce_enabled + and trend_reduce_step_pct_eff > 0 + and trend_reduce_size_pct > 0 + and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times) + ): anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price trigger = anchor * (1 - trend_reduce_step_pct_eff) if low <= trigger: @@ -3014,17 +3225,26 @@ import pandas as pd trend_reduce_times += 1 last_trend_reduce_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'reduce_short', - 'price': round(exec_price_reduce, 4), - 'amount': round(reduce_shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "reduce_short", + "price": round(exec_price_reduce, 4), + "amount": round(reduce_shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) # Adverse reduce (trigger on higher price) - if position_type == 'short' and position < 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times): + if ( + position_type == "short" + and position < 0 + and adverse_reduce_enabled + and adverse_reduce_step_pct_eff > 0 + and adverse_reduce_size_pct > 0 + and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times) + ): anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price trigger = anchor * (1 + adverse_reduce_step_pct_eff) if high >= trigger: @@ -3048,14 +3268,16 @@ import pandas as pd adverse_reduce_times += 1 last_adverse_reduce_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'reduce_short', - 'price': round(exec_price_reduce, 4), - 'amount': round(reduce_shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "reduce_short", + "price": round(exec_price_reduce, 4), + "amount": round(reduce_shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) # Handle add position signals if has_position_management and (not main_signal_on_bar): @@ -3063,45 +3285,47 @@ import pandas as pd # Add long: use indicator price or close target_price = add_long_price_arr[i] if add_long_price_arr[i] > 0 else close exec_price = target_price * (1 + slippage) - + # Use specified pct to add position_pct = position_size_arr[i] if position_size_arr[i] > 0 else 0.1 use_capital = capital * position_pct shares = (use_capital * leverage) / exec_price commission_fee = shares * exec_price * commission - + # Update average cost total_cost_before = position * entry_price total_cost_after = total_cost_before + shares * exec_price position += shares entry_price = total_cost_after / position - + capital -= commission_fee total_commission_paid += commission_fee - + # Recalculate liquidation price liquidation_price = entry_price * (1 - 1.0 / leverage) - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'add_long', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) - + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "add_long", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) + elif position < 0 and add_short_arr[i] and capital >= min_capital_to_trade: # Add short: use indicator price or close target_price = add_short_price_arr[i] if add_short_price_arr[i] > 0 else close exec_price = target_price * (1 - slippage) - + # Use specified pct to add position_pct = position_size_arr[i] if position_size_arr[i] > 0 else 0.1 use_capital = capital * position_pct shares = (use_capital * leverage) / exec_price commission_fee = shares * exec_price * commission - + # Update average cost current_shares = abs(position) total_cost_before = current_shares * entry_price @@ -3109,285 +3333,315 @@ import pandas as pd position -= shares # Short is negative current_shares = abs(position) entry_price = total_cost_after / current_shares - + capital -= commission_fee total_commission_paid += commission_fee - + # Recalculate liquidation price liquidation_price = entry_price * (1 + 1.0 / leverage) - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'add_short', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) - + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "add_short", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) + # Handle entry signals # In both mode, open_long/open_short can auto-close opposing position first - both_mode_active = signals.get('_both_mode', False) - + both_mode_active = signals.get("_both_mode", False) + # open_long: can execute when position==0, OR when both_mode and position<0 (auto-close short first) - if open_long_arr[i] and (position == 0 or (both_mode_active and position < 0)) and capital >= min_capital_to_trade: - # In both mode with short position, close it first - if both_mode_active and position < 0: - shares_to_close = abs(position) - close_price = open_ * (1 + slippage) - close_commission = shares_to_close * close_price * commission - close_profit = (entry_price - close_price) * shares_to_close - close_commission - capital += close_profit - if capital < 0: + if ( + open_long_arr[i] + and (position == 0 or (both_mode_active and position < 0)) + and capital >= min_capital_to_trade + ): + # In both mode with short position, close it first + if both_mode_active and position < 0: + shares_to_close = abs(position) + close_price = open_ * (1 + slippage) + close_commission = shares_to_close * close_price * commission + close_profit = (entry_price - close_price) * shares_to_close - close_commission + capital += close_profit + if capital < 0: + capital = 0 + total_commission_paid += close_commission + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short", + "price": round(close_price, 4), + "amount": round(shares_to_close, 4), + "profit": round(close_profit, 2), + "balance": round(max(0, capital), 2), + } + ) + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = ( + last_adverse_reduce_anchor + ) = None + # Check whether the position is liquidated + if capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": 0}) + continue + + # Now open long (position is guaranteed to be 0 here) + # Use indicator entry price or close + if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"]: + base_price = open_ + else: + base_price = open_long_price_arr[i] if open_long_price_arr[i] > 0 else close + exec_price = base_price * (1 + slippage) + + # Use specified pct (entryPct > position_size > full) + position_pct = None + if entry_pct_cfg and entry_pct_cfg > 0: + position_pct = entry_pct_cfg + elif has_position_management and position_size_arr[i] > 0: + position_pct = position_size_arr[i] + if position_pct is not None and position_pct > 0 and position_pct < 1: + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + else: + shares = (capital * leverage) / exec_price + + commission_fee = shares * exec_price * commission + + position = shares + entry_price = exec_price + position_type = "long" + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 - 1.0 / leverage) + highest_since_entry = entry_price + lowest_since_entry = entry_price + last_trend_add_anchor = entry_price + last_dca_add_anchor = entry_price + last_trend_reduce_anchor = entry_price + last_adverse_reduce_anchor = entry_price + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "open_long", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) + + # Strict intrabar stop-loss / liquidation check right after entry (closer to live trading). + # If this bar touches stop-loss price, close immediately at stop price (with slippage). + # If this bar also touches liquidation price, assume stop-loss triggers first only if it is above liquidation. + if position_type == "long" and position > 0: + sl_price = entry_price * (1 - stop_loss_pct_eff) if stop_loss_pct_eff > 0 else None + hit_sl = (sl_price is not None) and (low <= sl_price) + hit_liq = liquidation_price > 0 and (low <= liquidation_price) + if hit_sl or hit_liq: + if hit_liq and (not hit_sl or (sl_price is not None and sl_price <= liquidation_price)): + # Liquidation happens before stop-loss (or stop-loss not configured). + is_liquidated = True + liquidation_loss = self._liquidation_loss(capital) capital = 0 - total_commission_paid += close_commission - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short', - 'price': round(close_price, 4), - 'amount': round(shares_to_close, 4), - 'profit': round(close_profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(liquidation_price, 4), + "amount": round(position, 4), + "profit": liquidation_loss, + "balance": 0, + } + ) + else: + # Stop-loss triggers first. + exec_price_close = sl_price * (1 - slippage) + commission_fee_close = position * exec_price_close * commission + profit = (exec_price_close - entry_price) * position - commission_fee_close + capital += profit + total_commission_paid += commission_fee_close + if capital <= 0: + is_liquidated = True + capital = 0 + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long_stop", + "price": round(exec_price_close, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) + position = 0 position_type = None liquidation_price = 0 highest_since_entry = None lowest_since_entry = None - trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None - # Check whether the position is liquidated - if capital < min_capital_to_trade: - is_liquidated = True - capital = 0 - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) - continue - - # Now open long (position is guaranteed to be 0 here) - # Use indicator entry price or close - if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: - base_price = open_ - else: - base_price = open_long_price_arr[i] if open_long_price_arr[i] > 0 else close - exec_price = base_price * (1 + slippage) - - # Use specified pct (entryPct > position_size > full) - position_pct = None - if entry_pct_cfg and entry_pct_cfg > 0: - position_pct = entry_pct_cfg - elif has_position_management and position_size_arr[i] > 0: - position_pct = position_size_arr[i] - if position_pct is not None and position_pct > 0 and position_pct < 1: - use_capital = capital * position_pct - shares = (use_capital * leverage) / exec_price - else: - shares = (capital * leverage) / exec_price - - commission_fee = shares * exec_price * commission - - position = shares - entry_price = exec_price - position_type = 'long' - capital -= commission_fee - total_commission_paid += commission_fee - liquidation_price = entry_price * (1 - 1.0 / leverage) - highest_since_entry = entry_price - lowest_since_entry = entry_price - last_trend_add_anchor = entry_price - last_dca_add_anchor = entry_price - last_trend_reduce_anchor = entry_price - last_adverse_reduce_anchor = entry_price - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'open_long', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) - - # Strict intrabar stop-loss / liquidation check right after entry (closer to live trading). - # If this bar touches stop-loss price, close immediately at stop price (with slippage). - # If this bar also touches liquidation price, assume stop-loss triggers first only if it is above liquidation. - if position_type == 'long' and position > 0: - sl_price = entry_price * (1 - stop_loss_pct_eff) if stop_loss_pct_eff > 0 else None - hit_sl = (sl_price is not None) and (low <= sl_price) - hit_liq = liquidation_price > 0 and (low <= liquidation_price) - if hit_sl or hit_liq: - if hit_liq and (not hit_sl or (sl_price is not None and sl_price <= liquidation_price)): - # Liquidation happens before stop-loss (or stop-loss not configured). - is_liquidated = True - liquidation_loss = self._liquidation_loss(capital) - capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(liquidation_price, 4), - 'amount': round(position, 4), - 'profit': liquidation_loss, - 'balance': 0 - }) - else: - # Stop-loss triggers first. - exec_price_close = sl_price * (1 - slippage) - commission_fee_close = position * exec_price_close * commission - profit = (exec_price_close - entry_price) * position - commission_fee_close - capital += profit - total_commission_paid += commission_fee_close - if capital <= 0: - is_liquidated = True - capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long_stop', - 'price': round(exec_price_close, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": round(capital, 2)}) + continue - position = 0 - position_type = None - liquidation_price = 0 - highest_since_entry = None - lowest_since_entry = None - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) - continue - # open_short: can execute when position==0, OR when both_mode and position>0 (auto-close long first) - elif open_short_arr[i] and (position == 0 or (both_mode_active and position > 0)) and capital >= min_capital_to_trade: - # In both mode with long position, close it first - if both_mode_active and position > 0: - close_price = open_ * (1 - slippage) - close_commission = position * close_price * commission - close_profit = (close_price - entry_price) * position - close_commission - capital += close_profit - if capital < 0: + elif ( + open_short_arr[i] + and (position == 0 or (both_mode_active and position > 0)) + and capital >= min_capital_to_trade + ): + # In both mode with long position, close it first + if both_mode_active and position > 0: + close_price = open_ * (1 - slippage) + close_commission = position * close_price * commission + close_profit = (close_price - entry_price) * position - close_commission + capital += close_profit + if capital < 0: + capital = 0 + total_commission_paid += close_commission + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long", + "price": round(close_price, 4), + "amount": round(position, 4), + "profit": round(close_profit, 2), + "balance": round(max(0, capital), 2), + } + ) + position = 0 + position_type = None + liquidation_price = 0 + highest_since_entry = None + lowest_since_entry = None + trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = ( + last_adverse_reduce_anchor + ) = None + # Check whether the position is liquidated + if capital < min_capital_to_trade: + is_liquidated = True + capital = 0 + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": 0}) + continue + + # Now open short (position is guaranteed to be 0 here) + # Use indicator entry price or close + if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"]: + base_price = open_ + else: + base_price = open_short_price_arr[i] if open_short_price_arr[i] > 0 else close + exec_price = base_price * (1 - slippage) + + # Use specified pct (entryPct > position_size > full) + position_pct = None + if entry_pct_cfg and entry_pct_cfg > 0: + position_pct = entry_pct_cfg + elif has_position_management and position_size_arr[i] > 0: + position_pct = position_size_arr[i] + if position_pct is not None and position_pct > 0 and position_pct < 1: + use_capital = capital * position_pct + shares = (use_capital * leverage) / exec_price + else: + shares = (capital * leverage) / exec_price + + commission_fee = shares * exec_price * commission + + position = -shares + entry_price = exec_price + position_type = "short" + capital -= commission_fee + total_commission_paid += commission_fee + liquidation_price = entry_price * (1 + 1.0 / leverage) + highest_since_entry = entry_price + lowest_since_entry = entry_price + last_trend_add_anchor = entry_price + last_dca_add_anchor = entry_price + last_trend_reduce_anchor = entry_price + last_adverse_reduce_anchor = entry_price + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "open_short", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) + + # Strict intrabar stop-loss / liquidation check right after entry (closer to live trading). + if position_type == "short" and position < 0: + sl_price = entry_price * (1 + stop_loss_pct_eff) if stop_loss_pct_eff > 0 else None + hit_sl = (sl_price is not None) and (high >= sl_price) + hit_liq = liquidation_price > 0 and (high >= liquidation_price) + if hit_sl or hit_liq: + if hit_liq and (not hit_sl or (sl_price is not None and sl_price >= liquidation_price)): + # Liquidation happens before stop-loss (or stop-loss not configured). + is_liquidated = True + liquidation_loss = self._liquidation_loss(capital) capital = 0 - total_commission_paid += close_commission - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long', - 'price': round(close_price, 4), - 'amount': round(position, 4), - 'profit': round(close_profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(liquidation_price, 4), + "amount": round(abs(position), 4), + "profit": liquidation_loss, + "balance": 0, + } + ) + else: + # Stop-loss triggers first. + exec_price_close = sl_price * (1 + slippage) + shares_close = abs(position) + commission_fee_close = shares_close * exec_price_close * commission + profit = (entry_price - exec_price_close) * shares_close - commission_fee_close + capital += profit + total_commission_paid += commission_fee_close + if capital <= 0: + is_liquidated = True + capital = 0 + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short_stop", + "price": round(exec_price_close, 4), + "amount": round(shares_close, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) + position = 0 position_type = None liquidation_price = 0 highest_since_entry = None lowest_since_entry = None - trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None - # Check whether the position is liquidated - if capital < min_capital_to_trade: - is_liquidated = True - capital = 0 - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) - continue - - # Now open short (position is guaranteed to be 0 here) - # Use indicator entry price or close - if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: - base_price = open_ - else: - base_price = open_short_price_arr[i] if open_short_price_arr[i] > 0 else close - exec_price = base_price * (1 - slippage) - - # Use specified pct (entryPct > position_size > full) - position_pct = None - if entry_pct_cfg and entry_pct_cfg > 0: - position_pct = entry_pct_cfg - elif has_position_management and position_size_arr[i] > 0: - position_pct = position_size_arr[i] - if position_pct is not None and position_pct > 0 and position_pct < 1: - use_capital = capital * position_pct - shares = (use_capital * leverage) / exec_price - else: - shares = (capital * leverage) / exec_price - - commission_fee = shares * exec_price * commission - - position = -shares - entry_price = exec_price - position_type = 'short' - capital -= commission_fee - total_commission_paid += commission_fee - liquidation_price = entry_price * (1 + 1.0 / leverage) - highest_since_entry = entry_price - lowest_since_entry = entry_price - last_trend_add_anchor = entry_price - last_dca_add_anchor = entry_price - last_trend_reduce_anchor = entry_price - last_adverse_reduce_anchor = entry_price - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'open_short', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) - - # Strict intrabar stop-loss / liquidation check right after entry (closer to live trading). - if position_type == 'short' and position < 0: - sl_price = entry_price * (1 + stop_loss_pct_eff) if stop_loss_pct_eff > 0 else None - hit_sl = (sl_price is not None) and (high >= sl_price) - hit_liq = liquidation_price > 0 and (high >= liquidation_price) - if hit_sl or hit_liq: - if hit_liq and (not hit_sl or (sl_price is not None and sl_price >= liquidation_price)): - # Liquidation happens before stop-loss (or stop-loss not configured). - is_liquidated = True - liquidation_loss = self._liquidation_loss(capital) - capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(liquidation_price, 4), - 'amount': round(abs(position), 4), - 'profit': liquidation_loss, - 'balance': 0 - }) - else: - # Stop-loss triggers first. - exec_price_close = sl_price * (1 + slippage) - shares_close = abs(position) - commission_fee_close = shares_close * exec_price_close * commission - profit = (entry_price - exec_price_close) * shares_close - commission_fee_close - capital += profit - total_commission_paid += commission_fee_close - if capital <= 0: - is_liquidated = True - capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short_stop', - 'price': round(exec_price_close, 4), - 'amount': round(shares_close, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": round(capital, 2)}) + continue - position = 0 - position_type = None - liquidation_price = 0 - highest_since_entry = None - lowest_since_entry = None - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) - continue - # Check if liquidation hit (safety net) # Note: check after all active exit signals # If liquidation hit, check SL signal first if position != 0 and not is_liquidated: - if position_type == 'long' and low <= liquidation_price: + if position_type == "long" and low <= liquidation_price: # Long touches the liquidation line: check whether there is a stop loss signal has_stop_loss = close_long_arr[i] and close_long_price_arr[i] > 0 stop_loss_price = close_long_price_arr[i] if has_stop_loss else 0 - + # Determine SL or liquidation first if has_stop_loss and stop_loss_price > liquidation_price: # SL triggers before liquidation @@ -3396,44 +3650,52 @@ import pandas as pd profit = (exec_price_close - entry_price) * position - commission_fee_close capital += profit total_commission_paid += commission_fee_close - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long_stop', - 'price': round(exec_price_close, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long_stop", + "price": round(exec_price_close, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) else: # SL not strict enough, liquidation triggered - logger.warning(f"Long liquidation! entry={entry_price:.2f}, low={low:.2f}, " - f"liq_price={liquidation_price:.2f}, stop_loss_price={stop_loss_price:.2f}") + logger.warning( + f"Long liquidation! entry={entry_price:.2f}, low={low:.2f}, " + f"liq_price={liquidation_price:.2f}, stop_loss_price={stop_loss_price:.2f}" + ) is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(liquidation_price, 4), - 'amount': round(abs(position), 4), - 'profit': liquidation_loss, - 'balance': 0 - }) - + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(liquidation_price, 4), + "amount": round(abs(position), 4), + "profit": liquidation_loss, + "balance": 0, + } + ) + position = 0 position_type = None - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': capital}) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": capital}) continue - - elif position_type == 'short' and high >= liquidation_price: + + elif position_type == "short" and high >= liquidation_price: # Short hits the liquidation line: check if there is a stop loss signal has_stop_loss = close_short_arr[i] and close_short_price_arr[i] > 0 stop_loss_price = close_short_price_arr[i] if has_stop_loss else 0 - - logger.warning(f"[candle {i}] Short hit liquidation! entry={entry_price:.2f}, high={high:.2f}, liq_price={liquidation_price:.2f}, " - f"stop_loss_signal={close_short_arr[i]}, stop_loss_price={stop_loss_price:.4f}, time={timestamp}") - + + logger.warning( + f"[candle {i}] Short hit liquidation! entry={entry_price:.2f}, high={high:.2f}, liq_price={liquidation_price:.2f}, " + f"stop_loss_signal={close_short_arr[i]}, stop_loss_price={stop_loss_price:.4f}, time={timestamp}" + ) + # Determine SL or liquidation first if has_stop_loss and stop_loss_price < liquidation_price: # SL triggers before liquidation @@ -3443,111 +3705,120 @@ import pandas as pd profit = (entry_price - exec_price_close) * shares_close - commission_fee_close capital += profit total_commission_paid += commission_fee_close - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short_stop', - 'price': round(exec_price_close, 4), - 'amount': round(shares_close, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short_stop", + "price": round(exec_price_close, 4), + "amount": round(shares_close, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) else: # SL not strict enough, liquidation triggered - logger.warning(f"Short liquidation! entry={entry_price:.2f}, high={high:.2f}, " - f"liq_price={liquidation_price:.2f}, stop_loss_price={stop_loss_price:.2f}") + logger.warning( + f"Short liquidation! entry={entry_price:.2f}, high={high:.2f}, " + f"liq_price={liquidation_price:.2f}, stop_loss_price={stop_loss_price:.2f}" + ) is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(liquidation_price, 4), - 'amount': round(abs(position), 4), - 'profit': liquidation_loss, - 'balance': 0 - }) - + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(liquidation_price, 4), + "amount": round(abs(position), 4), + "profit": liquidation_loss, + "balance": 0, + } + ) + position = 0 position_type = None - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': capital}) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": capital}) continue - + # Record equity (unrealized PnL from close) - if position_type == 'long': + if position_type == "long": unrealized_pnl = (close - entry_price) * position total_value = capital + unrealized_pnl - elif position_type == 'short': + elif position_type == "short": shares = abs(position) unrealized_pnl = (entry_price - close) * shares total_value = capital + unrealized_pnl else: total_value = capital - + if total_value < 0: total_value = 0 - - equity_curve.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'value': round(total_value, 2) - }) - + + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": round(total_value, 2)}) + # Force exit at backtest end if position != 0: timestamp = df.index[-1] - final_close = df.iloc[-1]['close'] - + final_close = df.iloc[-1]["close"] + if position > 0: # Close long exec_price = final_close * (1 - slippage) commission_fee = position * exec_price * commission profit = (exec_price - entry_price) * position - commission_fee capital += profit total_commission_paid += commission_fee - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long', - 'price': round(exec_price, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long", + "price": round(exec_price, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) else: # Close short exec_price = final_close * (1 + slippage) shares = abs(position) commission_fee = shares * exec_price * commission profit = (entry_price - exec_price) * shares - commission_fee - + if capital + profit <= 0: - logger.warning(f"Liquidation at backtest end!") + logger.warning("Liquidation at backtest end!") liquidation_loss = self._liquidation_loss(capital) capital = 0 is_liquidated = True - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": liquidation_loss, + "balance": 0, + } + ) else: capital += profit total_commission_paid += commission_fee - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) - + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) + if equity_curve: - equity_curve[-1]['value'] = round(capital, 2) - + equity_curve[-1]["value"] = round(capital, 2) + return equity_curve, trades, total_commission_paid - + def _simulate_trading_old_format( self, df: pd.DataFrame, @@ -3556,8 +3827,8 @@ import pandas as pd commission: float, slippage: float, leverage: int = 1, - trade_direction: str = 'long', - strategy_config: Optional[Dict[str, Any]] = None + trade_direction: str = "long", + strategy_config: Optional[Dict[str, Any]] = None, ) -> tuple: """ Trading simulation using old format signals (maintaining compatibility) @@ -3568,7 +3839,7 @@ import pandas as pd is_liquidated = False # Liquidation flag liquidation_price = 0 # Liquidation price min_capital_to_trade = 1.0 # Below this balance, consider wiped out - + capital = initial_capital position = 0 # Positive=long, Negative=short entry_price = 0 @@ -3576,19 +3847,19 @@ import pandas as pd # Risk controls (also supported for legacy signals): SL / TP / trailing exit cfg = strategy_config or {} - exec_cfg = cfg.get('execution') or {} + exec_cfg = cfg.get("execution") or {} # Signal confirmation / execution timing (legacy mode): # - bar_close: execute on the same bar close # - next_bar_open: execute on next bar open after signal is confirmed on bar close (recommended) - signal_timing = str(exec_cfg.get('signalTiming') or 'next_bar_open').strip().lower() - risk_cfg = cfg.get('risk') or {} - stop_loss_pct = float(risk_cfg.get('stopLossPct') or 0.0) - take_profit_pct = float(risk_cfg.get('takeProfitPct') or 0.0) - trailing_cfg = risk_cfg.get('trailing') or {} - trailing_enabled = bool(trailing_cfg.get('enabled')) - trailing_pct = float(trailing_cfg.get('pct') or 0.0) - trailing_activation_pct = float(trailing_cfg.get('activationPct') or 0.0) - + signal_timing = str(exec_cfg.get("signalTiming") or "next_bar_open").strip().lower() + risk_cfg = cfg.get("risk") or {} + stop_loss_pct = float(risk_cfg.get("stopLossPct") or 0.0) + take_profit_pct = float(risk_cfg.get("takeProfitPct") or 0.0) + trailing_cfg = risk_cfg.get("trailing") or {} + trailing_enabled = bool(trailing_cfg.get("enabled")) + trailing_pct = float(trailing_cfg.get("pct") or 0.0) + trailing_activation_pct = float(trailing_cfg.get("activationPct") or 0.0) + # Risk percentages are defined on margin PnL; convert to price move thresholds by leverage. lev = max(int(leverage or 1), 1) stop_loss_pct_eff = stop_loss_pct / lev @@ -3599,38 +3870,38 @@ import pandas as pd lowest_since_entry = None # --- Position / scaling config (make old-format strategies support the same backtest modal features) --- - pos_cfg = cfg.get('position') or {} - entry_pct_cfg = float(pos_cfg.get('entryPct') if pos_cfg.get('entryPct') is not None else 1.0) # expected 0~1 + pos_cfg = cfg.get("position") or {} + entry_pct_cfg = float(pos_cfg.get("entryPct") if pos_cfg.get("entryPct") is not None else 1.0) # expected 0~1 # Accept both 0~1 and 0~100 inputs (some clients may send percent units). if entry_pct_cfg > 1: entry_pct_cfg = entry_pct_cfg / 100.0 entry_pct_cfg = max(0.0, min(entry_pct_cfg, 1.0)) - scale_cfg = cfg.get('scale') or {} - trend_add_cfg = scale_cfg.get('trendAdd') or {} - dca_add_cfg = scale_cfg.get('dcaAdd') or {} - trend_reduce_cfg = scale_cfg.get('trendReduce') or {} - adverse_reduce_cfg = scale_cfg.get('adverseReduce') or {} + scale_cfg = cfg.get("scale") or {} + trend_add_cfg = scale_cfg.get("trendAdd") or {} + dca_add_cfg = scale_cfg.get("dcaAdd") or {} + trend_reduce_cfg = scale_cfg.get("trendReduce") or {} + adverse_reduce_cfg = scale_cfg.get("adverseReduce") or {} - trend_add_enabled = bool(trend_add_cfg.get('enabled')) - trend_add_step_pct = float(trend_add_cfg.get('stepPct') or 0.0) - trend_add_size_pct = float(trend_add_cfg.get('sizePct') or 0.0) - trend_add_max_times = int(trend_add_cfg.get('maxTimes') or 0) + trend_add_enabled = bool(trend_add_cfg.get("enabled")) + trend_add_step_pct = float(trend_add_cfg.get("stepPct") or 0.0) + trend_add_size_pct = float(trend_add_cfg.get("sizePct") or 0.0) + trend_add_max_times = int(trend_add_cfg.get("maxTimes") or 0) - dca_add_enabled = bool(dca_add_cfg.get('enabled')) - dca_add_step_pct = float(dca_add_cfg.get('stepPct') or 0.0) - dca_add_size_pct = float(dca_add_cfg.get('sizePct') or 0.0) - dca_add_max_times = int(dca_add_cfg.get('maxTimes') or 0) + dca_add_enabled = bool(dca_add_cfg.get("enabled")) + dca_add_step_pct = float(dca_add_cfg.get("stepPct") or 0.0) + dca_add_size_pct = float(dca_add_cfg.get("sizePct") or 0.0) + dca_add_max_times = int(dca_add_cfg.get("maxTimes") or 0) - trend_reduce_enabled = bool(trend_reduce_cfg.get('enabled')) - trend_reduce_step_pct = float(trend_reduce_cfg.get('stepPct') or 0.0) - trend_reduce_size_pct = float(trend_reduce_cfg.get('sizePct') or 0.0) - trend_reduce_max_times = int(trend_reduce_cfg.get('maxTimes') or 0) + trend_reduce_enabled = bool(trend_reduce_cfg.get("enabled")) + trend_reduce_step_pct = float(trend_reduce_cfg.get("stepPct") or 0.0) + trend_reduce_size_pct = float(trend_reduce_cfg.get("sizePct") or 0.0) + trend_reduce_max_times = int(trend_reduce_cfg.get("maxTimes") or 0) - adverse_reduce_enabled = bool(adverse_reduce_cfg.get('enabled')) - adverse_reduce_step_pct = float(adverse_reduce_cfg.get('stepPct') or 0.0) - adverse_reduce_size_pct = float(adverse_reduce_cfg.get('sizePct') or 0.0) - adverse_reduce_max_times = int(adverse_reduce_cfg.get('maxTimes') or 0) + adverse_reduce_enabled = bool(adverse_reduce_cfg.get("enabled")) + adverse_reduce_step_pct = float(adverse_reduce_cfg.get("stepPct") or 0.0) + adverse_reduce_size_pct = float(adverse_reduce_cfg.get("sizePct") or 0.0) + adverse_reduce_max_times = int(adverse_reduce_cfg.get("maxTimes") or 0) # Trigger pct to price threshold with leverage trend_add_step_pct_eff = trend_add_step_pct / lev @@ -3647,11 +3918,11 @@ import pandas as pd last_dca_add_anchor = None last_trend_reduce_anchor = None last_adverse_reduce_anchor = None - + # Apply execution timing to avoid look-ahead bias in legacy signals (buy/sell series): # If signal is computed on bar close, realistic execution is next bar open. signals_exec = signals - if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']: + if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"]: try: signals_exec = signals.shift(1).fillna(0) except Exception: @@ -3667,26 +3938,28 @@ import pandas as pd is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(float(row.get('close', 0) or 0), 4), - 'amount': 0, - 'profit': liquidation_loss, - 'balance': 0 - }) - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(float(row.get("close", 0) or 0), 4), + "amount": 0, + "profit": liquidation_loss, + "balance": 0, + } + ) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": 0}) continue - + signal = signals_exec.iloc[i] if i < len(signals_exec) else 0 - high = row['high'] - low = row['low'] - price = row['close'] - open_ = row.get('open', price) + high = row["high"] + low = row["low"] + price = row["close"] + open_ = row.get("open", price) # Forced exit (TP/SL/trailing) over signals - if position != 0 and position_type in ['long', 'short']: - if position_type == 'long' and position > 0: + if position != 0 and position_type in ["long", "short"]: + if position_type == "long" and position > 0: if highest_since_entry is None: highest_since_entry = entry_price highest_since_entry = max(highest_since_entry, high) @@ -3694,11 +3967,11 @@ import pandas as pd if stop_loss_pct_eff > 0: sl_price = entry_price * (1 - stop_loss_pct_eff) if low <= sl_price: - candidates.append(('stop', sl_price)) + candidates.append(("stop", sl_price)) if take_profit_pct_eff > 0: tp_price = entry_price * (1 + take_profit_pct_eff) if high >= tp_price: - candidates.append(('profit', tp_price)) + candidates.append(("profit", tp_price)) if trailing_enabled and trailing_pct_eff > 0: trail_active = True if trailing_activation_pct_eff > 0: @@ -3706,10 +3979,10 @@ import pandas as pd if trail_active: tr_price = highest_since_entry * (1 - trailing_pct_eff) if low <= tr_price: - candidates.append(('trailing', tr_price)) + candidates.append(("trailing", tr_price)) if candidates: # SL > TrailingStop > TP - pri = {'stop': 0, 'trailing': 1, 'profit': 2} + pri = {"stop": 0, "trailing": 1, "profit": 2} reason, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), x[1]))[0] exec_price = trigger_price * (1 - slippage) commission_fee = position * exec_price * commission @@ -3717,23 +3990,29 @@ import pandas as pd profit = (exec_price - entry_price) * position - commission_fee capital += profit total_commission_paid += commission_fee - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': {'stop': 'close_long_stop', 'profit': 'close_long_profit', 'trailing': 'close_long_trailing'}.get(reason, 'close_long'), - 'price': round(exec_price, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": { + "stop": "close_long_stop", + "profit": "close_long_profit", + "trailing": "close_long_trailing", + }.get(reason, "close_long"), + "price": round(exec_price, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None liquidation_price = 0 highest_since_entry = None lowest_since_entry = None - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": round(capital, 2)}) continue - if position_type == 'short' and position < 0: + if position_type == "short" and position < 0: shares = abs(position) if lowest_since_entry is None: lowest_since_entry = entry_price @@ -3742,11 +4021,11 @@ import pandas as pd if stop_loss_pct_eff > 0: sl_price = entry_price * (1 + stop_loss_pct_eff) if high >= sl_price: - candidates.append(('stop', sl_price)) + candidates.append(("stop", sl_price)) if take_profit_pct_eff > 0: tp_price = entry_price * (1 - take_profit_pct_eff) if low <= tp_price: - candidates.append(('profit', tp_price)) + candidates.append(("profit", tp_price)) if trailing_enabled and trailing_pct_eff > 0: trail_active = True if trailing_activation_pct_eff > 0: @@ -3754,10 +4033,10 @@ import pandas as pd if trail_active: tr_price = lowest_since_entry * (1 + trailing_pct_eff) if high >= tr_price: - candidates.append(('trailing', tr_price)) + candidates.append(("trailing", tr_price)) if candidates: # SL > TrailingStop > TP - pri = {'stop': 0, 'trailing': 1, 'profit': 2} + pri = {"stop": 0, "trailing": 1, "profit": 2} reason, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), -x[1]))[0] exec_price = trigger_price * (1 + slippage) commission_fee = shares * exec_price * commission @@ -3767,46 +4046,59 @@ import pandas as pd liquidation_loss = self._liquidation_loss(capital) capital = 0 is_liquidated = True - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": liquidation_loss, + "balance": 0, + } + ) position = 0 position_type = None liquidation_price = 0 - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0}) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": 0}) continue capital += profit total_commission_paid += commission_fee - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': {'stop': 'close_short_stop', 'profit': 'close_short_profit', 'trailing': 'close_short_trailing'}.get(reason, 'close_short'), - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": { + "stop": "close_short_stop", + "profit": "close_short_profit", + "trailing": "close_short_trailing", + }.get(reason, "close_short"), + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) position = 0 position_type = None liquidation_price = 0 highest_since_entry = None lowest_since_entry = None - equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)}) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": round(capital, 2)}) continue - + # --- Parameterized scaling rules (also for old-format strategies) --- # Note: old format only has buy/sell, but scaling params should work. # Trigger pct as post-leverage threshold. # IMPORTANT: if this candle has a main buy/sell signal, do NOT apply any scale-in/scale-out. - if signal == 0 and position != 0 and position_type in ['long', 'short'] and capital >= min_capital_to_trade: + if signal == 0 and position != 0 and position_type in ["long", "short"] and capital >= min_capital_to_trade: # Long - if position_type == 'long' and position > 0: + if position_type == "long" and position > 0: # Trend add (increase positions with the trend: rising trigger) - if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times): + if ( + trend_add_enabled + and trend_add_step_pct_eff > 0 + and trend_add_size_pct > 0 + and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times) + ): anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price trigger = anchor * (1 + trend_add_step_pct_eff) if high >= trigger: @@ -3829,17 +4121,24 @@ import pandas as pd trend_add_times += 1 last_trend_add_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'add_long', - 'price': round(exec_price_add, 4), - 'amount': round(shares_add, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "add_long", + "price": round(exec_price_add, 4), + "amount": round(shares_add, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) # DCA add (Add position against the trend: Triggered by decline) - if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times): + if ( + dca_add_enabled + and dca_add_step_pct_eff > 0 + and dca_add_size_pct > 0 + and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times) + ): anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price trigger = anchor * (1 - dca_add_step_pct_eff) if low <= trigger: @@ -3862,17 +4161,24 @@ import pandas as pd dca_add_times += 1 last_dca_add_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'add_long', - 'price': round(exec_price_add, 4), - 'amount': round(shares_add, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "add_long", + "price": round(exec_price_add, 4), + "amount": round(shares_add, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) # Trend reduce (reduce positions with the trend: triggered by rising prices) - if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times): + if ( + trend_reduce_enabled + and trend_reduce_step_pct_eff > 0 + and trend_reduce_size_pct > 0 + and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times) + ): anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price trigger = anchor * (1 + trend_reduce_step_pct_eff) if high >= trigger: @@ -3889,7 +4195,9 @@ import pandas as pd position = 0 position_type = None liquidation_price = 0 - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = ( + last_adverse_reduce_anchor + ) = None trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 else: liquidation_price = entry_price * (1 - 1.0 / leverage) @@ -3897,17 +4205,26 @@ import pandas as pd trend_reduce_times += 1 last_trend_reduce_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'reduce_long', - 'price': round(exec_price_reduce, 4), - 'amount': round(reduce_shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "reduce_long", + "price": round(exec_price_reduce, 4), + "amount": round(reduce_shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) # Adverse reduce (against the trend: falling trigger) - if position_type == 'long' and position > 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times): + if ( + position_type == "long" + and position > 0 + and adverse_reduce_enabled + and adverse_reduce_step_pct_eff > 0 + and adverse_reduce_size_pct > 0 + and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times) + ): anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price trigger = anchor * (1 - adverse_reduce_step_pct_eff) if low <= trigger: @@ -3924,7 +4241,9 @@ import pandas as pd position = 0 position_type = None liquidation_price = 0 - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = ( + last_adverse_reduce_anchor + ) = None trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 else: liquidation_price = entry_price * (1 - 1.0 / leverage) @@ -3932,21 +4251,28 @@ import pandas as pd adverse_reduce_times += 1 last_adverse_reduce_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'reduce_long', - 'price': round(exec_price_reduce, 4), - 'amount': round(reduce_shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "reduce_long", + "price": round(exec_price_reduce, 4), + "amount": round(reduce_shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) # Short - if position_type == 'short' and position < 0: + if position_type == "short" and position < 0: shares_total = abs(position) # Trend add (Add short with the trend: triggered by decline) - if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times): + if ( + trend_add_enabled + and trend_add_step_pct_eff > 0 + and trend_add_size_pct > 0 + and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times) + ): anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price trigger = anchor * (1 - trend_add_step_pct_eff) if low <= trigger: @@ -3970,17 +4296,24 @@ import pandas as pd trend_add_times += 1 last_trend_add_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'add_short', - 'price': round(exec_price_add, 4), - 'amount': round(shares_add, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "add_short", + "price": round(exec_price_add, 4), + "amount": round(shares_add, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) # DCA add (Add short against the trend: rise trigger) - if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times): + if ( + dca_add_enabled + and dca_add_step_pct_eff > 0 + and dca_add_size_pct > 0 + and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times) + ): anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price trigger = anchor * (1 + dca_add_step_pct_eff) if high >= trigger: @@ -4004,17 +4337,24 @@ import pandas as pd dca_add_times += 1 last_dca_add_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'add_short', - 'price': round(exec_price_add, 4), - 'amount': round(shares_add, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "add_short", + "price": round(exec_price_add, 4), + "amount": round(shares_add, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) # Trend reduce (short reduction: triggered by decline, covering part of it) - if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times): + if ( + trend_reduce_enabled + and trend_reduce_step_pct_eff > 0 + and trend_reduce_size_pct > 0 + and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times) + ): anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price trigger = anchor * (1 - trend_reduce_step_pct_eff) if low <= trigger: @@ -4032,7 +4372,9 @@ import pandas as pd position = 0 position_type = None liquidation_price = 0 - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = ( + last_adverse_reduce_anchor + ) = None trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 else: liquidation_price = entry_price * (1 + 1.0 / leverage) @@ -4040,17 +4382,26 @@ import pandas as pd trend_reduce_times += 1 last_trend_reduce_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'reduce_short', - 'price': round(exec_price_reduce, 4), - 'amount': round(reduce_shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "reduce_short", + "price": round(exec_price_reduce, 4), + "amount": round(reduce_shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) # Adverse reduce (against the trend: rising trigger) - if position_type == 'short' and position < 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times): + if ( + position_type == "short" + and position < 0 + and adverse_reduce_enabled + and adverse_reduce_step_pct_eff > 0 + and adverse_reduce_size_pct > 0 + and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times) + ): anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price trigger = anchor * (1 + adverse_reduce_step_pct_eff) if high >= trigger: @@ -4068,7 +4419,9 @@ import pandas as pd position = 0 position_type = None liquidation_price = 0 - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = ( + last_adverse_reduce_anchor + ) = None trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 else: liquidation_price = entry_price * (1 + 1.0 / leverage) @@ -4076,21 +4429,23 @@ import pandas as pd adverse_reduce_times += 1 last_adverse_reduce_anchor = trigger - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'reduce_short', - 'price': round(exec_price_reduce, 4), - 'amount': round(reduce_shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "reduce_short", + "price": round(exec_price_reduce, 4), + "amount": round(reduce_shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) # Handle different trade directions - if trade_direction == 'long': + if trade_direction == "long": # Long only mode if signal == 1 and position == 0 and capital >= min_capital_to_trade: # Buy to open long logger.debug(f"[Long mode] Buy to open long: time={timestamp}, price={price}, leverage={leverage}x") - base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + base_price = open_ if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"] else price exec_price = base_price * (1 + slippage) # With leverage: position = capital * leverage / price # Use specified pct (entryPct preferred; else full) @@ -4103,15 +4458,15 @@ import pandas as pd else: shares = (capital * leverage) / exec_price # Margin (commission from capital) - margin = capital + margin = capital # noqa commission_fee = shares * exec_price * commission - + position = shares entry_price = exec_price - position_type = 'long' + position_type = "long" capital -= commission_fee # Only deduct commission total_commission_paid += commission_fee - + # Long liquidation when price drops to entry * (1 - 1/leverage) liquidation_price = entry_price * (1 - 1.0 / leverage) logger.debug(f"Long liquidation price: {liquidation_price:.2f}") @@ -4121,19 +4476,21 @@ import pandas as pd last_dca_add_anchor = entry_price last_trend_reduce_anchor = entry_price last_adverse_reduce_anchor = entry_price - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'open_long', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) - + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "open_long", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) + elif signal == -1 and position > 0: # Sell to close long logger.debug(f"[Long mode] Sell to close long: time={timestamp}, price={price}") - base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + base_price = open_ if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"] else price exec_price = base_price * (1 - slippage) # PnL = (exit - entry) * shares - commission commission_fee = position * exec_price * commission @@ -4141,38 +4498,46 @@ import pandas as pd capital += profit total_commission_paid += commission_fee liquidation_price = 0 # Clear liquidation price - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long', - 'price': round(exec_price, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) - + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long", + "price": round(exec_price, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) + position = 0 position_type = None - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = ( + last_adverse_reduce_anchor + ) = None trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 if capital < min_capital_to_trade: is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': 0, - 'profit': liquidation_loss, - 'balance': 0 - }) - - elif trade_direction == 'short': + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": 0, + "profit": liquidation_loss, + "balance": 0, + } + ) + + elif trade_direction == "short": # Short only mode if signal == -1 and position == 0 and capital >= min_capital_to_trade: # Sell to open short - logger.debug(f"[Short mode] Sell to open short: time={timestamp}, price={price}, leverage={leverage}x") - base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + logger.debug( + f"[Short mode] Sell to open short: time={timestamp}, price={price}, leverage={leverage}x" + ) + base_price = open_ if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"] else price exec_price = base_price * (1 - slippage) # With leverage: position = capital * leverage / price position_pct = None @@ -4184,13 +4549,13 @@ import pandas as pd else: shares = (capital * leverage) / exec_price commission_fee = shares * exec_price * commission - + position = -shares # Negative = short (owe shares) entry_price = exec_price - position_type = 'short' + position_type = "short" capital -= commission_fee # Only deduct commission total_commission_paid += commission_fee - + # Short liquidation when price rises to entry * (1 + 1/leverage) liquidation_price = entry_price * (1 + 1.0 / leverage) logger.debug(f"Short liquidation price: {liquidation_price:.2f}") @@ -4199,75 +4564,87 @@ import pandas as pd last_dca_add_anchor = entry_price last_trend_reduce_anchor = entry_price last_adverse_reduce_anchor = entry_price - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'open_short', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) - + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "open_short", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) + elif signal == 1 and position < 0: # Buy to close short logger.debug(f"[Short mode] Buy to close short: time={timestamp}, price={price}") - base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + base_price = open_ if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"] else price exec_price = base_price * (1 + slippage) shares = abs(position) # Shares to buy back # PnL = (entry - exit) * shares - commission commission_fee = shares * exec_price * commission profit = (entry_price - exec_price) * shares - commission_fee - + # Check for liquidation if capital + profit <= 0: - logger.warning(f"Insufficient funds when closing short - liquidation: capital={capital:.2f}, loss={-profit:.2f}") + logger.warning( + f"Insufficient funds when closing short - liquidation: capital={capital:.2f}, loss={-profit:.2f}" + ) liquidation_loss = self._liquidation_loss(capital) capital = 0 is_liquidated = True - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": liquidation_loss, + "balance": 0, + } + ) else: capital += profit total_commission_paid += commission_fee - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) - + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) + position = 0 position_type = None liquidation_price = 0 # Clear liquidation price - last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None + last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = ( + last_adverse_reduce_anchor + ) = None trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0 if capital < min_capital_to_trade and not is_liquidated: is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': 0, - 'profit': liquidation_loss, - 'balance': 0 - }) - - elif trade_direction == 'both': + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": 0, + "profit": liquidation_loss, + "balance": 0, + } + ) + + elif trade_direction == "both": # Both directions mode if signal == 1 and position == 0 and capital >= min_capital_to_trade: # Buy to open long logger.debug(f"[Both mode] Buy to open long: time={timestamp}, price={price}, leverage={leverage}x") - base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + base_price = open_ if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"] else price exec_price = base_price * (1 + slippage) # With leverage: position = capital * leverage / price position_pct = None @@ -4279,13 +4656,13 @@ import pandas as pd else: shares = (capital * leverage) / exec_price commission_fee = shares * exec_price * commission - + position = shares entry_price = exec_price - position_type = 'long' + position_type = "long" capital -= commission_fee # Only deduct commission total_commission_paid += commission_fee - + # Calculate liquidation price liquidation_price = entry_price * (1 - 1.0 / leverage) logger.debug(f"Long liquidation price: {liquidation_price:.2f}") @@ -4294,19 +4671,23 @@ import pandas as pd last_dca_add_anchor = entry_price last_trend_reduce_anchor = entry_price last_adverse_reduce_anchor = entry_price - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'open_long', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) - + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "open_long", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) + elif signal == -1 and position == 0 and capital >= min_capital_to_trade: # Sell to open short - logger.debug(f"[Both mode] Sell to open short: time={timestamp}, price={price}, leverage={leverage}x") - base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + logger.debug( + f"[Both mode] Sell to open short: time={timestamp}, price={price}, leverage={leverage}x" + ) + base_price = open_ if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"] else price exec_price = base_price * (1 - slippage) # With leverage: position = capital * leverage / price position_pct = None @@ -4318,13 +4699,13 @@ import pandas as pd else: shares = (capital * leverage) / exec_price commission_fee = shares * exec_price * commission - + position = -shares entry_price = exec_price - position_type = 'short' + position_type = "short" capital -= commission_fee total_commission_paid += commission_fee - + # Calculate liquidation price liquidation_price = entry_price * (1 + 1.0 / leverage) logger.debug(f"Short liquidation price: {liquidation_price:.2f}") @@ -4334,47 +4715,53 @@ import pandas as pd last_trend_reduce_anchor = entry_price last_adverse_reduce_anchor = entry_price - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'open_short', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) - + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "open_short", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) + elif signal == -1 and position > 0: # Close long open short logger.debug(f"[Both mode] Close long open short: time={timestamp}, price={price}") # First close long - base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + base_price = open_ if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"] else price exec_price = base_price * (1 - slippage) commission_fee_close = position * exec_price * commission profit = (exec_price - entry_price) * position - commission_fee_close capital += profit total_commission_paid += commission_fee_close - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long', - 'price': round(exec_price, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) - + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long", + "price": round(exec_price, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) + # Stop if balance too low after exit if capital < min_capital_to_trade or is_liquidated: is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': 0, - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": 0, + "profit": liquidation_loss, + "balance": 0, + } + ) continue # Re-open short (respects entryPct; default entryPct=100%) @@ -4387,77 +4774,87 @@ import pandas as pd else: shares = (capital * leverage) / exec_price commission_fee_open = shares * exec_price * commission - + position = -shares entry_price = exec_price - position_type = 'short' + position_type = "short" capital -= commission_fee_open total_commission_paid += commission_fee_open - + # Calculate liquidation price liquidation_price = entry_price * (1 + 1.0 / leverage) logger.debug(f"Short liquidation price: {liquidation_price:.2f}") - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'open_short', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) - + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "open_short", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) + elif signal == 1 and position < 0: # Close short open long logger.debug(f"[Both mode] Close short open long: time={timestamp}, price={price}") # First close short - base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price + base_price = open_ if signal_timing in ["next_bar_open", "next_open", "nextopen", "next"] else price exec_price = base_price * (1 + slippage) shares = abs(position) commission_fee_close = shares * exec_price * commission profit = (entry_price - exec_price) * shares - commission_fee_close - + # Check for liquidation if capital + profit <= 0: - logger.warning(f"Insufficient funds when closing short - liquidation: capital={capital:.2f}, loss={-profit:.2f}") + logger.warning( + f"Insufficient funds when closing short - liquidation: capital={capital:.2f}, loss={-profit:.2f}" + ) liquidation_loss = self._liquidation_loss(capital) capital = 0 is_liquidated = True - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": liquidation_loss, + "balance": 0, + } + ) position = 0 position_type = None continue # No new positions after liquidation - + capital += profit total_commission_paid += commission_fee_close - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) - + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) + if capital < min_capital_to_trade or is_liquidated: is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': 0, - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": 0, + "profit": liquidation_loss, + "balance": 0, + } + ) continue # Re-open long (respects entryPct; default entryPct=100%) @@ -4470,81 +4867,85 @@ import pandas as pd else: shares = (capital * leverage) / exec_price commission_fee_open = shares * exec_price * commission - + position = shares entry_price = exec_price - position_type = 'long' + position_type = "long" capital -= commission_fee_open total_commission_paid += commission_fee_open - + # Calculate liquidation price liquidation_price = entry_price * (1 - 1.0 / leverage) logger.debug(f"Long liquidation price: {liquidation_price:.2f}") - - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'open_long', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': 0, - 'balance': round(max(0, capital), 2) - }) - + + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "open_long", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": 0, + "balance": round(max(0, capital), 2), + } + ) + # Check if liquidation hit (safety net, only when no active exit) # Note: check after all signals, SL/TP takes priority if position != 0 and not is_liquidated: - if position_type == 'long': + if position_type == "long": # Long liquidation: the price falls below the liquidation line if price <= liquidation_price: - logger.warning(f"Long liquidation! entry={entry_price:.2f}, current={price:.2f}, liq_price={liquidation_price:.2f}") + logger.warning( + f"Long liquidation! entry={entry_price:.2f}, current={price:.2f}, liq_price={liquidation_price:.2f}" + ) is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(liquidation_price, 4), - 'amount': round(abs(position), 4), - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(liquidation_price, 4), + "amount": round(abs(position), 4), + "profit": liquidation_loss, + "balance": 0, + } + ) position = 0 position_type = None - equity_curve.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'value': 0 - }) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": 0}) continue - elif position_type == 'short': + elif position_type == "short": # Short liquidation: the price rises above the liquidation line if price >= liquidation_price: - logger.warning(f"Short liquidation! entry={entry_price:.2f}, current={price:.2f}, liq_price={liquidation_price:.2f}") + logger.warning( + f"Short liquidation! entry={entry_price:.2f}, current={price:.2f}, liq_price={liquidation_price:.2f}" + ) is_liquidated = True liquidation_loss = self._liquidation_loss(capital) capital = 0 - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(liquidation_price, 4), - 'amount': round(abs(position), 4), - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(liquidation_price, 4), + "amount": round(abs(position), 4), + "profit": liquidation_loss, + "balance": 0, + } + ) position = 0 position_type = None - equity_curve.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'value': 0 - }) + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": 0}) continue - + # Record equity - if position_type == 'long': + if position_type == "long": # Long equity = cash + unrealized PnL # Unrealized PnL = (current - entry) * shares unrealized_pnl = (price - entry_price) * position total_value = capital + unrealized_pnl - elif position_type == 'short': + elif position_type == "short": # Short equity = cash + unrealized PnL # Unrealized PnL = (entry - current) * shares shares = abs(position) @@ -4552,77 +4953,82 @@ import pandas as pd total_value = capital + unrealized_pnl else: total_value = capital - + # Ensure equity is not negative (liquidation already handled) if total_value < 0: total_value = 0 - - equity_curve.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'value': round(total_value, 2) - }) - + + equity_curve.append({"time": timestamp.strftime("%Y-%m-%d %H:%M"), "value": round(total_value, 2)}) + # Force exit at backtest end if position != 0: timestamp = df.index[-1] - price = df.iloc[-1]['close'] - + price = df.iloc[-1]["close"] + if position > 0: # Close long exec_price = price * (1 - slippage) commission_fee = position * exec_price * commission profit = (exec_price - entry_price) * position - commission_fee capital += profit total_commission_paid += commission_fee - + # Record close long trade - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_long', - 'price': round(exec_price, 4), - 'amount': round(position, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_long", + "price": round(exec_price, 4), + "amount": round(position, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) else: # Close short exec_price = price * (1 + slippage) shares = abs(position) commission_fee = shares * exec_price * commission profit = (entry_price - exec_price) * shares - commission_fee - + # Check for liquidation if capital + profit <= 0: - logger.warning(f"Liquidation at backtest end! Close short loss too large: capital={capital:.2f}, loss={-profit:.2f}") + logger.warning( + f"Liquidation at backtest end! Close short loss too large: capital={capital:.2f}, loss={-profit:.2f}" + ) is_liquidated = True liquidation_loss = self._liquidation_loss(capital) - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'liquidation', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': liquidation_loss, - 'balance': 0 - }) + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "liquidation", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": liquidation_loss, + "balance": 0, + } + ) capital = 0 else: capital += profit total_commission_paid += commission_fee - + # Record close short trade - trades.append({ - 'time': timestamp.strftime('%Y-%m-%d %H:%M'), - 'type': 'close_short', - 'price': round(exec_price, 4), - 'amount': round(shares, 4), - 'profit': round(profit, 2), - 'balance': round(max(0, capital), 2) - }) - + trades.append( + { + "time": timestamp.strftime("%Y-%m-%d %H:%M"), + "type": "close_short", + "price": round(exec_price, 4), + "amount": round(shares, 4), + "profit": round(profit, 2), + "balance": round(max(0, capital), 2), + } + ) + # Update last equity curve value with capital after forced exit if equity_curve: - equity_curve[-1]['value'] = round(capital, 2) - + equity_curve[-1]["value"] = round(capital, 2) + return equity_curve, trades, total_commission_paid - + def _calculate_metrics( self, equity_curve: List, @@ -4631,95 +5037,95 @@ import pandas as pd timeframe: str, start_date: datetime, end_date: datetime, - total_commission: float = 0 + total_commission: float = 0, ) -> Dict: """Calculate backtest indicators""" if not equity_curve: return {} - - final_value = equity_curve[-1]['value'] + + final_value = equity_curve[-1]["value"] total_return = (final_value - initial_capital) / initial_capital * 100 - + # Calculate annualized return: simple, not compound # For high-return strategies, compound annualization produces unrealistic numbers # Use actual data time range from equity_curve instead of requested start_date/end_date # This fixes the issue where data may only be available until a certain date (e.g., TSLA only to January) try: # Parse actual start and end times from equity_curve - actual_start_str = equity_curve[0]['time'] - actual_end_str = equity_curve[-1]['time'] - actual_start = datetime.strptime(actual_start_str, '%Y-%m-%d %H:%M') - actual_end = datetime.strptime(actual_end_str, '%Y-%m-%d %H:%M') + actual_start_str = equity_curve[0]["time"] + actual_end_str = equity_curve[-1]["time"] + actual_start = datetime.strptime(actual_start_str, "%Y-%m-%d %H:%M") + actual_end = datetime.strptime(actual_end_str, "%Y-%m-%d %H:%M") actual_days = (actual_end - actual_start).total_seconds() / 86400 except (KeyError, ValueError, IndexError) as e: # Fallback to requested date range if parsing fails logger.warning(f"Failed to parse actual time range from equity_curve: {e}, using requested range") actual_days = (end_date - start_date).total_seconds() / 86400 - + years = actual_days / 365.0 - + # Simple annualization: annualized return = total return / years if years > 0: annual_return = total_return / years else: annual_return = 0 - + # Calculate max drawdown - values = [e['value'] for e in equity_curve] + values = [e["value"] for e in equity_curve] max_drawdown = self._calculate_max_drawdown(values) - + # Calculate Sharpe ratio sharpe = self._calculate_sharpe(values, timeframe) - + # Calculate total PnL: final equity - initial capital (most accurate) total_profit = final_value - initial_capital - + # Calculate win rate (all exit trades) # Exit trades: trades with profit != 0 - closing_trades = [t for t in trades if t.get('profit', 0) != 0] - win_trades = [t for t in closing_trades if t['profit'] > 0] - loss_trades = [t for t in closing_trades if t['profit'] < 0] + closing_trades = [t for t in trades if t.get("profit", 0) != 0] + win_trades = [t for t in closing_trades if t["profit"] > 0] + loss_trades = [t for t in closing_trades if t["profit"] < 0] total_trades = len(closing_trades) win_rate = len(win_trades) / total_trades * 100 if total_trades > 0 else 0 - + # Calculate profit factor (= total profit / total loss) - total_wins = sum(t['profit'] for t in win_trades) - total_losses = abs(sum(t['profit'] for t in loss_trades)) + total_wins = sum(t["profit"] for t in win_trades) + total_losses = abs(sum(t["profit"] for t in loss_trades)) profit_factor = total_wins / total_losses if total_losses > 0 else (total_wins if total_wins > 0 else 0) - + return { - 'totalReturn': round(total_return, 2), - 'annualReturn': round(annual_return, 2), - 'maxDrawdown': round(max_drawdown, 2), - 'sharpeRatio': round(sharpe, 2), - 'winRate': round(win_rate, 2), - 'profitFactor': round(profit_factor, 2), - 'totalTrades': total_trades, - 'totalProfit': round(total_profit, 2), - 'totalCommission': round(total_commission, 2) + "totalReturn": round(total_return, 2), + "annualReturn": round(annual_return, 2), + "maxDrawdown": round(max_drawdown, 2), + "sharpeRatio": round(sharpe, 2), + "winRate": round(win_rate, 2), + "profitFactor": round(profit_factor, 2), + "totalTrades": total_trades, + "totalProfit": round(total_profit, 2), + "totalCommission": round(total_commission, 2), } - + def _calculate_max_drawdown(self, values: List[float]) -> float: """Calculate maximum drawdown""" if not values: return 0 - + peak = values[0] max_dd = 0 - + for value in values: if value > peak: peak = value dd = (peak - value) / peak * 100 if dd > max_dd: max_dd = dd - + return -max_dd - - def _calculate_sharpe(self, values: List[float], timeframe: str = '1D', risk_free_rate: float = 0.02) -> float: + + def _calculate_sharpe(self, values: List[float], timeframe: str = "1D", risk_free_rate: float = 0.02) -> float: """ Calculate Sharpe Ratio - + Args: values: list of equity curve values timeframe: time period @@ -4727,49 +5133,49 @@ import pandas as pd """ if len(values) < 2: return 0 - + # Filter out zero values (post-liquidation data), avoid division by 0 valid_values = [v for v in values if v > 0] if len(valid_values) < 2: return 0 - + # Determine annualization factor by timeframe annualization_factor = { - '1m': 252 * 24 * 60, # 1m candle: ~362,880 - '5m': 252 * 24 * 12, # 5 minutes K: about 72,576 - '15m': 252 * 24 * 4, # 15 minutes K: about 24,192 - '30m': 252 * 24 * 2, # 30 minutes K: about 12,096 - '1H': 252 * 24, # 1H candle: 6,048 - '4H': 252 * 6, # 4 hours K: 1,512 - '1D': 252, # 1D candle: 252 - '1W': 52 # 1W candle: 52 + "1m": 252 * 24 * 60, # 1m candle: ~362,880 + "5m": 252 * 24 * 12, # 5 minutes K: about 72,576 + "15m": 252 * 24 * 4, # 15 minutes K: about 24,192 + "30m": 252 * 24 * 2, # 30 minutes K: about 12,096 + "1H": 252 * 24, # 1H candle: 6,048 + "4H": 252 * 6, # 4 hours K: 1,512 + "1D": 252, # 1D candle: 252 + "1W": 52, # 1W candle: 52 }.get(timeframe, 252) - + try: # Calculate period returns returns = np.diff(valid_values) / valid_values[:-1] - + # Filter invalid values returns = returns[np.isfinite(returns)] if len(returns) == 0: return 0 - + # Annualized mean return avg_return = np.mean(returns) * annualization_factor - + # Annualized std (volatility) std_return = np.std(returns) * np.sqrt(annualization_factor) - + if std_return == 0 or not np.isfinite(std_return): return 0 - + # Sharpe ratio = (annualized return - risk-free rate) / annualized volatility sharpe = (avg_return - risk_free_rate) / std_return return sharpe if np.isfinite(sharpe) else 0 except Exception as e: logger.warning(f"Sharpe ratio calculation failed: {e}") return 0 - + def _execution_assumptions( self, strategy_config: Optional[Dict[str, Any]], @@ -4786,43 +5192,38 @@ import pandas as pd Keys use camelCase for JSON consumers (frontend). """ cfg = strategy_config or {} - raw = str((cfg.get('execution') or {}).get('signalTiming') or 'next_bar_open').strip().lower() - is_next_open = raw in ('next_bar_open', 'next_open', 'nextopen', 'next') - if raw in ('bar_close', 'close', 'same_bar_close', 'current_bar_close'): - timing_key = 'same_bar_close' + raw = str((cfg.get("execution") or {}).get("signalTiming") or "next_bar_open").strip().lower() + is_next_open = raw in ("next_bar_open", "next_open", "nextopen", "next") + if raw in ("bar_close", "close", "same_bar_close", "current_bar_close"): + timing_key = "same_bar_close" elif is_next_open: - timing_key = 'next_bar_open' + timing_key = "next_bar_open" else: timing_key = raw - default_fill = 'open' if is_next_open else 'close' + default_fill = "open" if is_next_open else "close" payload: Dict[str, Any] = { - 'signalTiming': timing_key, - 'signalTimingRaw': raw, - 'defaultFillPrice': default_fill, - 'simulationMode': simulation_mode, - 'strategyTimeframe': signal_timeframe, - 'executionTimeframe': execution_timeframe, - 'engineVersion': self.ENGINE_VERSION, - 'mtfRequested': bool(mtf_requested), - 'mtfActive': bool(mtf_active), + "signalTiming": timing_key, + "signalTimingRaw": raw, + "defaultFillPrice": default_fill, + "simulationMode": simulation_mode, + "strategyTimeframe": signal_timeframe, + "executionTimeframe": execution_timeframe, + "engineVersion": self.ENGINE_VERSION, + "mtfRequested": bool(mtf_requested), + "mtfActive": bool(mtf_active), } if mtf_fallback_reason: - payload['mtfFallbackReason'] = mtf_fallback_reason + payload["mtfFallbackReason"] = mtf_fallback_reason return payload - def _format_result( - self, - metrics: Dict, - equity_curve: List, - trades: List - ) -> Dict[str, Any]: + def _format_result(self, metrics: Dict, equity_curve: List, trades: List) -> Dict[str, Any]: """Format backtest results""" # Simplify equity curve max_points = 500 if len(equity_curve) > max_points: step = len(equity_curve) // max_points equity_curve = equity_curve[::step] - + # Clean NaN/Inf values for JSON serialization def clean_value(value): """Clean values, convert NaN/Inf to 0""" @@ -4830,20 +5231,17 @@ import pandas as pd if np.isnan(value) or np.isinf(value): return 0 return value - + # Clean metrics cleaned_metrics = {} for key, value in metrics.items(): cleaned_metrics[key] = clean_value(value) - + # Clean equity_curve cleaned_curve = [] for item in equity_curve: - cleaned_curve.append({ - 'time': item['time'], - 'value': clean_value(item['value']) - }) - + cleaned_curve.append({"time": item["time"], "value": clean_value(item["value"])}) + # Clean trades cleaned_trades = [] # Don't truncate trades: return all (frontend can paginate) @@ -4852,10 +5250,5 @@ import pandas as pd for key, value in trade.items(): cleaned_trade[key] = clean_value(value) cleaned_trades.append(cleaned_trade) - - return { - **cleaned_metrics, - 'equityCurve': cleaned_curve, - 'trades': cleaned_trades - } + return {**cleaned_metrics, "equityCurve": cleaned_curve, "trades": cleaned_trades} diff --git a/backend_api_python/app/services/billing_service.py b/backend_api_python/app/services/billing_service.py index e62ed37..7e56262 100644 --- a/backend_api_python/app/services/billing_service.py +++ b/backend_api_python/app/services/billing_service.py @@ -9,11 +9,12 @@ The current billing model is: Accounting configuration is stored in the `.env` file and can be configured through the system settings interface. """ + import os import time -from datetime import datetime, timezone, timedelta +from datetime import datetime, timedelta, timezone from decimal import Decimal -from typing import Dict, Any, Optional, Tuple +from typing import Any, Dict, Optional, Tuple from app.utils.db import get_db_connection from app.utils.logger import get_logger @@ -22,51 +23,50 @@ logger = get_logger(__name__) # Function billing configuration key name -BILLING_CONFIG_PREFIX = 'BILLING_' +BILLING_CONFIG_PREFIX = "BILLING_" # Default billing configuration DEFAULT_BILLING_CONFIG = { # global switch - 'enabled': False, # Whether to enable billing - + "enabled": False, # Whether to enable billing # Point consumption for each function (0 means free) # ai_analysis unified unit price: real-time analysis / AI filtering / scheduled tasks are all deducted based on this unit price × number of targets - 'cost_ai_analysis': 10, - 'cost_ai_code_gen': 30, - 'cost_polymarket_deep_analysis': 15, + "cost_ai_analysis": 10, + "cost_ai_code_gen": 30, + "cost_polymarket_deep_analysis": 15, } # Feature name mapping (for log recording) FEATURE_NAMES = { - 'ai_analysis': 'AI Analysis', - 'ai_code_gen': 'AI Code Generation', - 'polymarket_deep_analysis': 'Polymarket Deep Analysis', + "ai_analysis": "AI Analysis", + "ai_code_gen": "AI Code Generation", + "polymarket_deep_analysis": "Polymarket Deep Analysis", } class BillingService: """Billing service category""" - + def __init__(self): self._config_cache = None self._config_cache_time = 0 self._cache_ttl = 60 # Configure cache for 60 seconds - + def get_billing_config(self) -> Dict[str, Any]: """Get billing configuration""" now = time.time() if self._config_cache and (now - self._config_cache_time) < self._cache_ttl: return self._config_cache - + config = {} for key, default_value in DEFAULT_BILLING_CONFIG.items(): - env_key = f'{BILLING_CONFIG_PREFIX}{key.upper()}' + env_key = f"{BILLING_CONFIG_PREFIX}{key.upper()}" value = os.getenv(env_key) - - if value is None or value == '': + + if value is None or value == "": config[key] = default_value elif isinstance(default_value, bool): - config[key] = str(value).lower() in ('true', '1', 'yes') + config[key] = str(value).lower() in ("true", "1", "yes") elif isinstance(default_value, int): try: config[key] = int(value) @@ -74,50 +74,47 @@ class BillingService: config[key] = default_value else: config[key] = value - + self._config_cache = config self._config_cache_time = now return config - + def clear_config_cache(self): """Clear configuration cache""" self._config_cache = None self._config_cache_time = 0 - + def is_billing_enabled(self) -> bool: """Check if billing is enabled""" config = self.get_billing_config() - return config.get('enabled', False) - + return config.get("enabled", False) + def get_feature_cost(self, feature: str) -> int: """Get the points consumption of the specified function, 0 means free""" config = self.get_billing_config() - cost_key = f'cost_{feature}' + cost_key = f"cost_{feature}" return config.get(cost_key, 0) - + def get_user_credits(self, user_id: int) -> Decimal: """Get user points balance""" try: with get_db_connection() as db: cur = db.cursor() - cur.execute( - "SELECT credits FROM qd_users WHERE id = ?", - (user_id,) - ) + cur.execute("SELECT credits FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() - + if row: - return Decimal(str(row.get('credits', 0) or 0)) - return Decimal('0') + return Decimal(str(row.get("credits", 0) or 0)) + return Decimal("0") except Exception as e: logger.error(f"get_user_credits failed: {e}") - return Decimal('0') - + return Decimal("0") + def get_user_vip_status(self, user_id: int) -> Tuple[bool, Optional[datetime]]: """ Get user VIP status - + Returns: (is_vip, expires_at): whether the VIP is valid, VIP expiration time """ @@ -135,21 +132,21 @@ class BillingService: cur.execute("SELECT vip_expires_at FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() - - if row and row.get('vip_expires_at'): - expires_at = row['vip_expires_at'] + + if row and row.get("vip_expires_at"): + expires_at = row["vip_expires_at"] # Make sure it is a datetime object if isinstance(expires_at, str): - expires_at = datetime.fromisoformat(expires_at.replace('Z', '+00:00')) - + expires_at = datetime.fromisoformat(expires_at.replace("Z", "+00:00")) + # Check if expired now = datetime.now(timezone.utc) if expires_at.tzinfo is None: expires_at = expires_at.replace(tzinfo=timezone.utc) - + is_vip = expires_at > now return is_vip, expires_at - + return False, None except Exception as e: logger.error(f"get_user_vip_status failed: {e}") @@ -166,6 +163,7 @@ class BillingService: - yearly: price_usd, credits_once, duration_days - lifetime: price_usd, credits_monthly (granted every 30 days) """ + def _f(key: str, default: float) -> float: try: return float(os.getenv(key, str(default)).strip()) @@ -292,14 +290,26 @@ class BillingService: if credits_once > 0: # Use add_credits to update balance and log # NOTE: add_credits opens its own connection, so we do a direct update here for atomicity. - self._add_credits_in_tx(cur, user_id, credits_once, action="membership_bonus", - remark=f"Membership bonus ({plan})", reference_id=order_ref) + self._add_credits_in_tx( + cur, + user_id, + credits_once, + action="membership_bonus", + remark=f"Membership bonus ({plan})", + reference_id=order_ref, + ) else: # Lifetime: grant first month's credits immediately and set last grant time monthly_credits = int(plans["lifetime"].get("credits_monthly") or 0) if monthly_credits > 0: - self._add_credits_in_tx(cur, user_id, monthly_credits, action="membership_monthly", - remark="Lifetime membership monthly credits", reference_id=order_ref) + self._add_credits_in_tx( + cur, + user_id, + monthly_credits, + action="membership_monthly", + remark="Lifetime membership monthly credits", + reference_id=order_ref, + ) try: cur.execute( "UPDATE qd_users SET vip_monthly_credits_last_grant = ?, updated_at = NOW() WHERE id = ?", @@ -324,11 +334,15 @@ class BillingService: db.commit() cur.close() - return True, "success", { - "order_id": order_id, - "plan": plan, - "vip_expires_at": vip_expires_at.isoformat() if vip_expires_at else None, - } + return ( + True, + "success", + { + "order_id": order_id, + "plan": plan, + "vip_expires_at": vip_expires_at.isoformat() if vip_expires_at else None, + }, + ) except Exception as e: logger.error(f"purchase_membership failed: {e}", exc_info=True) return False, f"error:{str(e)}", {} @@ -363,7 +377,7 @@ class BillingService: except Exception: pass - def _add_credits_in_tx(self, cur, user_id: int, amount: int, action: str, remark: str, reference_id: str = ''): + def _add_credits_in_tx(self, cur, user_id: int, amount: int, action: str, remark: str, reference_id: str = ""): """Add credits within an existing DB transaction and write qd_credits_log.""" try: cur.execute("SELECT credits FROM qd_users WHERE id = ?", (user_id,)) @@ -371,7 +385,9 @@ class BillingService: credits = Decimal(str(row.get("credits", 0) or 0)) new_balance = credits + Decimal(str(amount)) - cur.execute("UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", (float(new_balance), user_id)) + cur.execute( + "UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", (float(new_balance), user_id) + ) cur.execute( """ INSERT INTO qd_credits_log @@ -437,8 +453,14 @@ class BillingService: periods = 6 total = monthly_credits * periods - self._add_credits_in_tx(cur, user_id, total, action="membership_monthly", - remark=f"Lifetime membership monthly credits x{periods}", reference_id="") + self._add_credits_in_tx( + cur, + user_id, + total, + action="membership_monthly", + remark=f"Lifetime membership monthly credits x{periods}", + reference_id="", + ) cur.execute( "UPDATE qd_users SET vip_monthly_credits_last_grant = ?, updated_at = NOW() WHERE id = ?", (now, user_id), @@ -446,29 +468,29 @@ class BillingService: except Exception: # Best-effort; never break caller pass - - def check_and_consume(self, user_id: int, feature: str, reference_id: str = '') -> Tuple[bool, str]: + + def check_and_consume(self, user_id: int, feature: str, reference_id: str = "") -> Tuple[bool, str]: """ Check and spend points - + Args: user_id: user ID feature: function name (ai_analysis / polymarket_deep_analysis) reference_id: association ID (optional) - + Returns: (success, message): Whether it is successful, prompt message """ # Check if billing is enabled if not self.is_billing_enabled(): - return True, 'billing_disabled' - - config = self.get_billing_config() + return True, "billing_disabled" + + config = self.get_billing_config() # noqa cost = self.get_feature_cost(feature) - + # free features if cost <= 0: - return True, 'free_feature' + return True, "free_feature" # Note: There is no longer a global point deduction bypass based on VIP. # VIP/membership is reserved only for package, expiry time and community vip_free metric benefits. @@ -476,48 +498,62 @@ class BillingService: # Check points balance credits = self.get_user_credits(user_id) if credits < cost: - return False, f'insufficient_credits:{credits}:{cost}' - + return False, f"insufficient_credits:{credits}:{cost}" + # Points deducted try: new_balance = credits - Decimal(str(cost)) - + with get_db_connection() as db: cur = db.cursor() - + # Update user points cur.execute( - "UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", - (float(new_balance), user_id) + "UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", (float(new_balance), user_id) ) - + # Logging - Use UTC time to ensure correct display across time zones feature_name = FEATURE_NAMES.get(feature, feature) created_at_utc = datetime.now(timezone.utc) cur.execute( """ - INSERT INTO qd_credits_log + INSERT INTO qd_credits_log (user_id, action, amount, balance_after, feature, reference_id, remark, created_at) VALUES (?, 'consume', ?, ?, ?, ?, ?, ?) """, - (user_id, -cost, float(new_balance), feature, reference_id, f'Consume: {feature_name}', created_at_utc) + ( + user_id, + -cost, + float(new_balance), + feature, + reference_id, + f"Consume: {feature_name}", + created_at_utc, + ), ) - + db.commit() cur.close() - + logger.info(f"User {user_id} consumed {cost} credits for {feature}, balance: {new_balance}") - return True, 'consumed' - + return True, "consumed" + except Exception as e: logger.error(f"check_and_consume failed: {e}") - return False, f'error:{str(e)}' - - def add_credits(self, user_id: int, amount: int, action: str = 'recharge', - remark: str = '', operator_id: int = None, reference_id: str = '') -> Tuple[bool, str]: + return False, f"error:{str(e)}" + + def add_credits( + self, + user_id: int, + amount: int, + action: str = "recharge", + remark: str = "", + operator_id: int = None, + reference_id: str = "", + ) -> Tuple[bool, str]: """ Increase user points - + Args: user_id: user ID amount: increase the amount (positive number) @@ -525,157 +561,149 @@ class BillingService: remark: remark operator_id: operator ID (when the administrator operates) reference_id: association ID (such as invited user ID, order number, etc.) - + Returns: (success, message) """ if amount <= 0: - return False, 'amount_must_be_positive' - + return False, "amount_must_be_positive" + try: credits = self.get_user_credits(user_id) new_balance = credits + Decimal(str(amount)) - + with get_db_connection() as db: cur = db.cursor() - + # Update user points cur.execute( - "UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", - (float(new_balance), user_id) + "UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", (float(new_balance), user_id) ) - + # Logging (contains reference_id) cur.execute( """ - INSERT INTO qd_credits_log + INSERT INTO qd_credits_log (user_id, action, amount, balance_after, remark, operator_id, reference_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, NOW()) """, - (user_id, action, amount, float(new_balance), remark, operator_id, reference_id) + (user_id, action, amount, float(new_balance), remark, operator_id, reference_id), ) - + db.commit() cur.close() - + logger.info(f"User {user_id} added {amount} credits ({action}), balance: {new_balance}") return True, str(new_balance) - + except Exception as e: logger.error(f"add_credits failed: {e}") return False, str(e) - - def set_credits(self, user_id: int, amount: int, remark: str = '', - operator_id: int = None) -> Tuple[bool, str]: + + def set_credits(self, user_id: int, amount: int, remark: str = "", operator_id: int = None) -> Tuple[bool, str]: """ Set user points (set directly by the administrator) - + Args: user_id: user ID amount: the set amount remark: remark operator_id: operator ID - + Returns: (success, message) """ if amount < 0: - return False, 'amount_cannot_be_negative' - + return False, "amount_cannot_be_negative" + try: old_credits = self.get_user_credits(user_id) diff = Decimal(str(amount)) - old_credits - + with get_db_connection() as db: cur = db.cursor() - + # Update user points - cur.execute( - "UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", - (amount, user_id) - ) - + cur.execute("UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", (amount, user_id)) + # logging cur.execute( """ - INSERT INTO qd_credits_log + INSERT INTO qd_credits_log (user_id, action, amount, balance_after, remark, operator_id, created_at) VALUES (?, 'admin_adjust', ?, ?, ?, ?, NOW()) """, - (user_id, float(diff), amount, remark or f'Admin adjust: {old_credits} -> {amount}', operator_id) + (user_id, float(diff), amount, remark or f"Admin adjust: {old_credits} -> {amount}", operator_id), ) - + db.commit() cur.close() - + logger.info(f"User {user_id} credits set to {amount} by admin {operator_id}") return True, str(amount) - + except Exception as e: logger.error(f"set_credits failed: {e}") return False, str(e) - - def set_vip(self, user_id: int, expires_at: Optional[datetime], - remark: str = '', operator_id: int = None) -> Tuple[bool, str]: + + def set_vip( + self, user_id: int, expires_at: Optional[datetime], remark: str = "", operator_id: int = None + ) -> Tuple[bool, str]: """ Set user VIP status - + Args: user_id: user ID expires_at: VIP expiration time (None means canceling VIP) remark: remark operator_id: operator ID - + Returns: (success, message) """ try: with get_db_connection() as db: cur = db.cursor() - + # Update VIP expiration time cur.execute( - "UPDATE qd_users SET vip_expires_at = ?, updated_at = NOW() WHERE id = ?", - (expires_at, user_id) + "UPDATE qd_users SET vip_expires_at = ?, updated_at = NOW() WHERE id = ?", (expires_at, user_id) ) - + # logging - action = 'vip_grant' if expires_at else 'vip_revoke' - log_remark = remark or (f'VIP granted until {expires_at}' if expires_at else 'VIP revoked') + action = "vip_grant" if expires_at else "vip_revoke" + log_remark = remark or (f"VIP granted until {expires_at}" if expires_at else "VIP revoked") cur.execute( """ - INSERT INTO qd_credits_log + INSERT INTO qd_credits_log (user_id, action, amount, balance_after, remark, operator_id, created_at) VALUES (?, ?, 0, (SELECT credits FROM qd_users WHERE id = ?), ?, ?, NOW()) """, - (user_id, action, user_id, log_remark, operator_id) + (user_id, action, user_id, log_remark, operator_id), ) - + db.commit() cur.close() - + logger.info(f"User {user_id} VIP set to {expires_at} by admin {operator_id}") - return True, 'success' - + return True, "success" + except Exception as e: logger.error(f"set_vip failed: {e}") return False, str(e) - + def get_credits_log(self, user_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]: """Get user points change log""" offset = (page - 1) * page_size - + try: with get_db_connection() as db: cur = db.cursor() - + # Get total - cur.execute( - "SELECT COUNT(*) as count FROM qd_credits_log WHERE user_id = ?", - (user_id,) - ) - total = cur.fetchone()['count'] - + cur.execute("SELECT COUNT(*) as count FROM qd_credits_log WHERE user_id = ?", (user_id,)) + total = cur.fetchone()["count"] + # Get log cur.execute( """ @@ -685,7 +713,7 @@ class BillingService: ORDER BY created_at DESC LIMIT ? OFFSET ? """, - (user_id, page_size, offset) + (user_id, page_size, offset), ) rows = cur.fetchall() or [] cur.close() @@ -694,43 +722,43 @@ class BillingService: logs = [] for r in rows: d = dict(r) - if d.get('created_at'): - dt = d['created_at'] - if hasattr(dt, 'isoformat'): - if getattr(dt, 'tzinfo', None) is not None: - d['created_at'] = dt.astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S') + 'Z' + if d.get("created_at"): + dt = d["created_at"] + if hasattr(dt, "isoformat"): + if getattr(dt, "tzinfo", None) is not None: + d["created_at"] = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") + "Z" else: # No time zone: New records are written in UTC, and old records may be in the local time of the server. They are returned in UTC to facilitate correct conversion by the front end. - d['created_at'] = dt.strftime('%Y-%m-%dT%H:%M:%S') + 'Z' + d["created_at"] = dt.strftime("%Y-%m-%dT%H:%M:%S") + "Z" logs.append(d) - + return { - 'items': logs, - 'total': total, - 'page': page, - 'page_size': page_size, - 'total_pages': (total + page_size - 1) // page_size + "items": logs, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size, } except Exception as e: logger.error(f"get_credits_log failed: {e}") - return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0} - + return {"items": [], "total": 0, "page": 1, "page_size": page_size, "total_pages": 0} + def get_user_billing_info(self, user_id: int) -> Dict[str, Any]: """Get a snapshot of user billing and membership information (for front-end display)""" credits = self.get_user_credits(user_id) is_vip, vip_expires_at = self.get_user_vip_status(user_id) config = self.get_billing_config() - + return { - 'credits': float(credits), - 'is_vip': is_vip, - 'vip_expires_at': vip_expires_at.isoformat() if vip_expires_at else None, - 'billing_enabled': config.get('enabled', False), - 'feature_costs': { - 'ai_analysis': config.get('cost_ai_analysis', 0), - 'ai_code_gen': config.get('cost_ai_code_gen', 0), - 'polymarket_deep_analysis': config.get('cost_polymarket_deep_analysis', 0), - } + "credits": float(credits), + "is_vip": is_vip, + "vip_expires_at": vip_expires_at.isoformat() if vip_expires_at else None, + "billing_enabled": config.get("enabled", False), + "feature_costs": { + "ai_analysis": config.get("cost_ai_analysis", 0), + "ai_code_gen": config.get("cost_ai_code_gen", 0), + "polymarket_deep_analysis": config.get("cost_polymarket_deep_analysis", 0), + }, } diff --git a/backend_api_python/app/services/community_service.py b/backend_api_python/app/services/community_service.py index f218e96..c3c1cf9 100644 --- a/backend_api_python/app/services/community_service.py +++ b/backend_api_python/app/services/community_service.py @@ -3,21 +3,22 @@ Community Service - indicator community service Handles functions such as indicator markets, purchases, and comments. """ + import json import time from decimal import Decimal -from typing import Dict, Any, List, Optional, Tuple +from typing import Any, Dict, Optional, Tuple +from app.services.billing_service import get_billing_service from app.utils.db import get_db_connection from app.utils.logger import get_logger -from app.services.billing_service import get_billing_service logger = get_logger(__name__) class CommunityService: """Indicator Community Service Category""" - + def __init__(self): self.billing = get_billing_service() # Best-effort: ensure vip_free column exists (for old databases) @@ -29,69 +30,72 @@ class CommunityService: cur.close() except Exception: pass - + # ========================================== # indicator market # ========================================== - + def get_market_indicators( self, page: int = 1, page_size: int = 12, keyword: str = None, pricing_type: str = None, # 'free' / 'paid' / None(all) - sort_by: str = 'newest', # 'newest' / 'hot' / 'price_asc' / 'price_desc' / 'rating' - user_id: int = None # Current user ID, used to determine whether purchase has been made + sort_by: str = "newest", # 'newest' / 'hot' / 'price_asc' / 'price_desc' / 'rating' + user_id: int = None, # Current user ID, used to determine whether purchase has been made ) -> Dict[str, Any]: """Get a list of published indicators on the market""" offset = (page - 1) * page_size - + try: with get_db_connection() as db: cur = db.cursor() - + # Build query conditions - only display published and approved indicators - where_clauses = ["i.publish_to_community = 1", "(i.review_status = 'approved' OR i.review_status IS NULL)"] + where_clauses = [ + "i.publish_to_community = 1", + "(i.review_status = 'approved' OR i.review_status IS NULL)", + ] params = [] - + if keyword and keyword.strip(): where_clauses.append("(i.name ILIKE ? OR i.description ILIKE ?)") search_term = f"%{keyword.strip()}%" params.extend([search_term, search_term]) - - if pricing_type == 'free': + + if pricing_type == "free": where_clauses.append("(i.pricing_type = 'free' OR i.price <= 0)") - elif pricing_type == 'paid': + elif pricing_type == "paid": where_clauses.append("(i.pricing_type != 'free' AND i.price > 0)") - + where_sql = " AND ".join(where_clauses) - + # sort order_map = { - 'newest': 'i.created_at DESC', - 'hot': 'i.purchase_count DESC, i.view_count DESC', - 'price_asc': 'i.price ASC, i.created_at DESC', - 'price_desc': 'i.price DESC, i.created_at DESC', - 'rating': 'i.avg_rating DESC, i.rating_count DESC' + "newest": "i.created_at DESC", + "hot": "i.purchase_count DESC, i.view_count DESC", + "price_asc": "i.price ASC, i.created_at DESC", + "price_desc": "i.price DESC, i.created_at DESC", + "rating": "i.avg_rating DESC, i.rating_count DESC", } - order_sql = order_map.get(sort_by, 'i.created_at DESC') - + order_sql = order_map.get(sort_by, "i.created_at DESC") + # Get total count_sql = f""" - SELECT COUNT(*) as count - FROM qd_indicator_codes i + SELECT COUNT(*) as count + FROM qd_indicator_codes i WHERE {where_sql} """ cur.execute(count_sql, tuple(params)) - total = cur.fetchone()['count'] - + total = cur.fetchone()["count"] + # Get the list (join the table to query author information) query_sql = f""" - SELECT + SELECT i.id, i.name, i.description, i.pricing_type, i.price, COALESCE(i.vip_free, FALSE) as vip_free, i.preview_image, i.purchase_count, i.avg_rating, i.rating_count, i.view_count, i.created_at, i.updated_at, - u.id as author_id, u.username as author_username, + u.id as author_id, u.username as author_username, u.nickname as author_nickname, u.avatar as author_avatar FROM qd_indicator_codes i LEFT JOIN qd_users u ON i.user_id = u.id @@ -101,138 +105,143 @@ class CommunityService: """ cur.execute(query_sql, tuple(params + [page_size, offset])) rows = cur.fetchall() or [] - + # If there is a current user, query the purchased indicators purchased_ids = set() if user_id: - indicator_ids = [r['id'] for r in rows] + indicator_ids = [r["id"] for r in rows] if indicator_ids: - placeholders = ','.join(['?'] * len(indicator_ids)) + placeholders = ",".join(["?"] * len(indicator_ids)) cur.execute( f"SELECT indicator_id FROM qd_indicator_purchases WHERE buyer_id = ? AND indicator_id IN ({placeholders})", - tuple([user_id] + indicator_ids) + tuple([user_id] + indicator_ids), ) - purchased_ids = {r['indicator_id'] for r in (cur.fetchall() or [])} - + purchased_ids = {r["indicator_id"] for r in (cur.fetchall() or [])} + cur.close() - - #Format return data + + # Format return data items = [] for row in rows: - items.append({ - 'id': row['id'], - 'name': row['name'], - 'description': row['description'][:200] if row['description'] else '', - 'pricing_type': row['pricing_type'] or 'free', - 'price': float(row['price'] or 0), - 'vip_free': bool(row.get('vip_free') or False), - 'preview_image': row['preview_image'] or '', - 'purchase_count': row['purchase_count'] or 0, - 'avg_rating': float(row['avg_rating'] or 0), - 'rating_count': row['rating_count'] or 0, - 'view_count': row['view_count'] or 0, - 'created_at': row['created_at'].isoformat() if row['created_at'] else None, - 'author': { - 'id': row['author_id'], - 'username': row['author_username'], - 'nickname': row['author_nickname'] or row['author_username'], - 'avatar': row['author_avatar'] or '/avatar2.jpg' - }, - 'is_purchased': row['id'] in purchased_ids, - 'is_own': row['author_id'] == user_id - }) - + items.append( + { + "id": row["id"], + "name": row["name"], + "description": row["description"][:200] if row["description"] else "", + "pricing_type": row["pricing_type"] or "free", + "price": float(row["price"] or 0), + "vip_free": bool(row.get("vip_free") or False), + "preview_image": row["preview_image"] or "", + "purchase_count": row["purchase_count"] or 0, + "avg_rating": float(row["avg_rating"] or 0), + "rating_count": row["rating_count"] or 0, + "view_count": row["view_count"] or 0, + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "author": { + "id": row["author_id"], + "username": row["author_username"], + "nickname": row["author_nickname"] or row["author_username"], + "avatar": row["author_avatar"] or "/avatar2.jpg", + }, + "is_purchased": row["id"] in purchased_ids, + "is_own": row["author_id"] == user_id, + } + ) + return { - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'total_pages': (total + page_size - 1) // page_size if total > 0 else 0 + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size if total > 0 else 0, } - + except Exception as e: logger.error(f"get_market_indicators failed: {e}") - return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0} - + return {"items": [], "total": 0, "page": 1, "page_size": page_size, "total_pages": 0} + def get_indicator_detail(self, indicator_id: int, user_id: int = None) -> Optional[Dict[str, Any]]: """Get indicator details.""" try: with get_db_connection() as db: cur = db.cursor() - + # Get indicator information - cur.execute(""" - SELECT + cur.execute( + """ + SELECT i.id, i.name, i.description, i.pricing_type, i.price, COALESCE(i.vip_free, FALSE) as vip_free, i.preview_image, i.purchase_count, i.avg_rating, i.rating_count, i.view_count, i.publish_to_community, i.created_at, i.updated_at, i.user_id, - u.id as author_id, u.username as author_username, + u.id as author_id, u.username as author_username, u.nickname as author_nickname, u.avatar as author_avatar FROM qd_indicator_codes i LEFT JOIN qd_users u ON i.user_id = u.id WHERE i.id = ? - """, (indicator_id,)) + """, + (indicator_id,), + ) row = cur.fetchone() - + if not row: cur.close() return None - + # Check if it has been published to the community (or its own indicator) - if not row['publish_to_community'] and row['user_id'] != user_id: + if not row["publish_to_community"] and row["user_id"] != user_id: cur.close() return None - + # Check if purchased is_purchased = False if user_id: cur.execute( "SELECT id FROM qd_indicator_purchases WHERE indicator_id = ? AND buyer_id = ?", - (indicator_id, user_id) + (indicator_id, user_id), ) is_purchased = cur.fetchone() is not None - + # Increase the number of views cur.execute( "UPDATE qd_indicator_codes SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?", - (indicator_id,) + (indicator_id,), ) db.commit() cur.close() - + return { - 'id': row['id'], - 'name': row['name'], - 'description': row['description'] or '', - 'pricing_type': row['pricing_type'] or 'free', - 'price': float(row['price'] or 0), - 'vip_free': bool(row.get('vip_free') or False), - 'preview_image': row['preview_image'] or '', - 'purchase_count': row['purchase_count'] or 0, - 'avg_rating': float(row['avg_rating'] or 0), - 'rating_count': row['rating_count'] or 0, - 'view_count': (row['view_count'] or 0) + 1, - 'created_at': row['created_at'].isoformat() if row['created_at'] else None, - 'updated_at': row['updated_at'].isoformat() if row['updated_at'] else None, - 'author': { - 'id': row['author_id'], - 'username': row['author_username'], - 'nickname': row['author_nickname'] or row['author_username'], - 'avatar': row['author_avatar'] or '/avatar2.jpg' + "id": row["id"], + "name": row["name"], + "description": row["description"] or "", + "pricing_type": row["pricing_type"] or "free", + "price": float(row["price"] or 0), + "vip_free": bool(row.get("vip_free") or False), + "preview_image": row["preview_image"] or "", + "purchase_count": row["purchase_count"] or 0, + "avg_rating": float(row["avg_rating"] or 0), + "rating_count": row["rating_count"] or 0, + "view_count": (row["view_count"] or 0) + 1, + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + "author": { + "id": row["author_id"], + "username": row["author_username"], + "nickname": row["author_nickname"] or row["author_username"], + "avatar": row["author_avatar"] or "/avatar2.jpg", }, - 'is_purchased': is_purchased, - 'is_own': row['user_id'] == user_id + "is_purchased": is_purchased, + "is_own": row["user_id"] == user_id, } - + except Exception as e: logger.error(f"get_indicator_detail failed: {e}") return None - + # ========================================== # Purchase function # ========================================== - + def purchase_indicator(self, buyer_id: int, indicator_id: int) -> Tuple[bool, str, Dict[str, Any]]: """ Purchase an indicator. @@ -243,147 +252,186 @@ class CommunityService: try: with get_db_connection() as db: cur = db.cursor() - + # 1. Get indicator information - cur.execute(""" + cur.execute( + """ SELECT id, user_id, name, code, description, pricing_type, price, COALESCE(vip_free, FALSE) as vip_free, preview_image, is_encrypted FROM qd_indicator_codes WHERE id = ? AND publish_to_community = 1 - """, (indicator_id,)) + """, + (indicator_id,), + ) indicator = cur.fetchone() - + if not indicator: cur.close() - return False, 'indicator_not_found', {} - - seller_id = indicator['user_id'] - price = float(indicator['price'] or 0) - pricing_type = indicator['pricing_type'] or 'free' - vip_free = bool(indicator.get('vip_free') or False) + return False, "indicator_not_found", {} + + seller_id = indicator["user_id"] + price = float(indicator["price"] or 0) + pricing_type = indicator["pricing_type"] or "free" + vip_free = bool(indicator.get("vip_free") or False) is_vip, _ = self.billing.get_user_vip_status(buyer_id) # VIP-free indicator: VIP users can get it without credits charge effective_price = 0.0 if (vip_free and is_vip) else price - + # 2. Check whether to buy your own indicator if seller_id == buyer_id: cur.close() - return False, 'cannot_buy_own', {} - + return False, "cannot_buy_own", {} + # 3. Check if purchased cur.execute( "SELECT id FROM qd_indicator_purchases WHERE indicator_id = ? AND buyer_id = ?", - (indicator_id, buyer_id) + (indicator_id, buyer_id), ) if cur.fetchone(): cur.close() - return False, 'already_purchased', {} - + return False, "already_purchased", {} + # 4. If it is a paid indicator, check and deduct points - if pricing_type != 'free' and effective_price > 0: + if pricing_type != "free" and effective_price > 0: buyer_credits = self.billing.get_user_credits(buyer_id) if buyer_credits < effective_price: cur.close() - return False, 'insufficient_credits', { - 'required': effective_price, - 'current': float(buyer_credits) - } - + return ( + False, + "insufficient_credits", + {"required": effective_price, "current": float(buyer_credits)}, + ) + # Deduct buyer points new_buyer_balance = buyer_credits - Decimal(str(effective_price)) cur.execute( "UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", - (float(new_buyer_balance), buyer_id) + (float(new_buyer_balance), buyer_id), ) - + # Record buyer points log - cur.execute(""" - INSERT INTO qd_credits_log + cur.execute( + """ + INSERT INTO qd_credits_log (user_id, action, amount, balance_after, feature, reference_id, remark, created_at) VALUES (?, 'indicator_purchase', ?, ?, 'indicator_purchase', ?, ?, NOW()) - """, (buyer_id, -effective_price, float(new_buyer_balance), str(indicator_id), - f"Buy indicator: {indicator['name']}")) - + """, + ( + buyer_id, + -effective_price, + float(new_buyer_balance), + str(indicator_id), + f"Buy indicator: {indicator['name']}", + ), + ) + # Increase points for sellers (the commission ratio can be configured, here 100% is given to sellers first) seller_credits = self.billing.get_user_credits(seller_id) new_seller_balance = seller_credits + Decimal(str(effective_price)) cur.execute( "UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", - (float(new_seller_balance), seller_id) + (float(new_seller_balance), seller_id), ) - + # Record seller points log - cur.execute(""" - INSERT INTO qd_credits_log + cur.execute( + """ + INSERT INTO qd_credits_log (user_id, action, amount, balance_after, feature, reference_id, remark, created_at) VALUES (?, 'indicator_sale', ?, ?, 'indicator_sale', ?, ?, NOW()) - """, (seller_id, effective_price, float(new_seller_balance), str(indicator_id), - f"Sell indicator: {indicator['name']}")) - + """, + ( + seller_id, + effective_price, + float(new_seller_balance), + str(indicator_id), + f"Sell indicator: {indicator['name']}", + ), + ) + # 5. Create purchase record - cur.execute(""" - INSERT INTO qd_indicator_purchases + cur.execute( + """ + INSERT INTO qd_indicator_purchases (indicator_id, buyer_id, seller_id, price, created_at) VALUES (?, ?, ?, ?, NOW()) - """, (indicator_id, buyer_id, seller_id, effective_price)) - + """, + (indicator_id, buyer_id, seller_id, effective_price), + ) + # 6. Copy the indicator to the buyer’s account now_ts = int(time.time()) # Get vip_free as boolean from indicator - vip_free_value = bool(indicator.get('vip_free') or False) - cur.execute(""" + vip_free_value = bool(indicator.get("vip_free") or False) + cur.execute( + """ INSERT INTO qd_indicator_codes (user_id, is_buy, end_time, name, code, description, publish_to_community, pricing_type, price, is_encrypted, preview_image, vip_free, createtime, updatetime, created_at, updated_at) VALUES (?, 1, 0, ?, ?, ?, 0, 'free', 0, ?, ?, ?, ?, ?, NOW(), NOW()) - """, ( - buyer_id, - indicator['name'], - indicator['code'], - indicator['description'], - indicator['is_encrypted'] or 0, - indicator['preview_image'], - vip_free_value, # Use boolean value instead of integer 0 - now_ts, now_ts - )) - + """, + ( + buyer_id, + indicator["name"], + indicator["code"], + indicator["description"], + indicator["is_encrypted"] or 0, + indicator["preview_image"], + vip_free_value, # Use boolean value instead of integer 0 + now_ts, + now_ts, + ), + ) + # 7. Update indicator purchase times - cur.execute(""" - UPDATE qd_indicator_codes - SET purchase_count = COALESCE(purchase_count, 0) + 1 + cur.execute( + """ + UPDATE qd_indicator_codes + SET purchase_count = COALESCE(purchase_count, 0) + 1 WHERE id = ? - """, (indicator_id,)) - + """, + (indicator_id,), + ) + db.commit() cur.close() - - logger.info(f"User {buyer_id} purchased indicator {indicator_id} for {effective_price} credits (vip_free={vip_free}, is_vip={is_vip})") - return True, 'success', {'indicator_name': indicator['name'], 'price': price, 'charged': effective_price, 'vip_free': vip_free} - + + logger.info( + f"User {buyer_id} purchased indicator {indicator_id} for {effective_price} credits (vip_free={vip_free}, is_vip={is_vip})" + ) + return ( + True, + "success", + { + "indicator_name": indicator["name"], + "price": price, + "charged": effective_price, + "vip_free": vip_free, + }, + ) + except Exception as e: logger.error(f"purchase_indicator failed: {e}") - return False, f'error: {str(e)}', {} - + return False, f"error: {str(e)}", {} + def get_my_purchases(self, user_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]: """Get the list of indicators purchased by the user.""" offset = (page - 1) * page_size - + try: with get_db_connection() as db: cur = db.cursor() - + # Get total - cur.execute( - "SELECT COUNT(*) as count FROM qd_indicator_purchases WHERE buyer_id = ?", - (user_id,) - ) - total = cur.fetchone()['count'] - + cur.execute("SELECT COUNT(*) as count FROM qd_indicator_purchases WHERE buyer_id = ?", (user_id,)) + total = cur.fetchone()["count"] + # Get list - cur.execute(""" - SELECT + cur.execute( + """ + SELECT p.id as purchase_id, p.price as purchase_price, p.created_at as purchase_time, i.id, i.name, i.description, i.preview_image, i.avg_rating, u.nickname as seller_nickname, u.avatar as seller_avatar @@ -393,63 +441,71 @@ class CommunityService: WHERE p.buyer_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ? - """, (user_id, page_size, offset)) + """, + (user_id, page_size, offset), + ) rows = cur.fetchall() or [] cur.close() - + items = [] for row in rows: - items.append({ - 'purchase_id': row['purchase_id'], - 'purchase_price': float(row['purchase_price'] or 0), - 'purchase_time': row['purchase_time'].isoformat() if row['purchase_time'] else None, - 'indicator': { - 'id': row['id'], - 'name': row['name'], - 'description': row['description'][:100] if row['description'] else '', - 'preview_image': row['preview_image'] or '', - 'avg_rating': float(row['avg_rating'] or 0) - }, - 'seller': { - 'nickname': row['seller_nickname'], - 'avatar': row['seller_avatar'] or '/avatar2.jpg' + items.append( + { + "purchase_id": row["purchase_id"], + "purchase_price": float(row["purchase_price"] or 0), + "purchase_time": row["purchase_time"].isoformat() if row["purchase_time"] else None, + "indicator": { + "id": row["id"], + "name": row["name"], + "description": row["description"][:100] if row["description"] else "", + "preview_image": row["preview_image"] or "", + "avg_rating": float(row["avg_rating"] or 0), + }, + "seller": { + "nickname": row["seller_nickname"], + "avatar": row["seller_avatar"] or "/avatar2.jpg", + }, } - }) - + ) + return { - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'total_pages': (total + page_size - 1) // page_size if total > 0 else 0 + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size if total > 0 else 0, } - + except Exception as e: logger.error(f"get_my_purchases failed: {e}") - return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0} - + return {"items": [], "total": 0, "page": 1, "page_size": page_size, "total_pages": 0} + # ========================================== # Comment function # ========================================== - + def get_comments(self, indicator_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]: """Get the list of indicator comments.""" offset = (page - 1) * page_size - + try: with get_db_connection() as db: cur = db.cursor() - + # Get the total number (only first-level comments are counted) - cur.execute(""" - SELECT COUNT(*) as count FROM qd_indicator_comments + cur.execute( + """ + SELECT COUNT(*) as count FROM qd_indicator_comments WHERE indicator_id = ? AND parent_id IS NULL AND is_deleted = 0 - """, (indicator_id,)) - total = cur.fetchone()['count'] - + """, + (indicator_id,), + ) + total = cur.fetchone()["count"] + # Get the list of comments - cur.execute(""" - SELECT + cur.execute( + """ + SELECT c.id, c.rating, c.content, c.created_at, u.id as user_id, u.nickname, u.avatar FROM qd_indicator_comments c @@ -457,42 +513,42 @@ class CommunityService: WHERE c.indicator_id = ? AND c.parent_id IS NULL AND c.is_deleted = 0 ORDER BY c.created_at DESC LIMIT ? OFFSET ? - """, (indicator_id, page_size, offset)) + """, + (indicator_id, page_size, offset), + ) rows = cur.fetchall() or [] cur.close() - + items = [] for row in rows: - items.append({ - 'id': row['id'], - 'rating': row['rating'], - 'content': row['content'], - 'created_at': row['created_at'].isoformat() if row['created_at'] else None, - 'user': { - 'id': row['user_id'], - 'nickname': row['nickname'], - 'avatar': row['avatar'] or '/avatar2.jpg' + items.append( + { + "id": row["id"], + "rating": row["rating"], + "content": row["content"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "user": { + "id": row["user_id"], + "nickname": row["nickname"], + "avatar": row["avatar"] or "/avatar2.jpg", + }, } - }) - + ) + return { - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'total_pages': (total + page_size - 1) // page_size if total > 0 else 0 + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size if total > 0 else 0, } - + except Exception as e: logger.error(f"get_comments failed: {e}") - return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0} - + return {"items": [], "total": 0, "page": 1, "page_size": page_size, "total_pages": 0} + def add_comment( - self, - user_id: int, - indicator_id: int, - rating: int, - content: str + self, user_id: int, indicator_id: int, rating: int, content: str ) -> Tuple[bool, str, Dict[str, Any]]: """ Add a comment. @@ -502,81 +558,82 @@ class CommunityService: try: # Verify score range rating = max(1, min(5, int(rating))) - content = (content or '').strip()[:500] # Limit 500 words - + content = (content or "").strip()[:500] # Limit 500 words + with get_db_connection() as db: cur = db.cursor() - + # Check if the indicator exists cur.execute( "SELECT id, user_id FROM qd_indicator_codes WHERE id = ? AND publish_to_community = 1", - (indicator_id,) + (indicator_id,), ) indicator = cur.fetchone() if not indicator: cur.close() - return False, 'indicator_not_found', {} - + return False, "indicator_not_found", {} + # Cannot comment on own indicators - if indicator['user_id'] == user_id: + if indicator["user_id"] == user_id: cur.close() - return False, 'cannot_comment_own', {} - + return False, "cannot_comment_own", {} + # Check if it has been purchased (free indicators also need to be "acquired" to comment) cur.execute( "SELECT id FROM qd_indicator_purchases WHERE indicator_id = ? AND buyer_id = ?", - (indicator_id, user_id) + (indicator_id, user_id), ) if not cur.fetchone(): cur.close() - return False, 'not_purchased', {} - + return False, "not_purchased", {} + # Check if comments have been made cur.execute( "SELECT id FROM qd_indicator_comments WHERE indicator_id = ? AND user_id = ? AND parent_id IS NULL", - (indicator_id, user_id) + (indicator_id, user_id), ) if cur.fetchone(): cur.close() - return False, 'already_commented', {} - - #Add comment - cur.execute(""" - INSERT INTO qd_indicator_comments + return False, "already_commented", {} + + # Add comment + cur.execute( + """ + INSERT INTO qd_indicator_comments (indicator_id, user_id, rating, content, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW()) - """, (indicator_id, user_id, rating, content)) + """, + (indicator_id, user_id, rating, content), + ) comment_id = cur.lastrowid - + # Update the scoring statistics of the indicator - cur.execute(""" - UPDATE qd_indicator_codes - SET + cur.execute( + """ + UPDATE qd_indicator_codes + SET rating_count = COALESCE(rating_count, 0) + 1, avg_rating = ( - SELECT AVG(rating) FROM qd_indicator_comments + SELECT AVG(rating) FROM qd_indicator_comments WHERE indicator_id = ? AND parent_id IS NULL AND is_deleted = 0 ) WHERE id = ? - """, (indicator_id, indicator_id)) - + """, + (indicator_id, indicator_id), + ) + db.commit() cur.close() - + logger.info(f"User {user_id} commented on indicator {indicator_id} with rating {rating}") - return True, 'success', {'comment_id': comment_id} - + return True, "success", {"comment_id": comment_id} + except Exception as e: logger.error(f"add_comment failed: {e}") - return False, f'error: {str(e)}', {} - + return False, f"error: {str(e)}", {} + def update_comment( - self, - user_id: int, - comment_id: int, - indicator_id: int, - rating: int, - content: str + self, user_id: int, comment_id: int, indicator_id: int, rating: int, content: str ) -> Tuple[bool, str, Dict[str, Any]]: """ Update a comment. @@ -585,123 +642,135 @@ class CommunityService: """ try: rating = max(1, min(5, int(rating))) - content = (content or '').strip()[:500] - + content = (content or "").strip()[:500] + with get_db_connection() as db: cur = db.cursor() - + # Check if the comment exists and belongs to the current user - cur.execute(""" - SELECT id, rating as old_rating FROM qd_indicator_comments + cur.execute( + """ + SELECT id, rating as old_rating FROM qd_indicator_comments WHERE id = ? AND user_id = ? AND indicator_id = ? AND is_deleted = 0 - """, (comment_id, user_id, indicator_id)) + """, + (comment_id, user_id, indicator_id), + ) comment = cur.fetchone() - + if not comment: cur.close() - return False, 'comment_not_found', {} - - old_rating = comment['old_rating'] - + return False, "comment_not_found", {} + + old_rating = comment["old_rating"] + # Update comment - cur.execute(""" - UPDATE qd_indicator_comments + cur.execute( + """ + UPDATE qd_indicator_comments SET rating = ?, content = ?, updated_at = NOW() WHERE id = ? - """, (rating, content, comment_id)) - + """, + (rating, content, comment_id), + ) + # If the rating changes, update the average rating of the metric if old_rating != rating: - cur.execute(""" - UPDATE qd_indicator_codes + cur.execute( + """ + UPDATE qd_indicator_codes SET avg_rating = ( - SELECT AVG(rating) FROM qd_indicator_comments + SELECT AVG(rating) FROM qd_indicator_comments WHERE indicator_id = ? AND parent_id IS NULL AND is_deleted = 0 ) WHERE id = ? - """, (indicator_id, indicator_id)) - + """, + (indicator_id, indicator_id), + ) + db.commit() cur.close() - + logger.info(f"User {user_id} updated comment {comment_id}") - return True, 'success', {'comment_id': comment_id} - + return True, "success", {"comment_id": comment_id} + except Exception as e: logger.error(f"update_comment failed: {e}") - return False, f'error: {str(e)}', {} - + return False, f"error: {str(e)}", {} + def get_user_comment(self, user_id: int, indicator_id: int) -> Optional[Dict[str, Any]]: """Get the user's comment for a specific indicator.""" try: with get_db_connection() as db: cur = db.cursor() - cur.execute(""" + cur.execute( + """ SELECT id, rating, content, created_at, updated_at FROM qd_indicator_comments WHERE user_id = ? AND indicator_id = ? AND parent_id IS NULL AND is_deleted = 0 - """, (user_id, indicator_id)) + """, + (user_id, indicator_id), + ) row = cur.fetchone() cur.close() - + if not row: return None - + return { - 'id': row['id'], - 'rating': row['rating'], - 'content': row['content'], - 'created_at': row['created_at'].isoformat() if row['created_at'] else None, - 'updated_at': row['updated_at'].isoformat() if row['updated_at'] else None + "id": row["id"], + "rating": row["rating"], + "content": row["content"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, } - + except Exception as e: logger.error(f"get_user_comment failed: {e}") return None - + # ========================================== # Administrator review function # ========================================== - + def get_pending_indicators( self, page: int = 1, page_size: int = 20, - review_status: str = 'pending' # 'pending' / 'approved' / 'rejected' / 'all' + review_status: str = "pending", # 'pending' / 'approved' / 'rejected' / 'all' ) -> Dict[str, Any]: """Get the list of indicators pending review for admins.""" offset = (page - 1) * page_size - + try: with get_db_connection() as db: cur = db.cursor() - + # Build query conditions where_clauses = ["i.publish_to_community = 1"] params = [] - - if review_status and review_status != 'all': + + if review_status and review_status != "all": where_clauses.append("i.review_status = ?") params.append(review_status) - + where_sql = " AND ".join(where_clauses) - + # Get total count_sql = f""" - SELECT COUNT(*) as count - FROM qd_indicator_codes i + SELECT COUNT(*) as count + FROM qd_indicator_codes i WHERE {where_sql} """ cur.execute(count_sql, tuple(params)) - total = cur.fetchone()['count'] - + total = cur.fetchone()["count"] + # Get the list query_sql = f""" - SELECT + SELECT i.id, i.name, i.description, i.pricing_type, i.price, - i.preview_image, i.code, i.review_status, i.review_note, + i.preview_image, i.code, i.review_status, i.review_note, i.reviewed_at, i.reviewed_by, i.created_at, - u.id as author_id, u.username as author_username, + u.id as author_id, u.username as author_username, u.nickname as author_nickname, u.avatar as author_avatar, r.username as reviewer_username FROM qd_indicator_codes i @@ -714,161 +783,175 @@ class CommunityService: cur.execute(query_sql, tuple(params + [page_size, offset])) rows = cur.fetchall() or [] cur.close() - + items = [] for row in rows: - items.append({ - 'id': row['id'], - 'name': row['name'], - 'description': row['description'][:300] if row['description'] else '', - 'pricing_type': row['pricing_type'] or 'free', - 'price': float(row['price'] or 0), - 'preview_image': row['preview_image'] or '', - 'code': row['code'] or '', # Administrators can view the code - 'review_status': row['review_status'] or 'pending', - 'review_note': row['review_note'] or '', - 'reviewed_at': row['reviewed_at'].isoformat() if row['reviewed_at'] else None, - 'reviewer_username': row['reviewer_username'], - 'created_at': row['created_at'].isoformat() if row['created_at'] else None, - 'author': { - 'id': row['author_id'], - 'username': row['author_username'], - 'nickname': row['author_nickname'] or row['author_username'], - 'avatar': row['author_avatar'] or '/avatar2.jpg' + items.append( + { + "id": row["id"], + "name": row["name"], + "description": row["description"][:300] if row["description"] else "", + "pricing_type": row["pricing_type"] or "free", + "price": float(row["price"] or 0), + "preview_image": row["preview_image"] or "", + "code": row["code"] or "", # Administrators can view the code + "review_status": row["review_status"] or "pending", + "review_note": row["review_note"] or "", + "reviewed_at": row["reviewed_at"].isoformat() if row["reviewed_at"] else None, + "reviewer_username": row["reviewer_username"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "author": { + "id": row["author_id"], + "username": row["author_username"], + "nickname": row["author_nickname"] or row["author_username"], + "avatar": row["author_avatar"] or "/avatar2.jpg", + }, } - }) - + ) + return { - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'total_pages': (total + page_size - 1) // page_size if total > 0 else 0 + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size if total > 0 else 0, } - + except Exception as e: logger.error(f"get_pending_indicators failed: {e}") - return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0} - + return {"items": [], "total": 0, "page": 1, "page_size": page_size, "total_pages": 0} + def review_indicator( self, admin_id: int, indicator_id: int, action: str, # 'approve' / 'reject' - note: str = '' + note: str = "", ) -> Tuple[bool, str]: """Review an indicator.""" try: - new_status = 'approved' if action == 'approve' else 'rejected' - note = (note or '').strip()[:500] - + new_status = "approved" if action == "approve" else "rejected" + note = (note or "").strip()[:500] + with get_db_connection() as db: cur = db.cursor() - + # Check if the indicator exists and has been published to the community - cur.execute(""" - SELECT id, name, user_id FROM qd_indicator_codes + cur.execute( + """ + SELECT id, name, user_id FROM qd_indicator_codes WHERE id = ? AND publish_to_community = 1 - """, (indicator_id,)) + """, + (indicator_id,), + ) indicator = cur.fetchone() - + if not indicator: cur.close() - return False, 'indicator_not_found' - + return False, "indicator_not_found" + # Update review status - cur.execute(""" - UPDATE qd_indicator_codes + cur.execute( + """ + UPDATE qd_indicator_codes SET review_status = ?, review_note = ?, reviewed_at = NOW(), reviewed_by = ? WHERE id = ? - """, (new_status, note, admin_id, indicator_id)) - + """, + (new_status, note, admin_id, indicator_id), + ) + db.commit() cur.close() - + logger.info(f"Admin {admin_id} {action}d indicator {indicator_id}") - return True, 'success' - + return True, "success" + except Exception as e: logger.error(f"review_indicator failed: {e}") - return False, f'error: {str(e)}' - - def unpublish_indicator(self, admin_id: int, indicator_id: int, note: str = '') -> Tuple[bool, str]: + return False, f"error: {str(e)}" + + def unpublish_indicator(self, admin_id: int, indicator_id: int, note: str = "") -> Tuple[bool, str]: """Unpublish an indicator.""" try: - note = (note or '').strip()[:500] - + note = (note or "").strip()[:500] + with get_db_connection() as db: cur = db.cursor() - + # Check if the indicator exists - cur.execute(""" + cur.execute( + """ SELECT id, name FROM qd_indicator_codes WHERE id = ? - """, (indicator_id,)) + """, + (indicator_id,), + ) indicator = cur.fetchone() - + if not indicator: cur.close() - return False, 'indicator_not_found' - + return False, "indicator_not_found" + # Remove from shelves (cancel publication) - cur.execute(""" - UPDATE qd_indicator_codes - SET publish_to_community = 0, review_status = 'rejected', + cur.execute( + """ + UPDATE qd_indicator_codes + SET publish_to_community = 0, review_status = 'rejected', review_note = ?, reviewed_at = NOW(), reviewed_by = ? WHERE id = ? - """, (f"Removal: {note}" if note else "Removal by administrator", admin_id, indicator_id)) - + """, + (f"Removal: {note}" if note else "Removal by administrator", admin_id, indicator_id), + ) + db.commit() cur.close() - + logger.info(f"Admin {admin_id} unpublished indicator {indicator_id}") - return True, 'success' - + return True, "success" + except Exception as e: logger.error(f"unpublish_indicator failed: {e}") - return False, f'error: {str(e)}' - + return False, f"error: {str(e)}" + def admin_delete_indicator(self, admin_id: int, indicator_id: int) -> Tuple[bool, str]: """Delete an indicator as an admin.""" try: with get_db_connection() as db: cur = db.cursor() - + # Check if the indicator exists cur.execute("SELECT id, name FROM qd_indicator_codes WHERE id = ?", (indicator_id,)) indicator = cur.fetchone() - + if not indicator: cur.close() - return False, 'indicator_not_found' - + return False, "indicator_not_found" + # Delete associated comments cur.execute("DELETE FROM qd_indicator_comments WHERE indicator_id = ?", (indicator_id,)) - + # Delete associated purchase history cur.execute("DELETE FROM qd_indicator_purchases WHERE indicator_id = ?", (indicator_id,)) - + # Delete indicator cur.execute("DELETE FROM qd_indicator_codes WHERE id = ?", (indicator_id,)) - + db.commit() cur.close() - + logger.info(f"Admin {admin_id} deleted indicator {indicator_id}") - return True, 'success' - + return True, "success" + except Exception as e: logger.error(f"admin_delete_indicator failed: {e}") - return False, f'error: {str(e)}' - + return False, f"error: {str(e)}" + def get_review_stats(self) -> Dict[str, int]: """Get audit statistics""" try: with get_db_connection() as db: cur = db.cursor() cur.execute(""" - SELECT + SELECT COUNT(*) FILTER (WHERE review_status = 'pending' OR review_status IS NULL) as pending_count, COUNT(*) FILTER (WHERE review_status = 'approved') as approved_count, COUNT(*) FILTER (WHERE review_status = 'rejected') as rejected_count @@ -877,16 +960,16 @@ class CommunityService: """) row = cur.fetchone() cur.close() - + return { - 'pending': row['pending_count'] or 0, - 'approved': row['approved_count'] or 0, - 'rejected': row['rejected_count'] or 0 + "pending": row["pending_count"] or 0, + "approved": row["approved_count"] or 0, + "rejected": row["rejected_count"] or 0, } except Exception as e: logger.error(f"get_review_stats failed: {e}") - return {'pending': 0, 'approved': 0, 'rejected': 0} - + return {"pending": 0, "approved": 0, "rejected": 0} + # ========================================== # Real performance (aggregated backtest + real transaction data) # ========================================== @@ -900,12 +983,12 @@ class CommunityService: 2. qd_strategy_trades + qd_strategies_trading - Real live trading records """ default_result = { - 'strategy_count': 0, - 'trade_count': 0, - 'win_rate': 0, - 'total_profit': 0, - 'avg_return': 0, - 'max_drawdown': 0 + "strategy_count": 0, + "trade_count": 0, + "win_rate": 0, + "total_profit": 0, + "avg_return": 0, + "max_drawdown": 0, } try: @@ -919,21 +1002,24 @@ class CommunityService: bt_trade_counts = [] try: - cur.execute(""" + cur.execute( + """ SELECT result_json FROM qd_backtest_runs WHERE indicator_id = %s AND status = 'success' AND result_json IS NOT NULL AND result_json != '' - """, (indicator_id,)) + """, + (indicator_id,), + ) rows = cur.fetchall() for row in rows: try: - rj = json.loads(row['result_json']) if isinstance(row['result_json'], str) else {} - tr = float(rj.get('totalReturn', 0) or 0) - wr = float(rj.get('winRate', 0) or 0) - md = float(rj.get('maxDrawdown', 0) or 0) - tc = int(rj.get('totalTrades', 0) or 0) + rj = json.loads(row["result_json"]) if isinstance(row["result_json"], str) else {} + tr = float(rj.get("totalReturn", 0) or 0) + wr = float(rj.get("winRate", 0) or 0) + md = float(rj.get("maxDrawdown", 0) or 0) + tc = int(rj.get("totalTrades", 0) or 0) bt_returns.append(tr) bt_win_rates.append(wr) bt_drawdowns.append(md) @@ -953,41 +1039,52 @@ class CommunityService: try: # Find the strategy that uses this indicator (matches indicator_id in indicator_config JSON) - cur.execute(""" + cur.execute( + """ SELECT id FROM qd_strategies_trading WHERE indicator_config::text LIKE %s - """, (f'%"indicator_id": {indicator_id}%',)) + """, + (f'%"indicator_id": {indicator_id}%',), + ) strategy_rows = cur.fetchall() # Also try to match formats without spaces if not strategy_rows: - cur.execute(""" + cur.execute( + """ SELECT id FROM qd_strategies_trading WHERE indicator_config::text LIKE %s - """, (f'%"indicator_id":{indicator_id}%',)) + """, + (f'%"indicator_id":{indicator_id}%',), + ) strategy_rows = cur.fetchall() if strategy_rows: - strategy_ids = [r['id'] for r in strategy_rows] + strategy_ids = [r["id"] for r in strategy_rows] live_strategy_count = len(strategy_ids) - placeholders = ','.join(['%s'] * len(strategy_ids)) - cur.execute(f""" - SELECT + placeholders = ",".join(["%s"] * len(strategy_ids)) + cur.execute( + f""" + SELECT COUNT(*) as trade_count, SUM(CASE WHEN profit > 0 THEN 1 ELSE 0 END) as win_count, SUM(profit) as total_profit FROM qd_strategy_trades WHERE strategy_id IN ({placeholders}) AND profit != 0 - """, tuple(strategy_ids)) + """, + tuple(strategy_ids), + ) trade_row = cur.fetchone() - if trade_row and (trade_row['trade_count'] or 0) > 0: - live_trade_count = int(trade_row['trade_count'] or 0) - win_count = int(trade_row['win_count'] or 0) - live_win_rate = round(win_count / live_trade_count * 100, 2) if live_trade_count > 0 else 0.0 - live_total_profit = round(float(trade_row['total_profit'] or 0), 2) + if trade_row and (trade_row["trade_count"] or 0) > 0: + live_trade_count = int(trade_row["trade_count"] or 0) + win_count = int(trade_row["win_count"] or 0) + live_win_rate = ( + round(win_count / live_trade_count * 100, 2) if live_trade_count > 0 else 0.0 + ) + live_total_profit = round(float(trade_row["total_profit"] or 0), 2) except Exception: logger.debug("Live trading query skipped or failed", exc_info=True) @@ -1008,7 +1105,7 @@ class CommunityService: # Average return (backtest totalReturn %) avg_return = round(sum(bt_returns) / len(bt_returns), 2) if bt_returns else 0.0 - #Total profit: priority is given to the absolute profit of the real offer. If there is no real offer, the average return rate of the backtest will be displayed. + # Total profit: priority is given to the absolute profit of the real offer. If there is no real offer, the average return rate of the backtest will be displayed. combined_profit = live_total_profit if live_trade_count > 0 else avg_return # The maximum drawdown is the worst in the backtest (maxDrawdown is a negative number, the smallest is the worst) @@ -1018,12 +1115,12 @@ class CommunityService: return default_result return { - 'strategy_count': total_strategy_count, - 'trade_count': total_trade_count, - 'win_rate': combined_win_rate, - 'total_profit': round(combined_profit, 2), - 'avg_return': avg_return, - 'max_drawdown': avg_drawdown + "strategy_count": total_strategy_count, + "trade_count": total_trade_count, + "win_rate": combined_win_rate, + "total_profit": round(combined_profit, 2), + "avg_return": avg_return, + "max_drawdown": avg_drawdown, } except Exception as e: diff --git a/backend_api_python/app/services/email_service.py b/backend_api_python/app/services/email_service.py index d8dd890..0fe856f 100644 --- a/backend_api_python/app/services/email_service.py +++ b/backend_api_python/app/services/email_service.py @@ -1,14 +1,16 @@ """ Email Service - Handles email verification codes and notifications. """ + import os import random -import string import smtplib -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart +import string from datetime import datetime, timedelta -from typing import Tuple, Optional +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Tuple + from app.utils.db import get_db_connection from app.utils.logger import get_logger @@ -28,110 +30,109 @@ def get_email_service(): class EmailService: """Email service for verification codes and notifications""" - + def __init__(self): self._load_config() - + def _load_config(self): """Load email configuration from environment variables""" - self.smtp_host = os.getenv('SMTP_HOST', '') - self.smtp_port = int(os.getenv('SMTP_PORT', '587')) - self.smtp_user = os.getenv('SMTP_USER', '') - self.smtp_password = os.getenv('SMTP_PASSWORD', '') - self.smtp_from = os.getenv('SMTP_FROM', '') or self.smtp_user - self.smtp_use_tls = os.getenv('SMTP_USE_TLS', 'true').lower() == 'true' - self.smtp_use_ssl = os.getenv('SMTP_USE_SSL', 'false').lower() == 'true' - + self.smtp_host = os.getenv("SMTP_HOST", "") + self.smtp_port = int(os.getenv("SMTP_PORT", "587")) + self.smtp_user = os.getenv("SMTP_USER", "") + self.smtp_password = os.getenv("SMTP_PASSWORD", "") + self.smtp_from = os.getenv("SMTP_FROM", "") or self.smtp_user + self.smtp_use_tls = os.getenv("SMTP_USE_TLS", "true").lower() == "true" + self.smtp_use_ssl = os.getenv("SMTP_USE_SSL", "false").lower() == "true" + # Verification code settings - self.code_expire_minutes = int(os.getenv('VERIFICATION_CODE_EXPIRE_MINUTES', '10')) + self.code_expire_minutes = int(os.getenv("VERIFICATION_CODE_EXPIRE_MINUTES", "10")) self.code_length = 6 - + # Verification code attempt limits (anti-brute-force) - self.code_max_attempts = int(os.getenv('VERIFICATION_CODE_MAX_ATTEMPTS', '5')) - self.code_lock_minutes = int(os.getenv('VERIFICATION_CODE_LOCK_MINUTES', '30')) - + self.code_max_attempts = int(os.getenv("VERIFICATION_CODE_MAX_ATTEMPTS", "5")) + self.code_lock_minutes = int(os.getenv("VERIFICATION_CODE_LOCK_MINUTES", "30")) + # Check if email is properly configured self.email_enabled = bool(self.smtp_host and self.smtp_user and self.smtp_password) - + if not self.email_enabled: logger.warning("Email service is not configured. SMTP settings are missing.") - + def is_configured(self) -> bool: """Check if email service is properly configured""" return self.email_enabled - + # ========================================================================= # Verification Code Generation & Storage # ========================================================================= - + def generate_code(self) -> str: """Generate a random numeric verification code""" - return ''.join(random.choices(string.digits, k=self.code_length)) - - def create_verification_code(self, email: str, code_type: str, - ip_address: str = None) -> Tuple[bool, str]: + return "".join(random.choices(string.digits, k=self.code_length)) + + def create_verification_code(self, email: str, code_type: str, ip_address: str = None) -> Tuple[bool, str]: """ Create and store a new verification code. - + Args: email: Email address code_type: Type of verification (register, reset_password, change_password, change_email) ip_address: Requester's IP address - + Returns: (success, code_or_message) """ try: code = self.generate_code() expires_at = datetime.now() + timedelta(minutes=self.code_expire_minutes) - + with get_db_connection() as db: cur = db.cursor() - + # Invalidate any existing unused codes of the same type for this email cur.execute( """ - UPDATE qd_verification_codes - SET used_at = NOW() + UPDATE qd_verification_codes + SET used_at = NOW() WHERE email = ? AND type = ? AND used_at IS NULL """, - (email, code_type) + (email, code_type), ) - + # Insert new code cur.execute( """ - INSERT INTO qd_verification_codes + INSERT INTO qd_verification_codes (email, code, type, expires_at, ip_address) VALUES (?, ?, ?, ?, ?) """, - (email, code, code_type, expires_at, ip_address) + (email, code, code_type, expires_at, ip_address), ) db.commit() cur.close() - + return True, code - + except Exception as e: logger.error(f"Failed to create verification code: {e}") - return False, 'Failed to generate verification code' - + return False, "Failed to generate verification code" + def verify_code(self, email: str, code: str, code_type: str) -> Tuple[bool, str]: """ Verify a submitted code with brute-force protection. - + Args: email: Email address code: The code to verify code_type: Type of verification - + Returns: (valid, message) """ try: with get_db_connection() as db: cur = db.cursor() - + # Check if locked due to too many failed attempts lock_window = datetime.now() - timedelta(minutes=self.code_lock_minutes) cur.execute( @@ -141,13 +142,13 @@ class EmailService: AND attempts >= ? AND last_attempt_at > ? AND used_at IS NULL """, - (email, code_type, self.code_max_attempts, lock_window.isoformat()) + (email, code_type, self.code_max_attempts, lock_window.isoformat()), ) lock_row = cur.fetchone() - if lock_row and lock_row['cnt'] > 0: + if lock_row and lock_row["cnt"] > 0: cur.close() - return False, f'Too many failed attempts. Please try again in {self.code_lock_minutes} minutes' - + return False, f"Too many failed attempts. Please try again in {self.code_lock_minutes} minutes" + # Find latest unused code for this email/type cur.execute( """ @@ -156,99 +157,97 @@ class EmailService: ORDER BY created_at DESC LIMIT 1 """, - (email, code_type) + (email, code_type), ) row = cur.fetchone() - + if not row: cur.close() - return False, 'Invalid verification code' - - code_id = row['id'] - stored_code = row['stored_code'] - attempts = row['attempts'] or 0 - + return False, "Invalid verification code" + + code_id = row["id"] + stored_code = row["stored_code"] + attempts = row["attempts"] or 0 + # Check if code matches if stored_code != code: # Increment attempt counter new_attempts = attempts + 1 cur.execute( """ - UPDATE qd_verification_codes + UPDATE qd_verification_codes SET attempts = ?, last_attempt_at = NOW() WHERE id = ? """, - (new_attempts, code_id) + (new_attempts, code_id), ) db.commit() cur.close() - + remaining = self.code_max_attempts - new_attempts if remaining <= 0: - return False, f'Too many failed attempts. Please try again in {self.code_lock_minutes} minutes' - return False, f'Invalid verification code. {remaining} attempts remaining' - + return False, f"Too many failed attempts. Please try again in {self.code_lock_minutes} minutes" + return False, f"Invalid verification code. {remaining} attempts remaining" + # Check expiration - expires_at = row['expires_at'] + expires_at = row["expires_at"] if isinstance(expires_at, str): expires_at = datetime.fromisoformat(expires_at) - + if datetime.now() > expires_at: cur.close() - return False, 'Verification code has expired' - + return False, "Verification code has expired" + # Mark as used - cur.execute( - "UPDATE qd_verification_codes SET used_at = NOW() WHERE id = ?", - (code_id,) - ) + cur.execute("UPDATE qd_verification_codes SET used_at = NOW() WHERE id = ?", (code_id,)) db.commit() cur.close() - - return True, 'verified' - + + return True, "verified" + except Exception as e: logger.error(f"Failed to verify code: {e}") - return False, 'Verification failed' - + return False, "Verification failed" + # ========================================================================= # Email Sending # ========================================================================= - + def send_email(self, to_email: str, subject: str, html_body: str) -> Tuple[bool, str]: """ Send an email. - + Args: to_email: Recipient email address subject: Email subject html_body: HTML body content - + Returns: (success, message) """ if not self.email_enabled: logger.warning(f"Email not sent (service disabled): {subject} to {to_email}") - return False, 'Email service is not configured' - + return False, "Email service is not configured" + try: - msg = MIMEMultipart('alternative') - msg['Subject'] = subject - msg['From'] = self.smtp_from - msg['To'] = to_email - + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = self.smtp_from + msg["To"] = to_email + # Plain text version (fallback) - text_body = html_body.replace('
', '\n').replace('
', '\n') + text_body = html_body.replace("
", "\n").replace("
", "\n") # Simple HTML tag removal for plain text import re - text_body = re.sub('<[^<]+?>', '', text_body) - - part1 = MIMEText(text_body, 'plain', 'utf-8') - part2 = MIMEText(html_body, 'html', 'utf-8') - + + text_body = re.sub("<[^<]+?>", "", text_body) + + part1 = MIMEText(text_body, "plain", "utf-8") + part2 = MIMEText(html_body, "html", "utf-8") + msg.attach(part1) msg.attach(part2) - + # Connect and send if self.smtp_use_ssl: server = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port) @@ -256,34 +255,33 @@ class EmailService: server = smtplib.SMTP(self.smtp_host, self.smtp_port) if self.smtp_use_tls: server.starttls() - + server.login(self.smtp_user, self.smtp_password) server.sendmail(self.smtp_from, to_email, msg.as_string()) server.quit() - + logger.info(f"Email sent successfully: {subject} to {to_email}") - return True, 'sent' - + return True, "sent" + except smtplib.SMTPAuthenticationError as e: logger.error(f"SMTP authentication failed: {e}") - return False, 'Email authentication failed' + return False, "Email authentication failed" except smtplib.SMTPException as e: logger.error(f"SMTP error: {e}") - return False, 'Failed to send email' + return False, "Failed to send email" except Exception as e: logger.error(f"Email error: {e}") - return False, 'Failed to send email' - - def send_verification_code(self, email: str, code_type: str, - ip_address: str = None) -> Tuple[bool, str]: + return False, "Failed to send email" + + def send_verification_code(self, email: str, code_type: str, ip_address: str = None) -> Tuple[bool, str]: """ Generate and send a verification code email. - + Args: email: Recipient email address code_type: Type of verification (register, reset_password, change_password) ip_address: Requester's IP address - + Returns: (success, message) """ @@ -291,36 +289,36 @@ class EmailService: success, code_or_msg = self.create_verification_code(email, code_type, ip_address) if not success: return False, code_or_msg - + code = code_or_msg - + # Prepare email content based on type - if code_type == 'register': - subject = 'QuantDinger - Verification Code for Registration' - action_text = 'complete your registration' - elif code_type == 'login': - subject = 'QuantDinger - Quick Login Verification Code' - action_text = 'log in to your account' - elif code_type == 'reset_password': - subject = 'QuantDinger - Password Reset Verification Code' - action_text = 'reset your password' - elif code_type == 'change_password': - subject = 'QuantDinger - Verification Code for Password Change' - action_text = 'change your password' - elif code_type == 'change_email': - subject = 'QuantDinger - Verification Code for Email Change' - action_text = 'change your email address' + if code_type == "register": + subject = "DinQuant - Verification Code for Registration" + action_text = "complete your registration" + elif code_type == "login": + subject = "DinQuant - Quick Login Verification Code" + action_text = "log in to your account" + elif code_type == "reset_password": + subject = "DinQuant - Password Reset Verification Code" + action_text = "reset your password" + elif code_type == "change_password": + subject = "DinQuant - Verification Code for Password Change" + action_text = "change your password" + elif code_type == "change_email": + subject = "DinQuant - Verification Code for Email Change" + action_text = "change your email address" else: - subject = 'QuantDinger - Verification Code' - action_text = 'complete the verification' - + subject = "DinQuant - Verification Code" + action_text = "complete the verification" + html_body = f"""

-

QuantDinger

+

DinQuant

AI-Driven Quantitative Insights

- +

Your verification code to {action_text} is: @@ -332,30 +330,31 @@ class EmailService: This code will expire in {self.code_expire_minutes} minutes.

- +

- Security Notice: If you did not request this code, + Security Notice: If you did not request this code, please ignore this email. Do not share this code with anyone.

- +
-

© QuantDinger. All rights reserved.

+

© DinQuant. All rights reserved.

""" - + # Send email return self.send_email(email, subject, html_body) - + # ========================================================================= # Email Validation # ========================================================================= - + @staticmethod def is_valid_email(email: str) -> bool: """Basic email format validation""" import re - pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + + pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" return bool(re.match(pattern, email)) diff --git a/backend_api_python/app/services/exchange_execution.py b/backend_api_python/app/services/exchange_execution.py index 8bcb05b..6cd8333 100644 --- a/backend_api_python/app/services/exchange_execution.py +++ b/backend_api_python/app/services/exchange_execution.py @@ -13,9 +13,9 @@ from __future__ import annotations import json from typing import Any, Dict +from app.utils.credential_crypto import decrypt_credential_blob from app.utils.db import get_db_connection from app.utils.logger import get_logger -from app.utils.credential_crypto import decrypt_credential_blob logger = get_logger(__name__) @@ -145,5 +145,3 @@ def resolve_exchange_config(exchange_config: Dict[str, Any], user_id: int = 1) - merged[k] = v return merged - - diff --git a/backend_api_python/app/services/fast_analysis.py b/backend_api_python/app/services/fast_analysis.py index 31091d3..17aa3f6 100644 --- a/backend_api_python/app/services/fast_analysis.py +++ b/backend_api_python/app/services/fast_analysis.py @@ -8,16 +8,16 @@ Core improvements: 3. Multi-dimensional news - using structured API, no need for in-depth reading 4. Single LLM call - strong constraint prompt, output structured analysis """ + import json import os import re import time -from typing import Dict, Any, Optional, List, Tuple -from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Dict, List, Optional, Tuple -from app.utils.logger import get_logger from app.services.llm import LLMService from app.services.market_data_collector import get_market_data_collector +from app.utils.logger import get_logger logger = get_logger(__name__) @@ -102,11 +102,25 @@ _GEO_CONTEXT_MODERATE: List[re.Pattern] = [ re.compile(r"\b(?:middle\s+east|south\s+china\s+sea|taiwan\s+strait)\s+(?:crisis|tension|conflict)\b", re.I), ] _GEO_ZH_SEVERE = ( - "宣战", "战争爆发", "全面战争", "武装冲突", "军事打击", "军事入侵", "空袭", "导弹袭击", - "开战", "交火", "战火", + "宣战", + "战争爆发", + "全面战争", + "武装冲突", + "军事打击", + "军事入侵", + "空袭", + "导弹袭击", + "开战", + "交火", + "战火", ) _GEO_ZH_MODERATE = ( - "地缘政治危机", "国际制裁升级", "断交", "撤侨", "军事对峙", "地区冲突升级", + "地缘政治危机", + "国际制裁升级", + "断交", + "撤侨", + "军事对峙", + "地区冲突升级", ) # Optional: country/region + conflict verb (single pattern, avoids "NYSE" noise) @@ -186,20 +200,20 @@ def _is_major_geopolitical_news_text(combined_text: str) -> bool: class FastAnalysisService: """ Rapid Analysis Service 3.0 - + Architecture: 1. Data collection layer - MarketDataCollector (unified data source) 2. Analysis layer - single LLM call (strong constraint prompt) 3. Memory layer - analysis history storage and retrieval """ - + def __init__(self): self.llm_service = LLMService() self.data_collector = get_market_data_collector() self._memory_db = None # Lazy init - + # ==================== Data Collection Layer ==================== - + def _collect_market_data( self, market: str, @@ -213,7 +227,7 @@ class FastAnalysisService: ) -> Dict[str, Any]: """ Collect market data using a unified data collector - + Data level: 1. Core data: price, K-line, technical indicators 2. Fundamentals: Company information, financial data @@ -230,7 +244,7 @@ class FastAnalysisService: include_polymarket=include_polymarket, # Contains prediction market data timeout=timeout, # Increase timeout to ensure data collection is complete ) - + def _calculate_indicators(self, kline_data: List[Dict]) -> Dict[str, Any]: """ Calculate technical indicators using rules (no LLM). @@ -238,18 +252,18 @@ class FastAnalysisService: """ if not kline_data or len(kline_data) < 5: return {"error": "Insufficient data"} - + try: # Use tools' built-in calculation raw_indicators = self.tools.calculate_technical_indicators(kline_data) - + # Extract key values closes = [float(k.get("close", 0)) for k in kline_data if k.get("close")] if not closes: return {"error": "No close prices"} - + current_price = closes[-1] - + # RSI interpretation rsi = raw_indicators.get("RSI", 50) if rsi < 30: @@ -261,12 +275,12 @@ class FastAnalysisService: else: rsi_signal = "neutral" rsi_action = "hold" - + # MACD interpretation macd = raw_indicators.get("MACD", 0) macd_signal_line = raw_indicators.get("MACD_Signal", 0) macd_hist = raw_indicators.get("MACD_Hist", 0) - + if macd > macd_signal_line and macd_hist > 0: macd_signal = "bullish" macd_trend = "golden_cross" if macd_hist > 0 and len(kline_data) > 1 else "bullish" @@ -276,12 +290,12 @@ class FastAnalysisService: else: macd_signal = "neutral" macd_trend = "consolidating" - + # Moving averages ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else current_price ma10 = sum(closes[-10:]) / 10 if len(closes) >= 10 else current_price ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else current_price - + if current_price > ma5 > ma10 > ma20: ma_trend = "strong_uptrend" elif current_price > ma20: @@ -292,25 +306,25 @@ class FastAnalysisService: ma_trend = "downtrend" else: ma_trend = "sideways" - + # Support/Resistance (simple: recent highs/lows) recent_highs = [float(k.get("high", 0)) for k in kline_data[-14:] if k.get("high")] recent_lows = [float(k.get("low", 0)) for k in kline_data[-14:] if k.get("low")] - + resistance = max(recent_highs) if recent_highs else current_price * 1.05 support = min(recent_lows) if recent_lows else current_price * 0.95 - + # Volatility (ATR-like) if len(kline_data) >= 14: ranges = [] for k in kline_data[-14:]: h = float(k.get("high", 0)) - l = float(k.get("low", 0)) - if h > 0 and l > 0: - ranges.append(h - l) + low_price = float(k.get("low", 0)) + if h > 0 and low_price > 0: + ranges.append(h - low_price) atr = sum(ranges) / len(ranges) if ranges else 0 volatility_pct = (atr / current_price * 100) if current_price > 0 else 0 - + if volatility_pct > 5: volatility = "high" elif volatility_pct > 2: @@ -320,7 +334,7 @@ class FastAnalysisService: else: volatility = "unknown" volatility_pct = 0 - + return { "current_price": round(current_price, 6), "rsi": { @@ -354,52 +368,53 @@ class FastAnalysisService: except Exception as e: logger.error(f"Indicator calculation failed: {e}") return {"error": str(e)} - + def _format_news_summary(self, news_data: List[Dict], max_items: int = 5) -> str: """Format news into a concise summary for the prompt.""" if not news_data: return "No recent news available." - + summaries = [] for item in news_data[:max_items]: title = item.get("title", item.get("headline", "")) sentiment = item.get("sentiment", "neutral") date = item.get("date", item.get("datetime", ""))[:10] if item.get("date") or item.get("datetime") else "" - + if title: summaries.append(f"- [{sentiment}] {title} ({date})") - + return "\n".join(summaries) if summaries else "No recent news available." - + def _format_polymarket_summary(self, polymarket_events: List[Dict], max_items: int = 3) -> str: """Format prediction market events into a concise summary for the prompt.""" if not polymarket_events: return "No related prediction market events found." - + summaries = [] for event in polymarket_events[:max_items]: - question = event.get('question', '') - prob = event.get('current_probability', 50.0) + question = event.get("question", "") + prob = event.get("current_probability", 50.0) summaries.append(f"- {question[:80]}: Market probability {prob:.1f}%") - + return "\n".join(summaries) if summaries else "No related prediction market events found." - + # ==================== Memory Layer ==================== - + def _get_memory_context(self, market: str, symbol: str, current_indicators: Dict) -> str: """ Retrieve relevant historical analysis for similar market conditions. """ try: from app.services.analysis_memory import get_analysis_memory + memory = get_analysis_memory() - + # Get similar patterns patterns = memory.get_similar_patterns(market, symbol, current_indicators, limit=3) - + if not patterns: return "No similar historical patterns found in memory." - + context_lines = ["Historical patterns with similar conditions:"] for p in patterns: outcome = "" @@ -408,19 +423,17 @@ class FastAnalysisService: if p.get("actual_return_pct"): outcome += f", Return: {p['actual_return_pct']:.2f}%" outcome += ")" - - context_lines.append( - f"- Decision: {p['decision']} at ${p.get('price', 'N/A')}{outcome}" - ) - + + context_lines.append(f"- Decision: {p['decision']} at ${p.get('price', 'N/A')}{outcome}") + return "\n".join(context_lines) - + except Exception as e: logger.warning(f"Memory retrieval failed: {e}") return "Memory retrieval failed." - + # ==================== Prompt Engineering ==================== - + def _build_analysis_prompt(self, data: Dict[str, Any], language: str) -> tuple: """ Build the single, comprehensive analysis prompt. @@ -429,38 +442,38 @@ class FastAnalysisService: price_data = data.get("price") or {} current_price = price_data.get("price", 0) if price_data else 0 change_24h = price_data.get("changePercent", 0) if price_data else 0 - + # Ensure all data fields have safe defaults (may be None from failed fetches) indicators = data.get("indicators") or {} fundamental = data.get("fundamental") or {} company = data.get("company") or {} news_summary = self._format_news_summary(data.get("news") or []) polymarket_events = data.get("polymarket") or [] - + # Language instruction - MUST be enforced strictly lang_map = { - 'zh-CN': '⚠️ 重要:你必须用简体中文回答所有内容,包括summary、key_reasons、risks等所有文本字段。不要使用英文。', - 'zh-TW': '⚠️ 重要:你必須用繁體中文回答所有內容,包括summary、key_reasons、risks等所有文本字段。不要使用英文。', - 'en-US': '⚠️ IMPORTANT: You MUST answer ALL content in English, including summary, key_reasons, risks, and all text fields. Do NOT use Chinese.', - 'ja-JP': '⚠️ 重要:すべての内容を日本語で回答してください。summary、key_reasons、risksなど、すべてのテキストフィールドを日本語で記述してください。', + "zh-CN": "⚠️ 重要:你必须用简体中文回答所有内容,包括summary、key_reasons、risks等所有文本字段。不要使用英文。", + "zh-TW": "⚠️ 重要:你必須用繁體中文回答所有內容,包括summary、key_reasons、risks等所有文本字段。不要使用英文。", + "en-US": "⚠️ IMPORTANT: You MUST answer ALL content in English, including summary, key_reasons, risks, and all text fields. Do NOT use Chinese.", + "ja-JP": "⚠️ 重要:すべての内容を日本語で回答してください。summary、key_reasons、risksなど、すべてのテキストフィールドを日本語で記述してください。", } - lang_instruction = lang_map.get(language, '⚠️ IMPORTANT: Answer ALL content in English.') - + lang_instruction = lang_map.get(language, "⚠️ IMPORTANT: Answer ALL content in English.") + # Get pre-calculated trading levels from technical analysis levels = indicators.get("levels", {}) trading_levels = indicators.get("trading_levels", {}) volatility = indicators.get("volatility", {}) - + support = levels.get("support", current_price * 0.95) resistance = levels.get("resistance", current_price * 1.05) pivot = levels.get("pivot", current_price) - + # Use ATR-based suggestions if available, otherwise use percentage atr = volatility.get("atr", current_price * 0.02) suggested_stop_loss = trading_levels.get("suggested_stop_loss", current_price - 2 * atr) suggested_take_profit = trading_levels.get("suggested_take_profit", current_price + 3 * atr) risk_reward_ratio = trading_levels.get("risk_reward_ratio", 1.5) - + # Price bounds (still enforce max 10% deviation) if current_price > 0: price_lower_bound = round(max(suggested_stop_loss, current_price * 0.90), 6) @@ -469,16 +482,16 @@ class FastAnalysisService: entry_range_high = round(current_price * 1.02, 6) else: price_lower_bound = price_upper_bound = entry_range_low = entry_range_high = 0 - + # Get technical indicator values for decision constraints rsi_value = indicators.get("rsi", {}).get("value", 50) macd_signal = indicators.get("macd", {}).get("signal", "neutral") ma_trend = indicators.get("moving_averages", {}).get("trend", "sideways") - + # Build decision guidance based on technical indicators decision_guidance = self._build_decision_guidance(rsi_value, macd_signal, ma_trend, change_24h) - - system_prompt = f"""You are QuantDinger's Senior Financial Analyst with 20+ years of experience. + + system_prompt = f"""You are QuantDinger's Senior Financial Analyst with 20+ years of experience. You are CONSERVATIVE and OBJECTIVE. Your analysis must be based on DATA, not speculation. {lang_instruction} @@ -509,7 +522,7 @@ You are CONSERVATIVE and OBJECTIVE. Your analysis must be based on DATA, not spe - When RSI > 60, MACD bearish, downtrend: Consider SELL (short position opportunity) - When RSI < 40, MACD bullish, uptrend: Consider BUY (long position opportunity) - Do NOT default to HOLD when clear technical signals exist -7. **Consider Macro Impact**: +7. **Consider Macro Impact**: - Strong USD (DXY ↑) usually negative for crypto/commodities → Consider SELL - High VIX (>30) indicates fear → Consider SELL or HOLD, avoid BUY - Rising interest rates usually negative for growth assets → Consider SELL @@ -519,7 +532,7 @@ You are CONSERVATIVE and OBJECTIVE. Your analysis must be based on DATA, not spe 📐 TECHNICAL LEVELS (Pre-calculated from chart data): - Support: ${support} | Resistance: ${resistance} | Pivot: ${pivot} -- ATR (14-day): ${atr:.4f} ({volatility.get('pct', 0)}% volatility) +- ATR (14-day): ${atr:.4f} ({volatility.get("pct", 0)}% volatility) - Suggested Stop Loss: ${suggested_stop_loss:.4f} (based on 2x ATR below support) - Suggested Take Profit: ${suggested_take_profit:.4f} (based on 3x ATR above resistance) - Risk/Reward Ratio: {risk_reward_ratio} @@ -535,11 +548,11 @@ You are CONSERVATIVE and OBJECTIVE. Your analysis must be based on DATA, not spe 📊 YOUR ANALYSIS MUST INCLUDE (ALL factors are important): 1. **Technical Analysis**: Objectively interpret RSI, MACD, MA, support/resistance. Be honest about conflicting signals. -2. **Macro Environment Analysis**: +2. **Macro Environment Analysis**: - Analyze DXY, VIX, interest rates impact on the asset - Consider geopolitical events and their potential impact - Evaluate how macro trends affect this specific market/symbol -3. **News & Event Analysis**: +3. **News & Event Analysis**: - **CRITICAL**: Pay special attention to GEOPOLITICAL EVENTS (wars, conflicts, military actions, sanctions) - These events can cause sudden and severe market movements, especially for crypto and global markets - Identify BREAKING NEWS or major events that could cause sudden moves @@ -554,7 +567,7 @@ You are CONSERVATIVE and OBJECTIVE. Your analysis must be based on DATA, not spe - If prediction markets show high probability for bearish events, consider this as a risk factor - Use prediction market probabilities as a sentiment indicator alongside technical analysis 5. **Fundamental Analysis**: Evaluate valuation, growth, competitive position if data available. If data is insufficient, say so. -6. **Risk Assessment**: +6. **Risk Assessment**: - Explain why the stop loss level is appropriate - List ALL significant risks (technical, macro, news, fundamental) - Consider tail risks from unexpected events @@ -591,7 +604,7 @@ Output ONLY valid JSON (do NOT include word counts or format hints in your actua "sentiment_score": 0-100 }} -⚠️ IMPORTANT: +⚠️ IMPORTANT: - The analysis fields should contain your ACTUAL analysis text, NOT the format description above. - Be HONEST and CONSERVATIVE. If you're not confident, choose HOLD with lower confidence. - Do NOT make up facts or exaggerate. Base everything on the provided data. @@ -599,7 +612,7 @@ Output ONLY valid JSON (do NOT include word counts or format hints in your actua 📊 OBJECTIVE SCORING SYSTEM (Reference): The system will calculate an objective score based on technical indicators, fundamentals, sentiment (including geopolitical events), and macro factors. - Score >= +20: Bullish signal → BUY recommended -- Score <= -20: Bearish signal → SELL recommended +- Score <= -20: Bearish signal → SELL recommended - Score between -20 and +20: Neutral → HOLD recommended (narrow range) - Score >= +70: Strong bullish → Strong BUY signal - Score <= -70: Strong bearish → Strong SELL signal @@ -614,12 +627,12 @@ When the score is neutral (-20 to +20), you can use your judgment, but still con ma_data = indicators.get("moving_averages") or {} vol_data = indicators.get("volatility") or {} levels = indicators.get("levels") or {} - + # Format macro data macro = data.get("macro") or {} macro_summary = self._format_macro_summary(macro, data.get("market", "")) - - user_prompt = f"""Analyze {data['symbol']} in {data['market']} market. + + user_prompt = f"""Analyze {data["symbol"]} in {data["market"]} market. 📊 REAL-TIME DATA: - Current Price: ${current_price} @@ -628,46 +641,46 @@ When the score is neutral (-20 to +20), you can use your judgment, but still con - Resistance: ${resistance} 📈 TECHNICAL INDICATORS: -- RSI(14): {rsi_data.get('value', 'N/A')} ({rsi_data.get('signal', 'N/A')}) -- MACD: {macd_data.get('signal', 'N/A')} ({macd_data.get('trend', 'N/A')}) -- MA Trend: {ma_data.get('trend', 'N/A')} -- Volatility: {vol_data.get('level', 'N/A')} ({vol_data.get('pct', 0)}%) -- Trend: {indicators.get('trend', 'N/A')} -- Price Position (20d): {indicators.get('price_position', 'N/A')}% +- RSI(14): {rsi_data.get("value", "N/A")} ({rsi_data.get("signal", "N/A")}) +- MACD: {macd_data.get("signal", "N/A")} ({macd_data.get("trend", "N/A")}) +- MA Trend: {ma_data.get("trend", "N/A")} +- Volatility: {vol_data.get("level", "N/A")} ({vol_data.get("pct", 0)}%) +- Trend: {indicators.get("trend", "N/A")} +- Price Position (20d): {indicators.get("price_position", "N/A")}% 🌐 MACRO ENVIRONMENT: {macro_summary} -📰 MARKET NEWS ({len(data.get('news') or [])} items): +📰 MARKET NEWS ({len(data.get("news") or [])} items): {news_summary} 🎯 PREDICTION MARKETS ({len(polymarket_events)} related events): {self._format_polymarket_summary(polymarket_events)} 💼 FUNDAMENTALS: -- Company: {company.get('name', data['symbol'])} -- Industry: {company.get('industry', 'N/A')} -- P/E Ratio: {fundamental.get('pe_ratio', 'N/A')} -- P/B Ratio: {fundamental.get('pb_ratio', 'N/A')} -- Market Cap: {fundamental.get('market_cap', 'N/A')} -- 52W High/Low: {fundamental.get('52w_high', 'N/A')} / {fundamental.get('52w_low', 'N/A')} -- ROE: {fundamental.get('roe', 'N/A')} -- Revenue Growth: {fundamental.get('revenue_growth', 'N/A')} -- Profit Margin: {fundamental.get('profit_margin', 'N/A')} -- Debt to Equity: {fundamental.get('debt_to_equity', 'N/A')} -- Current Ratio: {fundamental.get('current_ratio', 'N/A')} -- Free Cash Flow: {fundamental.get('free_cash_flow', 'N/A')} +- Company: {company.get("name", data["symbol"])} +- Industry: {company.get("industry", "N/A")} +- P/E Ratio: {fundamental.get("pe_ratio", "N/A")} +- P/B Ratio: {fundamental.get("pb_ratio", "N/A")} +- Market Cap: {fundamental.get("market_cap", "N/A")} +- 52W High/Low: {fundamental.get("52w_high", "N/A")} / {fundamental.get("52w_low", "N/A")} +- ROE: {fundamental.get("roe", "N/A")} +- Revenue Growth: {fundamental.get("revenue_growth", "N/A")} +- Profit Margin: {fundamental.get("profit_margin", "N/A")} +- Debt to Equity: {fundamental.get("debt_to_equity", "N/A")} +- Current Ratio: {fundamental.get("current_ratio", "N/A")} +- Free Cash Flow: {fundamental.get("free_cash_flow", "N/A")} 📊 FINANCIAL STATEMENTS (Latest Quarter): -{self._format_financial_statements(fundamental.get('financial_statements', {}))} +{self._format_financial_statements(fundamental.get("financial_statements", {}))} 📈 EARNINGS DATA: -{self._format_earnings_data(fundamental.get('earnings', {}))} +{self._format_earnings_data(fundamental.get("earnings", {}))} 📚 HISTORICAL PATTERNS (similar conditions in the past): -{self._get_memory_context(data.get('market', ''), data.get('symbol', ''), indicators)} +{self._get_memory_context(data.get("market", ""), data.get("symbol", ""), indicators)} -IMPORTANT: +IMPORTANT: 1. **CRITICAL**: Check for GEOPOLITICAL EVENTS (wars, conflicts, military actions) in the news section. These events have HIGHEST PRIORITY and can override all technical indicators. 2. Consider the macro environment (especially DXY, VIX, rates, geopolitical events) when making your recommendation. 3. Pay attention to BREAKING NEWS and international events that could cause sudden market moves. Geopolitical tensions (e.g., US-Iran conflict) can cause severe market volatility. @@ -676,74 +689,74 @@ IMPORTANT: 6. Provide your analysis now. Remember: all prices must be within 10% of ${current_price}.""" return system_prompt, user_prompt - + def _format_financial_statements(self, statements: Dict[str, Any]) -> str: """Formatting financial statement data for prompt words""" if not statements: return "财务报表数据暂不可用" - + lines = [] - + # balance sheet - if 'balance_sheet' in statements: - bs = statements['balance_sheet'] + if "balance_sheet" in statements: + bs = statements["balance_sheet"] lines.append("资产负债表 (Balance Sheet):") - if bs.get('total_assets'): + if bs.get("total_assets"): lines.append(f" - 总资产: ${bs['total_assets']:,.0f}") - if bs.get('total_liabilities'): + if bs.get("total_liabilities"): lines.append(f" - 总负债: ${bs['total_liabilities']:,.0f}") - if bs.get('total_equity'): + if bs.get("total_equity"): lines.append(f" - 股东权益: ${bs['total_equity']:,.0f}") - if bs.get('cash'): + if bs.get("cash"): lines.append(f" - 现金: ${bs['cash']:,.0f}") - if bs.get('debt'): + if bs.get("debt"): lines.append(f" - 总债务: ${bs['debt']:,.0f}") - if bs.get('current_assets') and bs.get('current_liabilities'): - current_ratio = bs['current_assets'] / bs['current_liabilities'] if bs['current_liabilities'] > 0 else 0 + if bs.get("current_assets") and bs.get("current_liabilities"): + current_ratio = bs["current_assets"] / bs["current_liabilities"] if bs["current_liabilities"] > 0 else 0 lines.append(f" - 流动比率: {current_ratio:.2f}") - + # income statement - if 'income_statement' in statements: - is_stmt = statements['income_statement'] + if "income_statement" in statements: + is_stmt = statements["income_statement"] lines.append("利润表 (Income Statement):") - if is_stmt.get('total_revenue'): + if is_stmt.get("total_revenue"): lines.append(f" - 总收入: ${is_stmt['total_revenue']:,.0f}") - if is_stmt.get('gross_profit'): + if is_stmt.get("gross_profit"): lines.append(f" - 毛利润: ${is_stmt['gross_profit']:,.0f}") - if is_stmt.get('operating_income'): + if is_stmt.get("operating_income"): lines.append(f" - 营业利润: ${is_stmt['operating_income']:,.0f}") - if is_stmt.get('net_income'): + if is_stmt.get("net_income"): lines.append(f" - 净利润: ${is_stmt['net_income']:,.0f}") - if is_stmt.get('eps'): + if is_stmt.get("eps"): lines.append(f" - 每股收益: ${is_stmt['eps']:.2f}") - + # cash flow statement - if 'cash_flow' in statements: - cf = statements['cash_flow'] + if "cash_flow" in statements: + cf = statements["cash_flow"] lines.append("现金流量表 (Cash Flow):") - if cf.get('operating_cash_flow'): + if cf.get("operating_cash_flow"): lines.append(f" - 经营现金流: ${cf['operating_cash_flow']:,.0f}") - if cf.get('free_cash_flow'): + if cf.get("free_cash_flow"): lines.append(f" - 自由现金流: ${cf['free_cash_flow']:,.0f}") - + return "\n".join(lines) if lines else "财务报表数据暂不可用" - + def _format_earnings_data(self, earnings: Dict[str, Any]) -> str: """Format profit data for prompt words""" if not earnings: return "盈利数据暂不可用" - + lines = [] - + # historical profit - if 'history' in earnings and earnings['history']: + if "history" in earnings and earnings["history"]: lines.append("历史盈利 (Earnings History):") - for i, hist in enumerate(earnings['history'][:4], 1): - date = hist.get('date', 'N/A') - eps_actual = hist.get('eps_actual') - eps_estimate = hist.get('eps_estimate') - surprise = hist.get('surprise') - + for i, hist in enumerate(earnings["history"][:4], 1): + date = hist.get("date", "N/A") + eps_actual = hist.get("eps_actual") + eps_estimate = hist.get("eps_estimate") + surprise = hist.get("surprise") + if eps_actual is not None: line = f" {i}. {date}: EPS实际={eps_actual:.2f}" if eps_estimate is not None: @@ -752,52 +765,54 @@ IMPORTANT: surprise_str = f"{surprise:+.1f}%" line += f", 超预期={surprise_str}" lines.append(line) - + # future profit - if 'upcoming' in earnings: - upcoming = earnings['upcoming'] - if upcoming.get('next_earnings_date'): + if "upcoming" in earnings: + upcoming = earnings["upcoming"] + if upcoming.get("next_earnings_date"): lines.append(f"下次盈利报告: {upcoming['next_earnings_date']}") - if upcoming.get('eps_estimate'): + if upcoming.get("eps_estimate"): lines.append(f" - EPS预期: ${upcoming['eps_estimate']:.2f}") - if upcoming.get('revenue_estimate'): + if upcoming.get("revenue_estimate"): lines.append(f" - 收入预期: ${upcoming['revenue_estimate']:,.0f}") - + # quarterly profit - if 'quarterly' in earnings: - q = earnings['quarterly'] - if q.get('latest_quarter'): + if "quarterly" in earnings: + q = earnings["quarterly"] + if q.get("latest_quarter"): lines.append(f"最新季度 ({q['latest_quarter']}):") - if q.get('revenue'): + if q.get("revenue"): lines.append(f" - 收入: ${q['revenue']:,.0f}") - if q.get('earnings'): + if q.get("earnings"): lines.append(f" - 盈利: ${q['earnings']:,.0f}") - + return "\n".join(lines) if lines else "盈利数据暂不可用" - + def _format_macro_summary(self, macro: Dict[str, Any], market: str) -> str: """Format macro data summaries""" if not macro: return "宏观数据暂不可用" - + lines = [] - + # dollar index - if 'DXY' in macro: - dxy = macro['DXY'] - direction = "↑" if dxy.get('change', 0) > 0 else "↓" - lines.append(f"- {dxy.get('name', 'USD Index')}: {dxy.get('price', 'N/A')} ({direction}{abs(dxy.get('changePercent', 0)):.2f}%)") + if "DXY" in macro: + dxy = macro["DXY"] + direction = "↑" if dxy.get("change", 0) > 0 else "↓" + lines.append( + f"- {dxy.get('name', 'USD Index')}: {dxy.get('price', 'N/A')} ({direction}{abs(dxy.get('changePercent', 0)):.2f}%)" + ) # The impact of the strength of the U.S. dollar on different assets - if market == 'Crypto': - impact = "利空加密货币" if dxy.get('change', 0) > 0 else "利好加密货币" + if market == "Crypto": + impact = "利空加密货币" if dxy.get("change", 0) > 0 else "利好加密货币" lines.append(f" ⚠️ 美元{direction} {impact}") - elif market == 'Forex': + elif market == "Forex": lines.append(f" ⚠️ 美元{direction} 直接影响外汇走势") - + # VIX panic index - if 'VIX' in macro: - vix = macro['VIX'] - vix_value = vix.get('price', 0) + if "VIX" in macro: + vix = macro["VIX"] + vix_value = vix.get("price", 0) if vix_value > 30: level = "极度恐慌 (>30)" elif vix_value > 20: @@ -807,42 +822,55 @@ IMPORTANT: else: level = "低波动 (<15)" lines.append(f"- {vix.get('name', 'VIX')}: {vix_value:.2f} - {level}") - + # U.S. Treasury yields - if 'TNX' in macro: - tnx = macro['TNX'] - direction = "↑" if tnx.get('change', 0) > 0 else "↓" + if "TNX" in macro: + tnx = macro["TNX"] + direction = "↑" if tnx.get("change", 0) > 0 else "↓" lines.append(f"- {tnx.get('name', '10Y Treasury')}: {tnx.get('price', 'N/A'):.3f}% ({direction})") - if tnx.get('price', 0) > 4.5: + if tnx.get("price", 0) > 4.5: lines.append(" ⚠️ 高利率环境,对估值不利") - + # gold - if 'GOLD' in macro: - gold = macro['GOLD'] - direction = "↑" if gold.get('change', 0) > 0 else "↓" - lines.append(f"- {gold.get('name', 'Gold')}: ${gold.get('price', 'N/A'):.2f} ({direction}{abs(gold.get('changePercent', 0)):.2f}%)") - + if "GOLD" in macro: + gold = macro["GOLD"] + direction = "↑" if gold.get("change", 0) > 0 else "↓" + lines.append( + f"- {gold.get('name', 'Gold')}: ${gold.get('price', 'N/A'):.2f} ({direction}{abs(gold.get('changePercent', 0)):.2f}%)" + ) + # S&P 500 - if 'SPY' in macro: - spy = macro['SPY'] - direction = "↑" if spy.get('change', 0) > 0 else "↓" - lines.append(f"- {spy.get('name', 'S&P 500')}: ${spy.get('price', 'N/A'):.2f} ({direction}{abs(spy.get('changePercent', 0)):.2f}%)") - + if "SPY" in macro: + spy = macro["SPY"] + direction = "↑" if spy.get("change", 0) > 0 else "↓" + lines.append( + f"- {spy.get('name', 'S&P 500')}: ${spy.get('price', 'N/A'):.2f} ({direction}{abs(spy.get('changePercent', 0)):.2f}%)" + ) + # Bitcoin (as a risk indicator) - if 'BTC' in macro and market != 'Crypto': - btc = macro['BTC'] - direction = "↑" if btc.get('change', 0) > 0 else "↓" - lines.append(f"- {btc.get('name', 'BTC')}: ${btc.get('price', 'N/A'):,.0f} ({direction}{abs(btc.get('changePercent', 0)):.2f}%) [风险偏好指标]") - + if "BTC" in macro and market != "Crypto": + btc = macro["BTC"] + direction = "↑" if btc.get("change", 0) > 0 else "↓" + lines.append( + f"- {btc.get('name', 'BTC')}: ${btc.get('price', 'N/A'):,.0f} ({direction}{abs(btc.get('changePercent', 0)):.2f}%) [风险偏好指标]" + ) + return "\n".join(lines) if lines else "宏观数据暂不可用" - + # ==================== Main Analysis ==================== - - def analyze(self, market: str, symbol: str, language: str = 'en-US', - model: str = None, timeframe: str = "1D", user_id: int = None) -> Dict[str, Any]: + + def analyze( + self, + market: str, + symbol: str, + language: str = "en-US", + model: str = None, + timeframe: str = "1D", + user_id: int = None, + ) -> Dict[str, Any]: """ Run fast single-call analysis. - + Args: market: Market type (Crypto, USStock, etc.) symbol: Trading pair or stock symbol @@ -850,17 +878,17 @@ IMPORTANT: model: LLM model to use timeframe: Analysis timeframe (1D, 4H, etc.) user_id: User ID for storing analysis history - + Returns: Complete analysis result with actionable recommendations. """ start_time = time.time() - + # Get default model if not specified if not model: model = self.llm_service.get_default_model() logger.debug(f"Using default model: {model}") - + result = { "market": market, "symbol": symbol, @@ -870,7 +898,7 @@ IMPORTANT: "analysis_time_ms": 0, "error": None, } - + try: # Phase 1: Data collection (multi-timeframe for consensus) logger.info(f"Fast analysis starting: {market}:{symbol}") @@ -1039,7 +1067,9 @@ IMPORTANT: # Agreement factor: how many timeframes support the consensus decision tf_count = max(1, len(objective_by_tf)) - agreement_cnt = sum(1 for x in objective_by_tf.values() if str(x.get("decision") or "").upper() == consensus_decision) + agreement_cnt = sum( + 1 for x in objective_by_tf.values() if str(x.get("decision") or "").upper() == consensus_decision + ) agreement_ratio = agreement_cnt / tf_count # Data quality degradation: derive from primary_data meta @@ -1063,14 +1093,14 @@ IMPORTANT: ) data = primary_data # keep original variable usage for prompt/LLM input - + # Validate we have essential data - with fallback to indicators current_price = None - + # Get it from price data first if data.get("price") and data["price"].get("price"): current_price = data["price"]["price"] - + # Fallback: Get from indicators (if the K-line is calculated successfully) if not current_price and data.get("indicators"): current_price = data["indicators"].get("current_price") @@ -1081,9 +1111,9 @@ IMPORTANT: "price": current_price, "change": 0, "changePercent": 0, - "source": "indicators_fallback" + "source": "indicators_fallback", } - + # Fallback: Get from the last kline if not current_price and data.get("kline"): klines = data["kline"] @@ -1098,14 +1128,14 @@ IMPORTANT: "price": current_price, "change": round(change, 6), "changePercent": round(change_pct, 2), - "source": "kline_fallback" + "source": "kline_fallback", } - + if not current_price or current_price <= 0: result["error"] = "Failed to fetch current price from all sources" logger.error(f"Price fetch failed for {market}:{symbol}, all sources exhausted") return result - + # Phase 2: Build prompt system_prompt, user_prompt = self._build_analysis_prompt(data, language) @@ -1143,6 +1173,7 @@ IMPORTANT: analyses_list.append(a) decisions = [str(a.get("decision", "HOLD") or "HOLD").upper() for a in analyses_list] from collections import Counter + vote = Counter(decisions).most_common(1)[0][0] idx = decisions.index(vote) analysis = analyses_list[idx].copy() @@ -1156,7 +1187,7 @@ IMPORTANT: llm_time = int((time.time() - llm_start) * 1000) logger.info(f"LLM call completed in {llm_time}ms") - + # Phase 4: Objective score (primary tf) + consensus calibration objective_score = self._calculate_objective_score(data, current_price) logger.info( @@ -1169,14 +1200,20 @@ IMPORTANT: llm_decision = str(analysis.get("decision", "HOLD") or "HOLD").upper() # Horizon trend outlook for users (short/medium/long decision reference) - score_1d = float((objective_by_tf.get("1D") or {}).get("overall_score", objective_score.get("overall_score", 0.0)) or 0.0) + score_1d = float( + (objective_by_tf.get("1D") or {}).get("overall_score", objective_score.get("overall_score", 0.0)) or 0.0 + ) score_4h = float((objective_by_tf.get("4H") or {}).get("overall_score", score_1d) or score_1d) score_1h = float((objective_by_tf.get("1H") or {}).get("overall_score", score_4h) or score_4h) # ~24h: prefer 1H bar objective; fall back 4H -> 1D score_24h = float(score_1h) score_1w = float((objective_by_tf.get("1W") or {}).get("overall_score", score_1d) or score_1d) score_3d = score_1d * 0.7 + score_4h * 0.3 - score_1m = score_1w * 0.55 + float(objective_score.get("fundamental_score", 0.0)) * 0.30 + float(objective_score.get("macro_score", 0.0)) * 0.15 + score_1m = ( + score_1w * 0.55 + + float(objective_score.get("fundamental_score", 0.0)) * 0.30 + + float(objective_score.get("macro_score", 0.0)) * 0.15 + ) def _trend_strength(score_val: float) -> str: a = abs(float(score_val)) @@ -1253,7 +1290,9 @@ IMPORTANT: analysis["summary"] = f"{original_summary} {consensus_note}".strip() else: # Near-neutral: keep LLM but shrink confidence by quality and enforce HOLD if quality is poor - analysis["confidence"] = int(max(0, min(100, int(analysis.get("confidence", 50) or 50) * quality_multiplier))) + analysis["confidence"] = int( + max(0, min(100, int(analysis.get("confidence", 50) or 50) * quality_multiplier)) + ) if quality_multiplier < quality_hold_thr: analysis["decision"] = "HOLD" @@ -1278,20 +1317,20 @@ IMPORTANT: "quality_multiplier": quality_multiplier, "market_regime": regime, } - + # Phase 5: Validate and constrain output (pass indicators for decision validation) # Check for major news or macro events that could override technical indicators news_data = data.get("news") or [] macro_data = data.get("macro") or {} has_major_news = self._has_major_news(news_data) has_macro_event = self._has_macro_event(macro_data, data.get("market", "")) - + analysis = self._validate_and_constrain( - analysis, - current_price, + analysis, + current_price, indicators=data.get("indicators"), has_major_news=has_major_news, - has_macro_event=has_macro_event + has_macro_event=has_macro_event, ) # Post-validate: adjust position sizing based on quality + agreement @@ -1313,96 +1352,101 @@ IMPORTANT: if os.getenv("ENABLE_CONFIDENCE_CALIBRATION", "false").lower() == "true": try: from app.services.analysis_memory import get_analysis_memory + raw_conf = int(analysis.get("confidence", 50) or 50) analysis["confidence"] = get_analysis_memory().get_adjusted_confidence( raw_conf, market=market, symbol=symbol ) except Exception as e: logger.debug(f"Confidence calibration skipped: {e}") - + # Build final result total_time = int((time.time() - start_time) * 1000) - + # Extract detailed analysis sections detailed_analysis = analysis.get("analysis", {}) if isinstance(detailed_analysis, str): # If AI returned a string instead of dict, use it as technical analysis detailed_analysis = {"technical": detailed_analysis, "fundamental": "", "sentiment": ""} - - result.update({ - "decision": analysis.get("decision", "HOLD"), - "confidence": analysis.get("confidence", 50), - "summary": analysis.get("summary", ""), - "model": model, # Model is already set in result initialization - "language": language, # Ensure language is included for task record - "detailed_analysis": { - "technical": detailed_analysis.get("technical", ""), - "fundamental": detailed_analysis.get("fundamental", ""), - "sentiment": detailed_analysis.get("sentiment", ""), - }, - "trading_plan": { - "entry_price": analysis.get("entry_price"), - "stop_loss": analysis.get("stop_loss"), - "take_profit": analysis.get("take_profit"), - "position_size_pct": analysis.get("position_size_pct", 10), - "timeframe": analysis.get("timeframe", "medium"), - # camelCase + semantic alias: for private front-end/legacy component binding (do not use indicators.trading_levels as a plan) - "entryPrice": analysis.get("entry_price"), - "stopLoss": analysis.get("stop_loss"), - "takeProfit": analysis.get("take_profit"), - "positionSizePct": analysis.get("position_size_pct", 10), - "decision": str(analysis.get("decision", "HOLD") or "HOLD").upper(), - # The same value as stop_loss / take_profit; the naming emphasizes "loss exit / profit target" to avoid confusion with the long order reference line - "loss_exit_price": analysis.get("stop_loss"), - "profit_target_price": analysis.get("take_profit"), - }, - "reasons": analysis.get("key_reasons", []), - "risks": analysis.get("risks", []), - "scores": { - "technical": analysis.get("technical_score", 50), - "fundamental": analysis.get("fundamental_score", 50), - "sentiment": analysis.get("sentiment_score", 50), - "overall": self._calculate_overall_score(analysis), - }, - "objective_score": analysis.get("objective_score", {}), - "score_based_decision": analysis.get("score_based_decision", "HOLD"), - "market_data": { - "current_price": current_price, - "change_24h": data["price"].get("changePercent", 0), - "support": data["indicators"].get("levels", {}).get("support"), - "resistance": data["indicators"].get("levels", {}).get("resistance"), - }, - "indicators": data.get("indicators", {}), - "consensus": analysis.get("consensus", {}), - "trend_outlook": trend_outlook, - "trend_outlook_summary": trend_outlook_summary, - "trendOutlook": trend_outlook, - "trendOutlookSummary": trend_outlook_summary, - "analysis_time_ms": total_time, - "llm_time_ms": llm_time, - "data_collection_time_ms": data.get("collection_time_ms", 0), - }) - + + result.update( + { + "decision": analysis.get("decision", "HOLD"), + "confidence": analysis.get("confidence", 50), + "summary": analysis.get("summary", ""), + "model": model, # Model is already set in result initialization + "language": language, # Ensure language is included for task record + "detailed_analysis": { + "technical": detailed_analysis.get("technical", ""), + "fundamental": detailed_analysis.get("fundamental", ""), + "sentiment": detailed_analysis.get("sentiment", ""), + }, + "trading_plan": { + "entry_price": analysis.get("entry_price"), + "stop_loss": analysis.get("stop_loss"), + "take_profit": analysis.get("take_profit"), + "position_size_pct": analysis.get("position_size_pct", 10), + "timeframe": analysis.get("timeframe", "medium"), + # camelCase + semantic alias: for private front-end/legacy component binding (do not use indicators.trading_levels as a plan) + "entryPrice": analysis.get("entry_price"), + "stopLoss": analysis.get("stop_loss"), + "takeProfit": analysis.get("take_profit"), + "positionSizePct": analysis.get("position_size_pct", 10), + "decision": str(analysis.get("decision", "HOLD") or "HOLD").upper(), + # The same value as stop_loss / take_profit; the naming emphasizes "loss exit / profit target" to avoid confusion with the long order reference line + "loss_exit_price": analysis.get("stop_loss"), + "profit_target_price": analysis.get("take_profit"), + }, + "reasons": analysis.get("key_reasons", []), + "risks": analysis.get("risks", []), + "scores": { + "technical": analysis.get("technical_score", 50), + "fundamental": analysis.get("fundamental_score", 50), + "sentiment": analysis.get("sentiment_score", 50), + "overall": self._calculate_overall_score(analysis), + }, + "objective_score": analysis.get("objective_score", {}), + "score_based_decision": analysis.get("score_based_decision", "HOLD"), + "market_data": { + "current_price": current_price, + "change_24h": data["price"].get("changePercent", 0), + "support": data["indicators"].get("levels", {}).get("support"), + "resistance": data["indicators"].get("levels", {}).get("resistance"), + }, + "indicators": data.get("indicators", {}), + "consensus": analysis.get("consensus", {}), + "trend_outlook": trend_outlook, + "trend_outlook_summary": trend_outlook_summary, + "trendOutlook": trend_outlook, + "trendOutlookSummary": trend_outlook_summary, + "analysis_time_ms": total_time, + "llm_time_ms": llm_time, + "data_collection_time_ms": data.get("collection_time_ms", 0), + } + ) + # Store in memory for future retrieval and get memory_id for feedback memory_id = self._store_analysis_memory(result, user_id=user_id) if memory_id: result["memory_id"] = memory_id - - logger.info(f"Fast analysis completed in {total_time}ms: {market}:{symbol} -> {result['decision']} (memory_id={memory_id}, user_id={user_id})") - + + logger.info( + f"Fast analysis completed in {total_time}ms: {market}:{symbol} -> {result['decision']} (memory_id={memory_id}, user_id={user_id})" + ) + except Exception as e: logger.error(f"Fast analysis failed: {e}", exc_info=True) result["error"] = str(e) - + return result - + def _build_decision_guidance(self, rsi_value: float, macd_signal: str, ma_trend: str, change_24h: float) -> str: """ Build decision guidance based on technical indicators to help AI make more reasonable decisions. Emphasize that the SELL signal is an effective short selling opportunity. """ guidance_parts = [] - + # RSI Guidance - Identify shorting opportunities more aggressively if rsi_value > 70: guidance_parts.append("🔴 RSI > 70 (超买): 强烈建议SELL做空,避免BUY") @@ -1414,7 +1458,7 @@ IMPORTANT: guidance_parts.append("🟡 RSI < 40 (偏超卖): 可以考虑BUY做多") else: guidance_parts.append("⚪ RSI 40-60 (中性): 技术面中性,需要结合其他指标判断") - + # MACD Guidance - Clear Short Signal if macd_signal == "bullish": guidance_parts.append("🟢 MACD 看涨: 支持BUY做多") @@ -1422,7 +1466,7 @@ IMPORTANT: guidance_parts.append("🔴 MACD 看跌: 支持SELL做空,这是有效的做空机会") else: guidance_parts.append("⚪ MACD 中性: 无明显方向") - + # MA Trend Guidance - Identify Trend Reversal Opportunities if "uptrend" in ma_trend.lower() or "strong_uptrend" in ma_trend.lower(): if rsi_value > 60: @@ -1433,36 +1477,26 @@ IMPORTANT: guidance_parts.append("🔴 均线趋势向下: 这是SELL做空的良好机会,避免BUY") else: guidance_parts.append("⚪ 均线横盘: 趋势不明确") - + # 24-hour price range guidance - identifying excessive volatility if change_24h > 5: guidance_parts.append("🔴 24h涨幅 > 5%: 可能已过度上涨,建议SELL做空或获利了结") elif change_24h < -5: guidance_parts.append("🟢 24h跌幅 > 5%: 可能已过度下跌,可以考虑BUY做多") - + # Comprehensive suggestions - sell_signals = sum([ - rsi_value > 60, - macd_signal == "bearish", - "downtrend" in ma_trend.lower(), - change_24h > 5 - ]) - buy_signals = sum([ - rsi_value < 40, - macd_signal == "bullish", - "uptrend" in ma_trend.lower(), - change_24h < -5 - ]) - + sell_signals = sum([rsi_value > 60, macd_signal == "bearish", "downtrend" in ma_trend.lower(), change_24h > 5]) + buy_signals = sum([rsi_value < 40, macd_signal == "bullish", "uptrend" in ma_trend.lower(), change_24h < -5]) + if sell_signals >= 2: guidance_parts.append(f"📊 综合判断: {sell_signals}个做空信号,建议考虑SELL") elif buy_signals >= 2: guidance_parts.append(f"📊 综合判断: {buy_signals}个做多信号,建议考虑BUY") else: guidance_parts.append("📊 综合判断: 信号混合,需要结合宏观和新闻判断") - + return "\n".join(guidance_parts) if guidance_parts else "技术指标数据不足,请谨慎判断" - + def _has_major_news(self, news_data: List[Dict]) -> bool: """ Check for breaking news events. @@ -1474,12 +1508,38 @@ IMPORTANT: # Substring keywords (longer words or Chinese to avoid mismatching of too short English) major_keywords = [ - "regulation", "regulatory", "approval", "policy", "government", "central bank", - "监管", "禁令", "批准", "政策", "政府", "央行", - "partnership", "merger", "acquisition", "scandal", "lawsuit", "investigation", - "合作", "合并", "收购", "丑闻", "诉讼", "调查", - "sanctions", "embargo", "制裁", "中东", "海湾", "北约", - "united states", "middle east", + "regulation", + "regulatory", + "approval", + "policy", + "government", + "central bank", + "监管", + "禁令", + "批准", + "政策", + "政府", + "央行", + "partnership", + "merger", + "acquisition", + "scandal", + "lawsuit", + "investigation", + "合作", + "合并", + "收购", + "丑闻", + "诉讼", + "调查", + "sanctions", + "embargo", + "制裁", + "中东", + "海湾", + "北约", + "united states", + "middle east", ] # Use word boundary matching for short English words (without using naked substrings) major_short_patterns = [ @@ -1507,7 +1567,7 @@ IMPORTANT: return True return False - + def _has_macro_event(self, macro_data: Dict, market: str) -> bool: """ Check for major macro events. @@ -1515,30 +1575,30 @@ IMPORTANT: """ if not macro_data: return False - + # Check the VIX (fear index) if "VIX" in macro_data: vix = macro_data["VIX"] vix_value = vix.get("price", 0) if vix_value > 30: # VIX > 30 indicates extreme panic return True - + # Check for large DXY swings (>1%) if "DXY" in macro_data: dxy = macro_data["DXY"] change_pct = abs(dxy.get("changePercent", 0)) if change_pct > 1.0: # The U.S. dollar index fluctuates more than 1% return True - + # Check for interest rate changes (big impact on stocks and cryptocurrencies) if "TNX" in macro_data and market in ["USStock", "Crypto"]: tnx = macro_data["TNX"] change_pct = abs(tnx.get("changePercent", 0)) if change_pct > 2.0: # Interest rates change by more than 2% return True - + return False - + def _finalize_trading_plan_for_decision( self, analysis: Dict, current_price: float, indicators: Optional[Dict] = None ) -> Dict: @@ -1621,20 +1681,26 @@ IMPORTANT: return analysis - def _validate_and_constrain(self, analysis: Dict, current_price: float, indicators: Dict = None, - has_major_news: bool = False, has_macro_event: bool = False) -> Dict: + def _validate_and_constrain( + self, + analysis: Dict, + current_price: float, + indicators: Dict = None, + has_major_news: bool = False, + has_macro_event: bool = False, + ) -> Dict: """ Validate LLM output and constrain prices to reasonable ranges. Also validate decision against technical indicators to prevent absurd recommendations. """ if not current_price or current_price <= 0: return analysis - + # Price bounds min_price = current_price * 0.90 max_price = current_price * 1.10 decision = str(analysis.get("decision", "HOLD")).upper() - + # Constrain entry price entry = _safe_float_price(analysis.get("entry_price"), current_price) if entry is not None and (entry < min_price or entry > max_price): @@ -1642,7 +1708,7 @@ IMPORTANT: analysis["entry_price"] = round(current_price, 6) elif entry is not None: analysis["entry_price"] = round(entry, 6) - + # Constrain stop loss / take profit by direction (numeric-safe). # BUY: stop_loss < current < take_profit # SELL: take_profit < current < stop_loss @@ -1672,40 +1738,44 @@ IMPORTANT: analysis["take_profit"] = tp_default else: analysis["take_profit"] = round(take_profit, 6) - + # Constrain confidence confidence = analysis.get("confidence", 50) analysis["confidence"] = max(0, min(100, int(confidence))) - + # Constrain scores for score_key in ["technical_score", "fundamental_score", "sentiment_score"]: score = analysis.get(score_key, 50) analysis[score_key] = max(0, min(100, int(score))) - + # Validate decision if decision not in ["BUY", "SELL", "HOLD"]: analysis["decision"] = "HOLD" else: analysis["decision"] = decision - + # Validate decision-making rationality based on technical indicators (allow macro/news factor coverage) if indicators: analysis = self._validate_decision_against_indicators( - analysis, indicators, confidence, - has_major_news=has_major_news, - has_macro_event=has_macro_event + analysis, indicators, confidence, has_major_news=has_major_news, has_macro_event=has_macro_event ) # Final geometry after any decision change (e.g. forced HOLD skips finalize in caller — still safe) analysis = self._finalize_trading_plan_for_decision(analysis, current_price, indicators) - + return analysis - - def _validate_decision_against_indicators(self, analysis: Dict, indicators: Dict, confidence: int, - has_major_news: bool = False, has_macro_event: bool = False) -> Dict: + + def _validate_decision_against_indicators( + self, + analysis: Dict, + indicators: Dict, + confidence: int, + has_major_news: bool = False, + has_macro_event: bool = False, + ) -> Dict: """ Justify decisions against technical indicators, but allow macro/news factors to override technical indicators. - + Args: analysis: AI analysis results indicators: technical indicator data @@ -1717,11 +1787,11 @@ IMPORTANT: rsi_data = indicators.get("rsi", {}) macd_data = indicators.get("macd", {}) ma_data = indicators.get("moving_averages", {}) - + rsi_value = rsi_data.get("value", 50) macd_signal = macd_data.get("signal", "neutral") ma_trend = ma_data.get("trend", "sideways") - + # If the confidence level is too low, force it to HOLD if confidence < 60: if decision != "HOLD": @@ -1729,46 +1799,52 @@ IMPORTANT: analysis["decision"] = "HOLD" analysis["confidence"] = max(confidence, 45) # Reduce confidence return analysis - + # Allows technical indicators to be overridden (but logs warnings) if there is major news or macro events allow_override = has_major_news or has_macro_event - + # Check whether the BUY decision conflicts with technical indicators if decision == "BUY": conflicts = [] - + # You should not buy when RSI > 70 (unless there is a major upside) if rsi_value > 70: conflicts.append(f"RSI {rsi_value:.1f} > 70 (超买)") - + # You should not BUY when MACD is bearish (unless there is a major upside) if macd_signal == "bearish": conflicts.append("MACD bearish") - + # You should not buy when the moving average trend is downward (unless there is a major benefit) # Only consider a conflict if the trend is very strong (avoid being too sensitive) if "strong_downtrend" in ma_trend.lower() or ("downtrend" in ma_trend.lower() and rsi_value > 50): conflicts.append(f"MA trend: {ma_trend}") - + if conflicts: if allow_override: # Allow override, but lower confidence and add description - logger.info(f"BUY decision conflicts with indicators but major news/macro event allows override: {', '.join(conflicts)}") + logger.info( + f"BUY decision conflicts with indicators but major news/macro event allows override: {', '.join(conflicts)}" + ) analysis["confidence"] = max(confidence - 15, 50) original_summary = analysis.get("summary", "") - analysis["summary"] = f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},但重大事件可能改变趋势]" + analysis["summary"] = ( + f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},但重大事件可能改变趋势]" + ) else: # If there is no major event, it is forced to be changed to HOLD. - logger.warning(f"BUY decision conflicts with indicators and no major event: {', '.join(conflicts)}. Forcing to HOLD") + logger.warning( + f"BUY decision conflicts with indicators and no major event: {', '.join(conflicts)}. Forcing to HOLD" + ) analysis["decision"] = "HOLD" analysis["confidence"] = max(confidence - 20, 40) original_summary = analysis.get("summary", "") analysis["summary"] = f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},建议观望]" - + # Check whether SELL decisions contradict technical indicators (relax restrictions because SELL is a valid short opportunity) elif decision == "SELL": conflicts = [] - + # Only block SELL (relax conditions) if there is a strong bullish signal # It is considered a contradiction when RSI < 30 and MACD is bullish and the moving average is upward. if rsi_value < 30 and macd_signal == "bullish" and "uptrend" in ma_trend.lower(): @@ -1776,28 +1852,34 @@ IMPORTANT: # Or RSI < 30 and the moving average is strongly upward elif rsi_value < 30 and "strong_uptrend" in ma_trend.lower(): conflicts.append(f"Very strong uptrend with oversold RSI {rsi_value:.1f}") - + if conflicts: if allow_override: # Allow override, but lower confidence and add description - logger.info(f"SELL decision conflicts with strong bullish indicators but major news/macro event allows override: {', '.join(conflicts)}") + logger.info( + f"SELL decision conflicts with strong bullish indicators but major news/macro event allows override: {', '.join(conflicts)}" + ) analysis["confidence"] = max(confidence - 15, 50) original_summary = analysis.get("summary", "") - analysis["summary"] = f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},但重大事件可能改变趋势]" + analysis["summary"] = ( + f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},但重大事件可能改变趋势]" + ) else: # Only change to HOLD if there is a very strong bullish signal - logger.warning(f"SELL decision conflicts with very strong bullish indicators: {', '.join(conflicts)}. Forcing to HOLD") + logger.warning( + f"SELL decision conflicts with very strong bullish indicators: {', '.join(conflicts)}. Forcing to HOLD" + ) analysis["decision"] = "HOLD" analysis["confidence"] = max(confidence - 20, 40) original_summary = analysis.get("summary", "") analysis["summary"] = f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},建议观望]" - + return analysis - + def _calculate_objective_score(self, data: Dict[str, Any], current_price: float) -> Dict[str, float]: """ Calculates a quantitative scoring system based on objective data - + Return a score between -100 and +100: - +100: Strong bullish (strong BUY) - +70 to +100: Strong bullish (strong BUY) @@ -1812,19 +1894,19 @@ IMPORTANT: news = data.get("news") or [] macro = data.get("macro") or {} price_data = data.get("price") or {} - + # 1. Technical indicator score (-100 to +100) technical_score = self._calculate_technical_score(indicators, price_data) - + # 2. Fundamental score (-100 to +100) fundamental_score = self._calculate_fundamental_score(fundamental, data.get("market", "")) - + # 3. News sentiment score (-100 to +100) sentiment_score = self._calculate_sentiment_score(news) - + # 4. Macro environment score (-100 to +100) macro_score = self._calculate_macro_score(macro, data.get("market", "")) - + # 5. Comprehensive rating (weighted average) # Optimization weight: Default technical 35%, fundamentals 20%, sentiment 25% (including geopolitics), macro 20% (increase macro weight) # But we need to "reweight the available information": when some modules are missing (such as news/macro is not obtained), do not use 0 points to dilute the overall strength. @@ -1856,9 +1938,7 @@ IMPORTANT: return True return False - fundamental_present = ( - market_type in ("USStock", "CNStock", "HKStock") and _fundamental_meaningful(fundamental) - ) + fundamental_present = market_type in ("USStock") and _fundamental_meaningful(fundamental) sentiment_present = bool(news) macro_present = bool(macro) # indicators usually exist once they are successfully calculated, but they are also protected here. @@ -1887,13 +1967,13 @@ IMPORTANT: + (sentiment_score * weights["sentiment"] if present_flags.get("sentiment") else 0.0) + (macro_score * weights["macro"] if present_flags.get("macro") else 0.0) ) / total_w - + return { "technical_score": technical_score, "fundamental_score": fundamental_score, "sentiment_score": sentiment_score, "macro_score": macro_score, - "overall_score": overall_score + "overall_score": overall_score, } def _get_ai_calibration(self, market: str = "Crypto") -> Dict[str, Any]: @@ -1914,6 +1994,7 @@ IMPORTANT: try: from app.services.ai_calibration import AICalibrationService + svc = AICalibrationService() cfg = svc.get_latest(key) except Exception as e: @@ -1923,12 +2004,12 @@ IMPORTANT: self._calibration_cache[key] = cfg self._calibration_cache_ts[key] = now return cfg - + def _calculate_technical_score(self, indicators: Dict, price_data: Dict) -> float: """Calculate technical indicator score (-100 to +100)""" score = 0.0 weight_sum = 0.0 - + # RSI score (-50 to +50) rsi_data = indicators.get("rsi", {}) rsi_value = rsi_data.get("value", 50) @@ -1945,7 +2026,7 @@ IMPORTANT: rsi_score = (50 - rsi_value) * 0.6 # Between 40-60, linear mapping score += rsi_score * 0.30 weight_sum += 0.30 - + # MACD score (-40 to +40) macd_data = indicators.get("macd", {}) macd_signal = macd_data.get("signal", "neutral") @@ -1957,7 +2038,7 @@ IMPORTANT: macd_score = 0 score += macd_score * 0.25 weight_sum += 0.25 - + # Moving average trend score (-40 to +40) ma_data = indicators.get("moving_averages", {}) ma_trend = ma_data.get("trend", "sideways") @@ -1973,7 +2054,7 @@ IMPORTANT: ma_score = 0 score += ma_score * 0.25 weight_sum += 0.25 - + # 24-hour rise and fall score (-20 to +20) change_24h = price_data.get("changePercent", 0) if change_24h > 10: @@ -2076,21 +2157,21 @@ IMPORTANT: extra_norm = max(-100.0, min(100.0, float(extra_score))) score += extra_norm * 0.15 weight_sum += 0.15 - + # Normalized to -100 to +100 if weight_sum > 0: score = score / weight_sum * 100 - + return max(-100, min(100, score)) - + def _calculate_fundamental_score(self, fundamental: Dict, market: str) -> float: """Calculate fundamental score (-100 to +100)""" - if market not in ("USStock", "CNStock", "HKStock") or not fundamental: - return 0.0 # Non-U.S. stocks or no fundamental data, return neutral - + if market != "USStock" or not fundamental: + return 0.0 # Only U.S. stocks or no fundamental data, return neutral + score = 0.0 factors = 0 - + # PE Ratio score pe_ratio = fundamental.get("pe_ratio") if pe_ratio and pe_ratio > 0: @@ -2106,7 +2187,7 @@ IMPORTANT: pe_score = 0 score += pe_score factors += 1 - + # ROE score roe = fundamental.get("roe") if roe: @@ -2122,7 +2203,7 @@ IMPORTANT: roe_score = 0 score += roe_score factors += 1 - + # revenue growth score revenue_growth = fundamental.get("revenue_growth") if revenue_growth: @@ -2138,7 +2219,7 @@ IMPORTANT: growth_score = 0 score += growth_score factors += 1 - + # Profitability score profit_margin = fundamental.get("profit_margin") if profit_margin: @@ -2154,7 +2235,7 @@ IMPORTANT: margin_score = 0 score += margin_score factors += 1 - + # Debt to Equity Ratio Score debt_to_equity = fundamental.get("debt_to_equity") if debt_to_equity: @@ -2166,15 +2247,17 @@ IMPORTANT: debt_score = 0 score += debt_score factors += 1 - + # Normalization (if there are multiple factors) if factors > 0: - score = score / factors * 100 / 4 # The maximum possible score is 20 points for each of the 4 factors = 80, normalized to 100 + score = ( + score / factors * 100 / 4 + ) # The maximum possible score is 20 points for each of the 4 factors = 80, normalized to 100 else: return 50.0 return max(-100, min(100, score)) - + def _calculate_sentiment_score(self, news: List[Dict]) -> float: """ Calculate news sentiment score (-100 to +100) @@ -2237,7 +2320,7 @@ IMPORTANT: final_score = base_score return max(-100, min(100, final_score)) - + def _calculate_macro_score(self, macro: Dict, market: str) -> float: """ Calculate macro environment score (-100 to +100) @@ -2245,10 +2328,10 @@ IMPORTANT: """ if not macro: return 0.0 # No macro data, neutral - + score = 0.0 factors = 0 - + # VIX score (fear index) - increased weight vix = macro.get("VIX", {}) vix_value = vix.get("price", 0) @@ -2269,7 +2352,7 @@ IMPORTANT: vix_score = 0 score += vix_score factors += 1 - + # DXY Score (USD Index) - Increased weighting dxy = macro.get("DXY", {}) dxy_value = dxy.get("price", 0) @@ -2297,7 +2380,7 @@ IMPORTANT: dxy_score = 0 score += dxy_score factors += 1 - + # Interest Rate Score (TNX) - Increased weighting tnx = macro.get("TNX", {}) tnx_change = tnx.get("changePercent", 0) @@ -2339,7 +2422,7 @@ IMPORTANT: factors += 1 except Exception: pass - + # Normalization (considering weights) if factors > 0: # Maximum possible score: VIX(-50~+20), DXY(-30~+30), TNX(-30~+30) = about -110 to +80 @@ -2347,9 +2430,9 @@ IMPORTANT: # Add the amplitude of Fear&Greed (about 15) and give some buffer max_possible = 125 # maximum absolute value score = score / max_possible * 100 - + return max(-100, min(100, score)) - + def _detect_market_regime(self, indicators: Dict) -> str: """Detect trending vs ranging from MA trend. trending | ranging""" ma = indicators.get("moving_averages") or {} @@ -2361,12 +2444,12 @@ IMPORTANT: def _score_to_decision(self, score: float, *, market: str = "Crypto") -> str: """ Transformed into decisions based on objective scoring - + Optimized threshold (significantly narrows the HOLD interval to make decisions clearer): - score >= +20: BUY (profit) - score <= -20: SELL (bad) - -20 < score < +20: HOLD (neutral) - + Hierarchical decision-making (for finer-grained judgment): - score >= +70: strong BUY - +40 <= score < +70: obvious BUY @@ -2388,7 +2471,7 @@ IMPORTANT: return "SELL" else: return "HOLD" - + def _calculate_overall_score(self, analysis: Dict) -> int: """Calculate weighted overall score (legacy method, now uses objective score if available).""" # Prioritize objective scoring @@ -2397,72 +2480,74 @@ IMPORTANT: overall = objective.get("overall_score", 50) # Convert to 0-100 format (used by the original system) return max(0, min(100, int(50 + overall * 0.5))) - + # Downgraded to LLM rating tech = analysis.get("technical_score", 50) fund = analysis.get("fundamental_score", 50) sent = analysis.get("sentiment_score", 50) - + # Weights: technical 40%, fundamental 35%, sentiment 25% overall = tech * 0.40 + fund * 0.35 + sent * 0.25 - + # Adjust based on decision decision = analysis.get("decision", "HOLD") confidence = analysis.get("confidence", 50) - + if decision == "BUY": overall = overall * 0.6 + (50 + confidence * 0.5) * 0.4 elif decision == "SELL": overall = overall * 0.6 + (50 - confidence * 0.5) * 0.4 - + return max(0, min(100, int(overall))) - + def _store_analysis_memory(self, result: Dict, user_id: int = None) -> Optional[int]: """Store analysis result for future learning. Returns memory_id.""" try: from app.services.analysis_memory import get_analysis_memory + memory = get_analysis_memory() memory_id = memory.store(result, user_id=user_id) - + # Also save to qd_analysis_tasks for admin statistics self._save_analysis_task(result, user_id=user_id) - + return memory_id except Exception as e: logger.warning(f"Memory storage failed: {e}") return None - + def _save_analysis_task(self, result: Dict, user_id: int = None) -> Optional[int]: """ Save analysis record to qd_analysis_tasks table for admin statistics. - + Args: result: Analysis result dictionary user_id: User ID who created this analysis - + Returns: Task ID or None if failed """ try: from app.utils.db import get_db_connection - + market = result.get("market", "") symbol = result.get("symbol", "") model = result.get("model", "") # If model is empty, get default model if not model: from app.services.llm import LLMService + llm_service = LLMService() model = llm_service.get_default_model() language = result.get("language", "en-US") status = "completed" if not result.get("error") else "failed" result_json = json.dumps(result, ensure_ascii=False) error_message = result.get("error", "") - + if not market or not symbol: - logger.warning(f"Cannot save analysis task: missing market or symbol") + logger.warning("Cannot save analysis task: missing market or symbol") return None - + with get_db_connection() as db: cur = db.cursor() # PostgreSQL: Use RETURNING to get the inserted ID @@ -2478,35 +2563,36 @@ IMPORTANT: int(user_id) if user_id else 1, # Default to user 1 if not provided str(market), str(symbol), - str(model) if model else '', + str(model) if model else "", str(language), str(status), str(result_json), - str(error_message) if error_message else '' - ) + str(error_message) if error_message else "", + ), ) row = cur.fetchone() - task_id = row['id'] if row else None + task_id = row["id"] if row else None db.commit() cur.close() - + if task_id: logger.debug(f"Saved analysis task {task_id} for user {user_id}: {market}:{symbol}") return task_id - + except Exception as e: logger.warning(f"Failed to save analysis task: {e}") return None - + # ==================== Backward Compatibility ==================== - - def analyze_legacy_format(self, market: str, symbol: str, language: str = 'en-US', - model: str = None, timeframe: str = "1D") -> Dict[str, Any]: + + def analyze_legacy_format( + self, market: str, symbol: str, language: str = "en-US", model: str = None, timeframe: str = "1D" + ) -> Dict[str, Any]: """ Returns analysis in legacy multi-agent format for backward compatibility. """ fast_result = self.analyze(market, symbol, language, model, timeframe) - + if fast_result.get("error"): return { "overview": {"report": f"Analysis failed: {fast_result['error']}"}, @@ -2517,7 +2603,7 @@ IMPORTANT: "risk": {"report": "N/A"}, "error": fast_result["error"], } - + # Convert to legacy format decision = fast_result.get("decision", "HOLD") confidence = fast_result.get("confidence", 50) @@ -2525,7 +2611,9 @@ IMPORTANT: to_sum = (fast_result.get("trend_outlook_summary") or "").strip() overview_report = fast_result.get("summary", "") or "" if to_sum: - overview_report = f"{overview_report}\n\n【周期预判】{to_sum}" if overview_report.strip() else f"【周期预判】{to_sum}" + overview_report = ( + f"{overview_report}\n\n【周期预判】{to_sum}" if overview_report.strip() else f"【周期预判】{to_sum}" + ) return { "overview": { @@ -2598,6 +2686,7 @@ IMPORTANT: # Singleton instance _fast_analysis_service = None + def get_fast_analysis_service() -> FastAnalysisService: """Get singleton FastAnalysisService instance.""" global _fast_analysis_service @@ -2606,8 +2695,9 @@ def get_fast_analysis_service() -> FastAnalysisService: return _fast_analysis_service -def fast_analyze(market: str, symbol: str, language: str = 'en-US', - model: str = None, timeframe: str = "1D") -> Dict[str, Any]: +def fast_analyze( + market: str, symbol: str, language: str = "en-US", model: str = None, timeframe: str = "1D" +) -> Dict[str, Any]: """Convenience function for fast analysis.""" service = get_fast_analysis_service() return service.analyze(market, symbol, language, model, timeframe) diff --git a/backend_api_python/app/services/ibkr_trading/README.md b/backend_api_python/app/services/ibkr_trading/README.md index 9d703ea..19cf692 100644 --- a/backend_api_python/app/services/ibkr_trading/README.md +++ b/backend_api_python/app/services/ibkr_trading/README.md @@ -125,7 +125,7 @@ Then remove the related import and registration code in `app/routes/__init__.py` ## Docker Note -When running in Docker, IBKR trading requires TWS/IB Gateway to be accessible from the container. +When running in Docker, IBKR trading requires TWS/IB Gateway to be accessible from the container. For local deployment, you can: 1. Run TWS/Gateway on host machine diff --git a/backend_api_python/app/services/ibkr_trading/__init__.py b/backend_api_python/app/services/ibkr_trading/__init__.py index d42131f..132e55d 100644 --- a/backend_api_python/app/services/ibkr_trading/__init__.py +++ b/backend_api_python/app/services/ibkr_trading/__init__.py @@ -11,4 +11,4 @@ Port Reference: from app.services.ibkr_trading.client import IBKRClient, IBKRConfig from app.services.ibkr_trading.symbols import normalize_symbol, parse_symbol -__all__ = ['IBKRClient', 'IBKRConfig', 'normalize_symbol', 'parse_symbol'] +__all__ = ["IBKRClient", "IBKRConfig", "normalize_symbol", "parse_symbol"] diff --git a/backend_api_python/app/services/ibkr_trading/client.py b/backend_api_python/app/services/ibkr_trading/client.py index 3dd34c6..36aa8fe 100644 --- a/backend_api_python/app/services/ibkr_trading/client.py +++ b/backend_api_python/app/services/ibkr_trading/client.py @@ -4,14 +4,13 @@ Interactive Brokers Trading Client Uses ib_insync library to connect to TWS or IB Gateway for trading. """ -import time -import threading import asyncio +import threading from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List +from typing import Any, Dict, List, Optional +from app.services.ibkr_trading.symbols import format_display_symbol, normalize_symbol from app.utils.logger import get_logger -from app.services.ibkr_trading.symbols import normalize_symbol, format_display_symbol logger = get_logger(__name__) @@ -19,7 +18,7 @@ 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. """ @@ -34,6 +33,7 @@ def _ensure_event_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 @@ -44,17 +44,17 @@ def _ensure_ib_insync(): if ib_insync is None: try: import ib_insync as _ib + ib_insync = _ib except ImportError: - raise ImportError( - "ib_insync is not installed. Run: pip install ib_insync" - ) + raise ImportError("ib_insync is not installed. Run: pip install ib_insync") return ib_insync @dataclass class IBKRConfig: """IBKR connection configuration.""" + host: str = "127.0.0.1" port: int = 7497 # TWS Live:7497, TWS Paper:7496, Gateway Live:4001, Gateway Paper:4002 client_id: int = 1 @@ -66,6 +66,7 @@ class IBKRConfig: @dataclass class OrderResult: """Order execution result.""" + success: bool order_id: int = 0 filled: float = 0.0 @@ -78,67 +79,69 @@ class OrderResult: class IBKRClient: """ Interactive Brokers Trading Client - + Usage: config = IBKRConfig(port=7497) # TWS Live client = IBKRClient(config) - + if client.connect(): # Place order result = client.place_market_order("AAPL", "buy", 10, "USStock") - + # Get positions positions = client.get_positions() - + client.disconnect() """ - + def __init__(self, config: Optional[IBKRConfig] = None): self.config = config or IBKRConfig() self._ib = None self._connected = False self._lock = threading.Lock() self._account = "" - + @property def connected(self) -> bool: """Check if connected.""" if self._ib is None: return False return self._ib.isConnected() - + def connect(self) -> bool: """ Connect to TWS or IB Gateway. - + Returns: True if connected successfully """ with self._lock: if self.connected: 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: self._ib = ib_insync.IB() - - logger.info(f"Connecting to IBKR: {self.config.host}:{self.config.port} (clientId={self.config.client_id})") - + + logger.info( + f"Connecting to IBKR: {self.config.host}:{self.config.port} (clientId={self.config.client_id})" + ) + self._ib.connect( host=self.config.host, port=self.config.port, clientId=self.config.client_id, readonly=self.config.readonly, - timeout=self.config.timeout + timeout=self.config.timeout, ) - + self._connected = True - + # Get account accounts = self._ib.managedAccounts() if accounts: @@ -146,14 +149,14 @@ class IBKRClient: logger.info(f"IBKR connected, account: {self._account}") else: logger.warning("IBKR connected but no account info retrieved") - + return True - + except Exception as e: logger.error(f"IBKR connection failed: {e}") self._connected = False return False - + def disconnect(self): """Disconnect from IBKR.""" with self._lock: @@ -165,7 +168,7 @@ class IBKRClient: finally: self._connected = False logger.info("IBKR disconnected") - + def _ensure_connected(self): """Ensure connection is established.""" # Ensure event loop exists (may be called from different threads) @@ -173,27 +176,23 @@ class IBKRClient: if not self.connected: if not self.connect(): raise ConnectionError("Cannot connect to IBKR") - + def _create_contract(self, symbol: str, market_type: str): """ Create IB contract object. - + Args: symbol: Symbol code market_type: Market type (USStock) """ _ensure_ib_insync() - + ib_symbol, exchange, currency = normalize_symbol(symbol, market_type) - - contract = ib_insync.Stock( - symbol=ib_symbol, - exchange=exchange, - currency=currency - ) - + + contract = ib_insync.Stock(symbol=ib_symbol, exchange=exchange, currency=currency) + return contract - + def _qualify_contract(self, contract) -> bool: """Validate contract.""" try: @@ -202,9 +201,9 @@ class IBKRClient: except Exception as e: logger.warning(f"Contract qualification failed: {e}") return False - + # ==================== Order Methods ==================== - + def place_market_order( self, symbol: str, @@ -214,38 +213,33 @@ class IBKRClient: ) -> OrderResult: """ Place a market order. - + Args: symbol: Symbol code (e.g., AAPL, 0700.HK) side: Direction ("buy" or "sell") quantity: Number of shares market_type: Market type ("USStock") - + Returns: OrderResult """ try: self._ensure_connected() _ensure_ib_insync() - + contract = self._create_contract(symbol, market_type) if not self._qualify_contract(contract): - return OrderResult( - success=False, - message=f"Invalid contract: {symbol}" - ) - + return OrderResult(success=False, message=f"Invalid contract: {symbol}") + order = ib_insync.MarketOrder( - action="BUY" if side.lower() == "buy" else "SELL", - totalQuantity=quantity, - account=self._account + action="BUY" if side.lower() == "buy" else "SELL", totalQuantity=quantity, account=self._account ) - + trade = self._ib.placeOrder(contract, order) - + # Wait for order status update self._ib.sleep(2) - + return OrderResult( success=True, order_id=trade.order.orderId, @@ -258,16 +252,13 @@ class IBKRClient: "status": trade.orderStatus.status, "filled": float(trade.orderStatus.filled or 0), "remaining": float(trade.orderStatus.remaining or 0), - } + }, ) - + except Exception as e: logger.error(f"Order failed: {e}") - return OrderResult( - success=False, - message=str(e) - ) - + return OrderResult(success=False, message=str(e)) + def place_limit_order( self, symbol: str, @@ -278,38 +269,35 @@ class IBKRClient: ) -> OrderResult: """ Place a limit order. - + Args: symbol: Symbol code side: Direction ("buy" or "sell") quantity: Number of shares price: Limit price market_type: Market type - + Returns: OrderResult """ try: self._ensure_connected() _ensure_ib_insync() - + contract = self._create_contract(symbol, market_type) if not self._qualify_contract(contract): - return OrderResult( - success=False, - message=f"Invalid contract: {symbol}" - ) - + return OrderResult(success=False, message=f"Invalid contract: {symbol}") + order = ib_insync.LimitOrder( action="BUY" if side.lower() == "buy" else "SELL", totalQuantity=quantity, lmtPrice=price, - account=self._account + account=self._account, ) - + trade = self._ib.placeOrder(contract, order) self._ib.sleep(1) - + return OrderResult( success=True, order_id=trade.order.orderId, @@ -321,167 +309,161 @@ class IBKRClient: "orderId": trade.order.orderId, "status": trade.orderStatus.status, "limitPrice": price, - } + }, ) - + except Exception as e: logger.error(f"Limit order failed: {e}") - return OrderResult( - success=False, - message=str(e) - ) - + return OrderResult(success=False, message=str(e)) + def cancel_order(self, order_id: int) -> bool: """ Cancel an order. - + Args: order_id: Order ID - + Returns: True if cancelled successfully """ try: self._ensure_connected() - + for trade in self._ib.openTrades(): if trade.order.orderId == order_id: self._ib.cancelOrder(trade.order) logger.info(f"Order {order_id} cancelled") return True - + logger.warning(f"Order not found: {order_id}") return False - + except Exception as e: logger.error(f"Cancel order failed: {e}") return False - + # ==================== Query Methods ==================== - + def get_account_summary(self) -> Dict[str, Any]: """ Get account summary. - + Returns: Account info dictionary """ try: self._ensure_connected() - + summary = self._ib.accountSummary(self._account) result = {} for item in summary: - result[item.tag] = { - "value": item.value, - "currency": item.currency - } - - return { - "account": self._account, - "summary": result, - "success": True - } - + result[item.tag] = {"value": item.value, "currency": item.currency} + + return {"account": self._account, "summary": result, "success": True} + except Exception as e: logger.error(f"Get account summary failed: {e}") return {"success": False, "error": str(e)} - + def get_positions(self) -> List[Dict[str, Any]]: """ Get current positions. - + Returns: List of positions """ try: self._ensure_connected() - + positions = self._ib.positions(self._account) result = [] - + for pos in positions: contract = pos.contract exchange = contract.exchange or contract.primaryExchange or "SMART" - - result.append({ - "symbol": format_display_symbol(contract.symbol, exchange), - "ib_symbol": contract.symbol, - "secType": contract.secType, - "exchange": exchange, - "currency": contract.currency, - "quantity": float(pos.position), - "avgCost": float(pos.avgCost), - "marketValue": float(pos.position) * float(pos.avgCost), - }) - + + result.append( + { + "symbol": format_display_symbol(contract.symbol, exchange), + "ib_symbol": contract.symbol, + "secType": contract.secType, + "exchange": exchange, + "currency": contract.currency, + "quantity": float(pos.position), + "avgCost": float(pos.avgCost), + "marketValue": float(pos.position) * float(pos.avgCost), + } + ) + return result - + except Exception as e: logger.error(f"Get positions failed: {e}") return [] - + def get_open_orders(self) -> List[Dict[str, Any]]: """ Get open orders. - + Returns: List of orders """ try: self._ensure_connected() - + trades = self._ib.openTrades() result = [] - + for trade in trades: order = trade.order contract = trade.contract status = trade.orderStatus - - result.append({ - "orderId": order.orderId, - "symbol": contract.symbol, - "action": order.action, - "quantity": float(order.totalQuantity), - "orderType": order.orderType, - "limitPrice": getattr(order, 'lmtPrice', None), - "status": status.status, - "filled": float(status.filled or 0), - "remaining": float(status.remaining or 0), - "avgFillPrice": float(status.avgFillPrice or 0), - }) - + + result.append( + { + "orderId": order.orderId, + "symbol": contract.symbol, + "action": order.action, + "quantity": float(order.totalQuantity), + "orderType": order.orderType, + "limitPrice": getattr(order, "lmtPrice", None), + "status": status.status, + "filled": float(status.filled or 0), + "remaining": float(status.remaining or 0), + "avgFillPrice": float(status.avgFillPrice or 0), + } + ) + return result - + except Exception as e: logger.error(f"Get orders failed: {e}") return [] - + def get_quote(self, symbol: str, market_type: str = "USStock") -> Dict[str, Any]: """ Get real-time quote. - + Args: symbol: Symbol code market_type: Market type - + Returns: Quote data """ try: self._ensure_connected() - + contract = self._create_contract(symbol, market_type) if not self._qualify_contract(contract): return {"success": False, "error": f"Invalid contract: {symbol}"} - + # Request market data - ticker = self._ib.reqMktData(contract, '', False, False) - + ticker = self._ib.reqMktData(contract, "", False, False) + # Wait for data self._ib.sleep(2) - + result = { "success": True, "symbol": symbol, @@ -493,16 +475,16 @@ class IBKRClient: "volume": ticker.volume if ticker.volume and ticker.volume > 0 else None, "close": ticker.close if ticker.close and ticker.close > 0 else None, } - + # Cancel subscription self._ib.cancelMktData(contract) - + return result - + except Exception as e: logger.error(f"Get quote failed: {e}") return {"success": False, "error": str(e)} - + def get_connection_status(self) -> Dict[str, Any]: """Get connection status.""" return { @@ -523,15 +505,15 @@ _global_lock = threading.Lock() def get_ibkr_client(config: Optional[IBKRConfig] = None) -> IBKRClient: """ Get global IBKR client singleton. - + Args: config: Configuration (only effective on first call) - + Returns: IBKRClient instance """ global _global_client - + with _global_lock: if _global_client is None: _global_client = IBKRClient(config) @@ -541,7 +523,7 @@ def get_ibkr_client(config: Optional[IBKRConfig] = None) -> IBKRClient: def reset_ibkr_client(): """Reset global client (disconnect and clear instance).""" global _global_client - + with _global_lock: if _global_client is not None: _global_client.disconnect() diff --git a/backend_api_python/app/services/ibkr_trading/symbols.py b/backend_api_python/app/services/ibkr_trading/symbols.py index 6f6a089..ad84ef9 100644 --- a/backend_api_python/app/services/ibkr_trading/symbols.py +++ b/backend_api_python/app/services/ibkr_trading/symbols.py @@ -4,28 +4,28 @@ Symbol Mapping and Conversion Converts QuantDinger system symbols to IB contract format. """ -from typing import Tuple, Optional +from typing import Optional, Tuple def normalize_symbol(symbol: str, market_type: str) -> Tuple[str, str, str]: """ Convert system symbol to IB contract parameters. - + Args: symbol: Symbol code in the system market_type: Market type (USStock) - + Returns: (ib_symbol, exchange, currency) """ symbol = (symbol or "").strip().upper() market_type = (market_type or "").strip() - + if market_type == "USStock": # US stocks: AAPL, TSLA, GOOGL # Use SMART routing for best execution return symbol, "SMART", "USD" - + else: # Default to US stock return symbol, "SMART", "USD" @@ -34,15 +34,15 @@ def normalize_symbol(symbol: str, market_type: str) -> Tuple[str, str, str]: def parse_symbol(symbol: str) -> Tuple[str, Optional[str]]: """ Parse symbol and auto-detect market type. - + Args: symbol: Symbol code - + Returns: (clean_symbol, market_type) """ symbol = (symbol or "").strip().upper() - + # Default to US stock return symbol, "USStock" @@ -50,11 +50,11 @@ def parse_symbol(symbol: str) -> Tuple[str, Optional[str]]: def format_display_symbol(ib_symbol: str, exchange: str) -> str: """ Convert IB contract format back to display format. - + Args: ib_symbol: IB symbol exchange: Exchange code - + Returns: Display symbol """ diff --git a/backend_api_python/app/services/indicator_params.py b/backend_api_python/app/services/indicator_params.py index 442cfdb..bac5662 100644 --- a/backend_api_python/app/services/indicator_params.py +++ b/backend_api_python/app/services/indicator_params.py @@ -14,29 +14,31 @@ Parameter declaration format: Supported types: int, float, bool, str """ +from __future__ import annotations + import re -import json -from typing import Dict, Any, List, Optional, Tuple -from app.utils.logger import get_logger +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + from app.utils.db import get_db_connection +from app.utils.logger import get_logger + +if TYPE_CHECKING: + import pandas as pd logger = get_logger(__name__) class IndicatorParamsParser: """Parsing parameter declarations in indicator code""" - + # Parameter declaration rules: # @param name type default description - PARAM_PATTERN = re.compile( - r'#\s*@param\s+(\w+)\s+(int|float|bool|str|string)\s+(\S+)\s*(.*)', - re.IGNORECASE - ) - + PARAM_PATTERN = re.compile(r"#\s*@param\s+(\w+)\s+(int|float|bool|str|string)\s+(\S+)\s*(.*)", re.IGNORECASE) + @classmethod def parse_params(cls, indicator_code: str) -> List[Dict[str, Any]]: """ Parsing parameter declarations in indicator code - + Returns: List of param definitions: [ @@ -52,219 +54,239 @@ class IndicatorParamsParser: params = [] if not indicator_code: return params - - for line in indicator_code.split('\n'): + + for line in indicator_code.split("\n"): line = line.strip() match = cls.PARAM_PATTERN.match(line) if match: name = match.group(1) param_type = match.group(2).lower() default_str = match.group(3) - description = match.group(4).strip() if match.group(4) else '' - + description = match.group(4).strip() if match.group(4) else "" + # Convert default value type default = cls._convert_value(default_str, param_type) - + # Canonical type name - if param_type == 'string': - param_type = 'str' - - params.append({ - "name": name, - "type": param_type, - "default": default, - "description": description - }) - + if param_type == "string": + param_type = "str" + + params.append({"name": name, "type": param_type, "default": default, "description": description}) + return params - + @classmethod def _convert_value(cls, value_str: str, param_type: str) -> Any: """Convert string value to corresponding type""" try: param_type = param_type.lower() - if param_type == 'int': + if param_type == "int": return int(value_str) - elif param_type == 'float': + elif param_type == "float": return float(value_str) - elif param_type == 'bool': - return value_str.lower() in ('true', '1', 'yes', 'on') + elif param_type == "bool": + return value_str.lower() in ("true", "1", "yes", "on") else: # str/string return value_str except (ValueError, TypeError): return value_str - + @classmethod def merge_params(cls, declared_params: List[Dict], user_params: Dict[str, Any]) -> Dict[str, Any]: """ Merge declared parameters with user-supplied parameters - + Args: declared_params: parameter declarations parsed from code user_params: user-provided parameter values - + Returns: Merged parameter dictionary (using user values ​​or default values) """ result = {} for param in declared_params: - name = param['name'] - param_type = param['type'] - default = param['default'] - + name = param["name"] + param_type = param["type"] + default = param["default"] + if name in user_params: # User supplied value, converted to correct type result[name] = cls._convert_value(str(user_params[name]), param_type) else: # Use default value result[name] = default - + return result class IndicatorCaller: """ Indicator caller - allows one indicator to call another indicator - + Usage (in indicator code): # Call by ID rsi_df = call_indicator(5, df) - + # Call by name (own indicator) macd_df = call_indicator('My MACD', df) """ - + # Maximum call depth to prevent circular dependencies MAX_CALL_DEPTH = 5 - + def __init__(self, user_id: int, current_indicator_id: int = None): self.user_id = user_id self.current_indicator_id = current_indicator_id self._call_stack = [] # Call stack for detecting circular dependencies - + def call_indicator( - self, + self, indicator_ref: Any, # int (ID) or str (name) - df: 'pd.DataFrame', + df: pd.DataFrame, params: Dict[str, Any] = None, - _depth: int = 0 - ) -> Optional['pd.DataFrame']: + _depth: int = 0, + ) -> Optional[pd.DataFrame]: """ Call another indicator and return the result - + Args: indicator_ref: indicator ID or name df: input K-line data params: parameters passed to the called indicator _depth: used internally to track call depth - + Returns: DataFrame after execution, containing columns calculated by the called indicator """ - import pandas as pd import numpy as np - + import pandas as pd + # Check call depth if _depth >= self.MAX_CALL_DEPTH: logger.error(f"Indicator call depth exceeded {self.MAX_CALL_DEPTH}") return df.copy() - + # Get indicator code indicator_code, indicator_id = self._get_indicator_code(indicator_ref) if not indicator_code: logger.warning(f"Indicator not found: {indicator_ref}") return df.copy() - + # Check for circular dependencies if indicator_id in self._call_stack: logger.error(f"Circular dependency detected: {self._call_stack} -> {indicator_id}") return df.copy() - + self._call_stack.append(indicator_id) - + try: # Parse and merge parameters declared_params = IndicatorParamsParser.parse_params(indicator_code) merged_params = IndicatorParamsParser.merge_params(declared_params, params or {}) - + # Prepare execution environment df_copy = df.copy() local_vars = { - 'df': df_copy, - 'open': df_copy['open'].astype('float64') if 'open' in df_copy.columns else pd.Series(dtype='float64'), - 'high': df_copy['high'].astype('float64') if 'high' in df_copy.columns else pd.Series(dtype='float64'), - 'low': df_copy['low'].astype('float64') if 'low' in df_copy.columns else pd.Series(dtype='float64'), - 'close': df_copy['close'].astype('float64') if 'close' in df_copy.columns else pd.Series(dtype='float64'), - 'volume': df_copy['volume'].astype('float64') if 'volume' in df_copy.columns else pd.Series(dtype='float64'), - 'signals': pd.Series(0, index=df_copy.index, dtype='float64'), - 'np': np, - 'pd': pd, - 'params': merged_params, + "df": df_copy, + "open": df_copy["open"].astype("float64") if "open" in df_copy.columns else pd.Series(dtype="float64"), + "high": df_copy["high"].astype("float64") if "high" in df_copy.columns else pd.Series(dtype="float64"), + "low": df_copy["low"].astype("float64") if "low" in df_copy.columns else pd.Series(dtype="float64"), + "close": df_copy["close"].astype("float64") + if "close" in df_copy.columns + else pd.Series(dtype="float64"), + "volume": df_copy["volume"].astype("float64") + if "volume" in df_copy.columns + else pd.Series(dtype="float64"), + "signals": pd.Series(0, index=df_copy.index, dtype="float64"), + "np": np, + "pd": pd, + "params": merged_params, # Recursive call support - 'call_indicator': lambda ref, d, p=None: self.call_indicator(ref, d, p, _depth + 1) + "call_indicator": lambda ref, d, p=None: self.call_indicator(ref, d, p, _depth + 1), } - + # Safe execution import builtins + def safe_import(name, *args, **kwargs): - allowed_modules = ['numpy', 'pandas', 'math', 'json', 'time'] - if name in allowed_modules or name.split('.')[0] in allowed_modules: + allowed_modules = ["numpy", "pandas", "math", "json", "time"] + if name in allowed_modules or name.split(".")[0] in allowed_modules: return builtins.__import__(name, *args, **kwargs) raise ImportError(f"Module not allowed: {name}") - - safe_builtins = {k: getattr(builtins, k) for k in dir(builtins) - if not k.startswith('_') and k not in [ - 'eval', 'exec', 'compile', 'open', 'input', - 'help', 'exit', 'quit', '__import__', - 'copyright', 'credits', 'license' - ]} - safe_builtins['__import__'] = safe_import - + + safe_builtins = { + k: getattr(builtins, k) + for k in dir(builtins) + if not k.startswith("_") + and k + not in [ + "eval", + "exec", + "compile", + "open", + "input", + "help", + "exit", + "quit", + "__import__", + "copyright", + "credits", + "license", + ] + } + safe_builtins["__import__"] = safe_import + exec_env = local_vars.copy() - exec_env['__builtins__'] = safe_builtins - + exec_env["__builtins__"] = safe_builtins + pre_import = "import numpy as np\nimport pandas as pd\n" exec(pre_import, exec_env) exec(indicator_code, exec_env) - - return exec_env.get('df', df_copy) - + + return exec_env.get("df", df_copy) + except Exception as e: logger.error(f"Error calling indicator {indicator_ref}: {e}") return df.copy() finally: self._call_stack.pop() - + def _get_indicator_code(self, indicator_ref: Any) -> Tuple[Optional[str], Optional[int]]: """Get indicator code""" try: with get_db_connection() as db: cursor = db.cursor() - + if isinstance(indicator_ref, int): # Query by ID - cursor.execute(""" - SELECT id, code FROM qd_indicator_codes + cursor.execute( + """ + SELECT id, code FROM qd_indicator_codes WHERE id = %s AND (user_id = %s OR publish_to_community = 1) - """, (indicator_ref, self.user_id)) + """, + (indicator_ref, self.user_id), + ) else: # Query by name (priority to own indicators) - cursor.execute(""" - SELECT id, code FROM qd_indicator_codes + cursor.execute( + """ + SELECT id, code FROM qd_indicator_codes WHERE name = %s AND user_id = %s UNION - SELECT id, code FROM qd_indicator_codes + SELECT id, code FROM qd_indicator_codes WHERE name = %s AND publish_to_community = 1 LIMIT 1 - """, (str(indicator_ref), self.user_id, str(indicator_ref))) - + """, + (str(indicator_ref), self.user_id, str(indicator_ref)), + ) + row = cursor.fetchone() cursor.close() - + if row: - return row['code'], row['id'] + return row["code"], row["id"] return None, None - + except Exception as e: logger.error(f"Error fetching indicator code: {e}") return None, None @@ -273,10 +295,10 @@ class IndicatorCaller: def get_indicator_params(indicator_id: int) -> List[Dict[str, Any]]: """ Get the indicator parameter declarations for API usage - + Args: indicator_id: Indicator ID - + Returns: A list of parameter declarations """ @@ -286,9 +308,9 @@ def get_indicator_params(indicator_id: int) -> List[Dict[str, Any]]: cursor.execute("SELECT code FROM qd_indicator_codes WHERE id = %s", (indicator_id,)) row = cursor.fetchone() cursor.close() - - if row and row['code']: - return IndicatorParamsParser.parse_params(row['code']) + + if row and row["code"]: + return IndicatorParamsParser.parse_params(row["code"]) return [] except Exception as e: logger.error(f"Error getting indicator params: {e}") diff --git a/backend_api_python/app/services/kline.py b/backend_api_python/app/services/kline.py index 618c9e3..bf889e6 100644 --- a/backend_api_python/app/services/kline.py +++ b/backend_api_python/app/services/kline.py @@ -1,41 +1,37 @@ """ K-line data service """ -from typing import Dict, List, Any, Optional +from typing import Any, Dict, List, Optional + +from app.config import CacheConfig from app.data_sources import DataSourceFactory from app.utils.cache import CacheManager from app.utils.logger import get_logger -from app.config import CacheConfig logger = get_logger(__name__) class KlineService: """K-line data service""" - + def __init__(self): self.cache = CacheManager() self.cache_ttl = CacheConfig.KLINE_CACHE_TTL - + def get_kline( - self, - market: str, - symbol: str, - timeframe: str, - limit: int = 300, - before_time: Optional[int] = None + self, market: str, symbol: str, timeframe: str, limit: int = 300, before_time: Optional[int] = None ) -> List[Dict[str, Any]]: """ Get K-line data - + Args: market: market type (Crypto, USStock, Forex, Futures) symbol: trading pair/stock code timeframe: time period limit: number of data items before_time: Get data before this time - + Returns: K-line data list """ @@ -46,40 +42,36 @@ class KlineService: if cached: # logger.info(f"Hit cache: {cache_key}") return cached - + # Get data klines = DataSourceFactory.get_kline( - market=market, - symbol=symbol, - timeframe=timeframe, - limit=limit, - before_time=before_time + market=market, symbol=symbol, timeframe=timeframe, limit=limit, before_time=before_time ) - + # Set cache (latest data only) if klines and not before_time: ttl = self.cache_ttl.get(timeframe, 300) self.cache.set(cache_key, klines, ttl) # logger.info(f"Cache settings: {cache_key}, TTL: {ttl}s") - + return klines - + def get_latest_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]: """Get the latest price (use 1-minute K-line, deprecated, it is recommended to use get_realtime_price)""" - klines = self.get_kline(market, symbol, '1m', 1) + klines = self.get_kline(market, symbol, "1m", 1) if klines: return klines[-1] return None - + def get_realtime_price(self, market: str, symbol: str, force_refresh: bool = False) -> Dict[str, Any]: """ Get real-time prices (priority to use ticker API, downgrade to minute K-line) - + Args: market: market type (Crypto, USStock, Forex, Futures) symbol: trading pair/stock code force_refresh: whether to force refresh (skip cache) - + Returns: Real-time price data: { 'price': latest price, @@ -94,97 +86,96 @@ class KlineService: """ # Build a cache key (short-term cache to avoid frequent requests) cache_key = f"realtime_price:{market}:{symbol}" - + # If it is not a forced refresh, try using caching if not force_refresh: cached = self.cache.get(cache_key) if cached: return cached - + result = { - 'price': 0, - 'change': 0, - 'changePercent': 0, - 'high': 0, - 'low': 0, - 'open': 0, - 'previousClose': 0, - 'source': 'unknown' + "price": 0, + "change": 0, + "changePercent": 0, + "high": 0, + "low": 0, + "open": 0, + "previousClose": 0, + "source": "unknown", } - + # First try to use the ticker API to get real-time prices try: ticker = DataSourceFactory.get_ticker(market, symbol) - if ticker and ticker.get('last', 0) > 0: + if ticker and ticker.get("last", 0) > 0: result = { - 'price': ticker.get('last', 0), - 'change': ticker.get('change', 0), - 'changePercent': ticker.get('changePercent', 0), - 'high': ticker.get('high', 0), - 'low': ticker.get('low', 0), - 'open': ticker.get('open', 0), - 'previousClose': ticker.get('previousClose', 0), - 'source': 'ticker' + "price": ticker.get("last", 0), + "change": ticker.get("change", 0), + "changePercent": ticker.get("changePercent", 0), + "high": ticker.get("high", 0), + "low": ticker.get("low", 0), + "open": ticker.get("open", 0), + "previousClose": ticker.get("previousClose", 0), + "source": "ticker", } # Cache for 30 seconds self.cache.set(cache_key, result, 30) return result except Exception as e: logger.debug(f"Ticker API failed for {market}:{symbol}, falling back to kline: {e}") - + # Downgrade: Use 1 minute candlestick try: - klines = self.get_kline(market, symbol, '1m', 2) + klines = self.get_kline(market, symbol, "1m", 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) - + 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_pct = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0 - + result = { - 'price': current_price, - 'change': change, - 'changePercent': change_pct, - 'high': latest.get('high', 0), - 'low': latest.get('low', 0), - 'open': latest.get('open', 0), - 'previousClose': prev_close, - 'source': 'kline_1m' + "price": current_price, + "change": change, + "changePercent": change_pct, + "high": latest.get("high", 0), + "low": latest.get("low", 0), + "open": latest.get("open", 0), + "previousClose": prev_close, + "source": "kline_1m", } # Cache for 30 seconds self.cache.set(cache_key, result, 30) return result except Exception as e: logger.debug(f"1m kline failed for {market}:{symbol}, trying daily: {e}") - + # Last downgrade: using daily data (applies to non-trading hours) try: - klines = self.get_kline(market, symbol, '1D', 2) + klines = self.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) - + 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_pct = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0 - + result = { - 'price': current_price, - 'change': change, - 'changePercent': change_pct, - 'high': latest.get('high', 0), - 'low': latest.get('low', 0), - 'open': latest.get('open', 0), - 'previousClose': prev_close, - 'source': 'kline_1d' + "price": current_price, + "change": change, + "changePercent": change_pct, + "high": latest.get("high", 0), + "low": latest.get("low", 0), + "open": latest.get("open", 0), + "previousClose": prev_close, + "source": "kline_1d", } # Daily data cache for 5 minutes self.cache.set(cache_key, result, 300) return result except Exception as e: logger.error(f"All price sources failed for {market}:{symbol}: {e}") - - return result + return result diff --git a/backend_api_python/app/services/live_trading/__init__.py b/backend_api_python/app/services/live_trading/__init__.py index 7a8ed77..36a98d3 100644 --- a/backend_api_python/app/services/live_trading/__init__.py +++ b/backend_api_python/app/services/live_trading/__init__.py @@ -3,5 +3,3 @@ Live trading (direct exchange REST) clients. This package intentionally does NOT use ccxt. """ - - diff --git a/backend_api_python/app/services/live_trading/base.py b/backend_api_python/app/services/live_trading/base.py index 37ecda7..4fdfbf6 100644 --- a/backend_api_python/app/services/live_trading/base.py +++ b/backend_api_python/app/services/live_trading/base.py @@ -149,5 +149,3 @@ class BaseRestClient: @staticmethod def _json_dumps(obj: Any) -> str: return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) - - diff --git a/backend_api_python/app/services/live_trading/binance.py b/backend_api_python/app/services/live_trading/binance.py index 44aba80..0d40c97 100644 --- a/backend_api_python/app/services/live_trading/binance.py +++ b/backend_api_python/app/services/live_trading/binance.py @@ -7,10 +7,10 @@ API docs (reference): from __future__ import annotations -import hmac import hashlib +import hmac import time -from decimal import Decimal, ROUND_DOWN +from decimal import ROUND_DOWN, Decimal from typing import Any, Dict, Optional, Tuple from urllib.parse import urlencode @@ -19,7 +19,16 @@ from app.services.live_trading.symbols import to_binance_futures_symbol class BinanceFuturesClient(BaseRestClient): - def __init__(self, *, api_key: str, secret_key: str, base_url: str = None, enable_demo_trading: bool = False, timeout_sec: float = 15.0, broker_id: str = ""): + def __init__( + self, + *, + api_key: str, + secret_key: str, + base_url: str = None, + enable_demo_trading: bool = False, + timeout_sec: float = 15.0, + broker_id: str = "", + ): if not base_url: base_url = "https://demo-fapi.binance.com" if enable_demo_trading else "https://fapi.binance.com" @@ -57,7 +66,7 @@ class BinanceFuturesClient(BaseRestClient): Convert Decimal to string with controlled precision. Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision. This method ensures the output string doesn't exceed the required precision. - + Args: d: Decimal value to format max_decimals: Maximum decimal places (fallback if strict_precision not provided) @@ -68,7 +77,7 @@ class BinanceFuturesClient(BaseRestClient): return "0" # Normalize to remove unnecessary trailing zeros from internal representation normalized = d.normalize() - + # If strict_precision is provided, use it and strictly limit decimal places # This ensures we match the stepSize requirement exactly if strict_precision is not None: @@ -84,18 +93,18 @@ class BinanceFuturesClient(BaseRestClient): # Format with exact precision - this will produce at most 'prec' decimal places s = format(quantized, f".{prec}f") # Remove trailing zeros and decimal point if not needed - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass - + # Fallback to original logic if strict_precision not provided or failed # Convert to string using fixed-point notation s = format(normalized, f".{max_decimals}f") # Remove trailing zeros and decimal point if not needed - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: # Fallback: try to convert safely @@ -108,21 +117,21 @@ class BinanceFuturesClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass # Format with max_decimals and remove trailing zeros s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: # Last resort: convert to string s = str(d) # Try to remove scientific notation if present - if 'e' in s.lower() or 'E' in s: + if "e" in s.lower() or "E" in s: try: f = float(s) if strict_precision is not None: @@ -130,14 +139,14 @@ class BinanceFuturesClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") except Exception: pass return s if s else "0" @@ -360,7 +369,7 @@ class BinanceFuturesClient(BaseRestClient): def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Tuple[Decimal, Optional[int]]: """ Normalize futures order quantity using LOT_SIZE / MARKET_LOT_SIZE filters (best-effort). - + Returns: Tuple of (normalized_quantity, precision) where precision is the number of decimal places required. """ @@ -381,7 +390,7 @@ class BinanceFuturesClient(BaseRestClient): if step > 0: q = self._floor_to_step(q, step) - + # Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111). # First try to get precision from metadata qty_precision = None @@ -391,7 +400,7 @@ class BinanceFuturesClient(BaseRestClient): qty_precision = meta.get("quantityPrecision") except Exception: pass - + # If precision not available, infer from stepSize if qty_precision is None and step > 0: try: @@ -399,9 +408,9 @@ class BinanceFuturesClient(BaseRestClient): # Use normalize() to remove trailing zeros, then count decimal places step_normalized = step.normalize() step_str = str(step_normalized) - if '.' in step_str: + if "." in step_str: # Count decimal places after removing trailing zeros - decimal_part = step_str.split('.')[1] + decimal_part = step_str.split(".")[1] qty_precision = len(decimal_part) # Ensure precision is at least 0 and at most 18 if qty_precision < 0: @@ -413,11 +422,11 @@ class BinanceFuturesClient(BaseRestClient): qty_precision = 0 except Exception: pass - + # Apply precision limit if qty_precision is not None: q = self._floor_to_precision(q, qty_precision) - + if min_qty > 0 and q < min_qty: return (Decimal("0"), qty_precision) return (q, qty_precision) @@ -539,7 +548,7 @@ class BinanceFuturesClient(BaseRestClient): s = str(err or "") except Exception: s = "" - return f'\"code\":{int(code)}' in s or f"'code': {int(code)}" in s or f"'code':{int(code)}" in s + return f'"code":{int(code)}' in s or f"'code': {int(code)}" in s or f"'code':{int(code)}" in s @staticmethod def _normalize_position_side(pos_side: Optional[str]) -> str: @@ -613,7 +622,9 @@ class BinanceFuturesClient(BaseRestClient): while True: try: - last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) + last = self.get_order( + symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "") + ) except Exception: last = last or {} @@ -712,7 +723,11 @@ class BinanceFuturesClient(BaseRestClient): dual_side = self.get_dual_side_position() pos_norm = self._normalize_position_side(position_side) if dual_side is True: - params["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only)) + params["positionSide"] = ( + pos_norm + if pos_norm in ("LONG", "SHORT") + else self._infer_position_side(side=sd, reduce_only=reduce_only) + ) elif dual_side is False: # Keep default (BOTH) by omitting positionSide. params.pop("positionSide", None) @@ -748,7 +763,11 @@ class BinanceFuturesClient(BaseRestClient): pass else: # Likely hedge mode; retry with inferred positionSide. - params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only)) + params2["positionSide"] = ( + pos_norm + if pos_norm in ("LONG", "SHORT") + else self._infer_position_side(side=sd, reduce_only=reduce_only) + ) params2.pop("reduceOnly", None) try: raw = self._signed_request("POST", "/fapi/v1/order", params=params2) @@ -853,7 +872,11 @@ class BinanceFuturesClient(BaseRestClient): dual_side = self.get_dual_side_position() pos_norm = self._normalize_position_side(position_side) if dual_side is True: - params["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only)) + params["positionSide"] = ( + pos_norm + if pos_norm in ("LONG", "SHORT") + else self._infer_position_side(side=sd, reduce_only=reduce_only) + ) elif dual_side is False: params.pop("positionSide", None) else: @@ -881,7 +904,11 @@ class BinanceFuturesClient(BaseRestClient): except Exception: pass else: - params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only)) + params2["positionSide"] = ( + pos_norm + if pos_norm in ("LONG", "SHORT") + else self._infer_position_side(side=sd, reduce_only=reduce_only) + ) params2.pop("reduceOnly", None) try: raw = self._signed_request("POST", "/fapi/v1/order", params=params2) @@ -903,7 +930,9 @@ class BinanceFuturesClient(BaseRestClient): exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "") filled = float(raw.get("executedQty") or 0.0) avg_price = float(raw.get("avgPrice") or raw.get("price") or 0.0) - return LiveOrderResult(exchange_id="binance", exchange_order_id=exchange_order_id, filled=filled, avg_price=avg_price, raw=raw) + return LiveOrderResult( + exchange_id="binance", exchange_order_id=exchange_order_id, filled=filled, avg_price=avg_price, raw=raw + ) def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]: sym = to_binance_futures_symbol(symbol) @@ -952,5 +981,3 @@ class BinanceFuturesClient(BaseRestClient): return rows sym = to_binance_futures_symbol(want) return [p for p in rows if isinstance(p, dict) and str(p.get("symbol") or "") == sym] - - diff --git a/backend_api_python/app/services/live_trading/binance_spot.py b/backend_api_python/app/services/live_trading/binance_spot.py index 9fc65eb..4d0f1b0 100644 --- a/backend_api_python/app/services/live_trading/binance_spot.py +++ b/backend_api_python/app/services/live_trading/binance_spot.py @@ -4,10 +4,10 @@ Binance Spot (direct REST) client. from __future__ import annotations -import hmac import hashlib +import hmac import time -from decimal import Decimal, ROUND_DOWN +from decimal import ROUND_DOWN, Decimal from typing import Any, Dict, Optional, Tuple from urllib.parse import urlencode @@ -16,7 +16,16 @@ from app.services.live_trading.symbols import to_binance_futures_symbol class BinanceSpotClient(BaseRestClient): - def __init__(self, *, api_key: str, secret_key: str, base_url: str = None, enable_demo_trading: bool = False, timeout_sec: float = 15.0, broker_id: str = ""): + def __init__( + self, + *, + api_key: str, + secret_key: str, + base_url: str = None, + enable_demo_trading: bool = False, + timeout_sec: float = 15.0, + broker_id: str = "", + ): if not base_url: base_url = "https://demo-api.binance.com" if enable_demo_trading else "https://api.binance.com" @@ -47,7 +56,7 @@ class BinanceSpotClient(BaseRestClient): Convert Decimal to string with controlled precision. Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision. This method ensures the output string doesn't exceed the required precision. - + Args: d: Decimal value to format max_decimals: Maximum decimal places (fallback if strict_precision not provided) @@ -58,7 +67,7 @@ class BinanceSpotClient(BaseRestClient): return "0" # Normalize to remove unnecessary trailing zeros from internal representation normalized = d.normalize() - + # If strict_precision is provided, use it and strictly limit decimal places # This ensures we match the stepSize requirement exactly if strict_precision is not None: @@ -76,18 +85,18 @@ class BinanceSpotClient(BaseRestClient): s = format(quantized, f".{prec}f") # Remove trailing zeros and decimal point if not needed # This is safe because we've already quantized to the correct precision - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass - + # Fallback to original logic if strict_precision not provided or failed # Convert to string using fixed-point notation s = format(normalized, f".{max_decimals}f") # Remove trailing zeros and decimal point if not needed - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: # Fallback: try to convert safely @@ -103,21 +112,21 @@ class BinanceSpotClient(BaseRestClient): if prec > 18: prec = 18 s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass # Format with max_decimals and remove trailing zeros s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: # Last resort: convert to string s = str(d) # Try to remove scientific notation if present - if 'e' in s.lower() or 'E' in s: + if "e" in s.lower() or "E" in s: try: f = float(s) if strict_precision is not None: @@ -125,14 +134,14 @@ class BinanceSpotClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") except Exception: pass return s if s else "0" @@ -343,7 +352,7 @@ class BinanceSpotClient(BaseRestClient): def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Tuple[Decimal, Optional[int]]: """ Normalize spot order quantity using LOT_SIZE / MARKET_LOT_SIZE filters (best-effort). - + Returns: Tuple of (normalized_quantity, precision) where precision is the number of decimal places required. """ @@ -364,7 +373,7 @@ class BinanceSpotClient(BaseRestClient): if step > 0: q = self._floor_to_step(q, step) - + # Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111). # First try to get precision from metadata qty_precision = None @@ -374,7 +383,7 @@ class BinanceSpotClient(BaseRestClient): qty_precision = meta.get("quantityPrecision") except Exception: pass - + # If precision not available, infer from stepSize if qty_precision is None and step > 0: try: @@ -382,9 +391,9 @@ class BinanceSpotClient(BaseRestClient): # Use normalize() to remove trailing zeros, then count decimal places step_normalized = step.normalize() step_str = str(step_normalized) - if '.' in step_str: + if "." in step_str: # Count decimal places after removing trailing zeros - decimal_part = step_str.split('.')[1] + decimal_part = step_str.split(".")[1] qty_precision = len(decimal_part) # Ensure precision is at least 0 and at most 18 if qty_precision < 0: @@ -396,11 +405,11 @@ class BinanceSpotClient(BaseRestClient): qty_precision = 0 except Exception: pass - + # Apply precision limit if qty_precision is not None: q = self._floor_to_precision(q, qty_precision) - + if min_qty > 0 and q < min_qty: return (Decimal("0"), qty_precision) return (q, qty_precision) @@ -452,7 +461,9 @@ class BinanceSpotClient(BaseRestClient): exchange_id="binance", exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""), filled=float(raw.get("executedQty") or 0.0), - avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) if float(raw.get("executedQty") or 0.0) > 0 else 0.0, + avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) + if float(raw.get("executedQty") or 0.0) > 0 + else 0.0, raw=raw, ) @@ -493,7 +504,9 @@ class BinanceSpotClient(BaseRestClient): exchange_id="binance", exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""), filled=float(raw.get("executedQty") or 0.0), - avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) if float(raw.get("executedQty") or 0.0) > 0 else 0.0, + avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) + if float(raw.get("executedQty") or 0.0) > 0 + else 0.0, raw=raw, ) @@ -589,7 +602,9 @@ class BinanceSpotClient(BaseRestClient): last: Dict[str, Any] = {} while True: try: - last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) + last = self.get_order( + symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "") + ) except Exception: last = last or {} @@ -613,5 +628,3 @@ class BinanceSpotClient(BaseRestClient): if time.time() >= end_ts: return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} time.sleep(float(poll_interval_sec or 0.5)) - - diff --git a/backend_api_python/app/services/live_trading/bitfinex.py b/backend_api_python/app/services/live_trading/bitfinex.py index 46323af..fd45e21 100644 --- a/backend_api_python/app/services/live_trading/bitfinex.py +++ b/backend_api_python/app/services/live_trading/bitfinex.py @@ -19,12 +19,13 @@ import time from typing import Any, Dict, Optional from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError -from app.services.live_trading.symbols import to_bitfinex_spot_symbol -from app.services.live_trading.symbols import to_bitfinex_perp_symbol +from app.services.live_trading.symbols import to_bitfinex_perp_symbol, to_bitfinex_spot_symbol class BitfinexClient(BaseRestClient): - def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.bitfinex.com", timeout_sec: float = 15.0): + def __init__( + self, *, api_key: str, secret_key: str, base_url: str = "https://api.bitfinex.com", timeout_sec: float = 15.0 + ): super().__init__(base_url=base_url, timeout_sec=timeout_sec) self.api_key = (api_key or "").strip() self.secret_key = (secret_key or "").strip() @@ -40,14 +41,21 @@ class BitfinexClient(BaseRestClient): return hmac.new(self.secret_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha384).hexdigest() def _headers(self, nonce: str, sign: str) -> Dict[str, str]: - return {"bfx-apikey": self.api_key, "bfx-nonce": nonce, "bfx-signature": sign, "content-type": "application/json"} + return { + "bfx-apikey": self.api_key, + "bfx-nonce": nonce, + "bfx-signature": sign, + "content-type": "application/json", + } def _signed_request(self, method: str, path: str, *, json_body: Optional[Dict[str, Any]] = None) -> Any: m = str(method or "POST").upper() nonce = self._nonce() body_str = self._json_dumps(json_body) if json_body is not None else "" sign = self._sign(path, nonce, body_str) - code, data, text = self._request(m, path, params=None, data=body_str if body_str else None, headers=self._headers(nonce, sign)) + code, data, text = self._request( + m, path, params=None, data=body_str if body_str else None, headers=self._headers(nonce, sign) + ) if code >= 400: raise LiveTradingError(f"Bitfinex HTTP {code}: {text[:500]}") return data @@ -71,77 +79,9 @@ class BitfinexClient(BaseRestClient): """ return self._signed_request("POST", "/v2/auth/r/wallets", json_body={}) - -class BitfinexDerivativesClient(BitfinexClient): - """ - Bitfinex derivatives/perpetual client (best-effort). - - Differences vs spot: - - Symbol uses tBASEF0:QUOTEF0 (e.g. tBTCF0:USTF0) - - Order type typically uses MARKET/LIMIT (not EXCHANGE MARKET/LIMIT) - """ - - def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult: - sd = (side or "").strip().lower() - if sd not in ("buy", "sell"): - raise LiveTradingError(f"Invalid side: {side}") - qty = float(size or 0.0) - if qty <= 0: - raise LiveTradingError("Invalid size") - sym = to_bitfinex_perp_symbol(symbol) - amt = qty if sd == "buy" else -qty - body: Dict[str, Any] = {"type": "MARKET", "symbol": sym, "amount": str(amt)} - if client_order_id: - try: - cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0") - if cid > 0: - body["cid"] = cid - except Exception: - pass - raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body) - oid = "" - try: - if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]: - order = raw[3][0] - if isinstance(order, list) and order: - oid = str(order[0]) - except Exception: - oid = "" - return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw}) - - def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult: - sd = (side or "").strip().lower() - if sd not in ("buy", "sell"): - raise LiveTradingError(f"Invalid side: {side}") - qty = float(size or 0.0) - px = float(price or 0.0) - if qty <= 0 or px <= 0: - raise LiveTradingError("Invalid size/price") - sym = to_bitfinex_perp_symbol(symbol) - amt = qty if sd == "buy" else -qty - body: Dict[str, Any] = {"type": "LIMIT", "symbol": sym, "amount": str(amt), "price": str(px)} - if client_order_id: - try: - cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0") - if cid > 0: - body["cid"] = cid - except Exception: - pass - raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body) - oid = "" - try: - if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]: - order = raw[3][0] - if isinstance(order, list) and order: - oid = str(order[0]) - except Exception: - oid = "" - return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw}) - - def get_positions(self) -> Any: - return self._signed_request("POST", "/v2/auth/r/positions", json_body={}) - - def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + def place_market_order( + self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: sd = (side or "").strip().lower() if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") @@ -169,9 +109,13 @@ class BitfinexDerivativesClient(BitfinexClient): oid = str(order[0]) except Exception: oid = "" - return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw}) + return LiveOrderResult( + exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw} + ) - def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + def place_limit_order( + self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: sd = (side or "").strip().lower() if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") @@ -198,7 +142,9 @@ class BitfinexDerivativesClient(BitfinexClient): oid = str(order[0]) except Exception: oid = "" - return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw}) + return LiveOrderResult( + exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw} + ) def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any: if order_id: @@ -224,7 +170,9 @@ class BitfinexDerivativesClient(BitfinexClient): # Bitfinex v2 order status endpoint return self._signed_request("POST", f"/v2/auth/r/order/{oid}") - def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]: + def wait_for_fill( + self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5 + ) -> Dict[str, Any]: end_ts = time.time() + float(max_wait_sec or 0.0) last: Any = None while True: @@ -251,11 +199,108 @@ class BitfinexDerivativesClient(BitfinexClient): # Note: Bitfinex order response doesn't include fee; fee is typically in trades. # We return 0.0 here; actual fee can be fetched via trades endpoint if needed. if filled > 0 and avg_price > 0: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if isinstance(status, str) and ("EXECUTED" in status.upper() or "CANCELED" in status.upper()): - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } time.sleep(float(poll_interval_sec or 0.5)) +class BitfinexDerivativesClient(BitfinexClient): + """ + Bitfinex derivatives/perpetual client (best-effort). + + Differences vs spot: + - Symbol uses tBASEF0:QUOTEF0 (e.g. tBTCF0:USTF0) + - Order type typically uses MARKET/LIMIT (not EXCHANGE MARKET/LIMIT) + """ + + def place_market_order( + self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: + sd = (side or "").strip().lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + qty = float(size or 0.0) + if qty <= 0: + raise LiveTradingError("Invalid size") + sym = to_bitfinex_perp_symbol(symbol) + amt = qty if sd == "buy" else -qty + body: Dict[str, Any] = {"type": "MARKET", "symbol": sym, "amount": str(amt)} + if client_order_id: + try: + cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0") + if cid > 0: + body["cid"] = cid + except Exception: + pass + raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body) + oid = "" + try: + if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]: + order = raw[3][0] + if isinstance(order, list) and order: + oid = str(order[0]) + except Exception: + oid = "" + return LiveOrderResult( + exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw} + ) + + def place_limit_order( + self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: + sd = (side or "").strip().lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + qty = float(size or 0.0) + px = float(price or 0.0) + if qty <= 0 or px <= 0: + raise LiveTradingError("Invalid size/price") + sym = to_bitfinex_perp_symbol(symbol) + amt = qty if sd == "buy" else -qty + body: Dict[str, Any] = {"type": "LIMIT", "symbol": sym, "amount": str(amt), "price": str(px)} + if client_order_id: + try: + cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0") + if cid > 0: + body["cid"] = cid + except Exception: + pass + raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body) + oid = "" + try: + if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]: + order = raw[3][0] + if isinstance(order, list) and order: + oid = str(order[0]) + except Exception: + oid = "" + return LiveOrderResult( + exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw} + ) + + def get_positions(self) -> Any: + return self._signed_request("POST", "/v2/auth/r/positions", json_body={}) diff --git a/backend_api_python/app/services/live_trading/bitget.py b/backend_api_python/app/services/live_trading/bitget.py index a9c42b4..5aec2a7 100644 --- a/backend_api_python/app/services/live_trading/bitget.py +++ b/backend_api_python/app/services/live_trading/bitget.py @@ -11,7 +11,7 @@ import base64 import hashlib import hmac import time -from decimal import Decimal, ROUND_DOWN +from decimal import ROUND_DOWN, Decimal from typing import Any, Dict, Optional, Tuple from urllib.parse import urlencode @@ -77,7 +77,7 @@ class BitgetMixClient(BaseRestClient): """ Convert Decimal to string with controlled precision. Bitget requires quantities to match sizeStep/sizePlace precision. - + Args: d: Decimal value to format max_decimals: Maximum decimal places (fallback if strict_precision not provided) @@ -87,7 +87,7 @@ class BitgetMixClient(BaseRestClient): if d == 0: return "0" normalized = d.normalize() - + if strict_precision is not None: try: prec = int(strict_precision) @@ -95,15 +95,15 @@ class BitgetMixClient(BaseRestClient): q = Decimal("1").scaleb(-prec) quantized = normalized.quantize(q, rounding=ROUND_DOWN) s = format(quantized, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass - + s = format(normalized, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: try: @@ -115,18 +115,18 @@ class BitgetMixClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: s = str(d) - if 'e' in s.lower() or 'E' in s: + if "e" in s.lower() or "E" in s: try: f = float(s) if strict_precision is not None: @@ -134,14 +134,14 @@ class BitgetMixClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") except Exception: pass return s if s else "0" @@ -379,9 +379,7 @@ class BitgetMixClient(BaseRestClient): sd = (side or "").lower() if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") - pos_mode = self.get_account_pos_mode( - symbol=symbol, margin_coin=margin_coin, product_type=product_type - ) + pos_mode = self.get_account_pos_mode(symbol=symbol, margin_coin=margin_coin, product_type=product_type) hedge = pos_mode == "hedge_mode" if hedge: # Mirror CCXT: hedge close flips side; hedge open keeps side + tradeSide open. @@ -426,7 +424,7 @@ class BitgetMixClient(BaseRestClient): This system computes `amount` as base-asset quantity (e.g. BTC amount). Bitget mix `size` is typically in contracts; convert using contractSize if available, then align to size step / min trade number (best-effort). - + Returns: Tuple of (normalized_size, precision) where precision is the number of decimal places required. """ @@ -447,7 +445,9 @@ class BitgetMixClient(BaseRestClient): qty = req_base / ct # Determine step size. - step = self._to_dec(contract.get("sizeMultiplier") or contract.get("sizeStep") or contract.get("lotSize") or "0") + step = self._to_dec( + contract.get("sizeMultiplier") or contract.get("sizeStep") or contract.get("lotSize") or "0" + ) size_precision = None if step <= 0: sp = contract.get("sizePlace") @@ -466,8 +466,8 @@ class BitgetMixClient(BaseRestClient): try: step_normalized = step.normalize() step_str = str(step_normalized) - if '.' in step_str: - decimal_part = step_str.split('.')[1] + if "." in step_str: + decimal_part = step_str.split(".")[1] size_precision = len(decimal_part) if size_precision < 0: size_precision = 0 @@ -492,7 +492,9 @@ class BitgetMixClient(BaseRestClient): """ Private endpoint to validate credentials (best-effort). """ - return self._signed_request("GET", "/api/v2/mix/account/accounts", params={"productType": str(product_type or "USDT-FUTURES")}) + return self._signed_request( + "GET", "/api/v2/mix/account/accounts", params={"productType": str(product_type or "USDT-FUTURES")} + ) def get_positions(self, *, product_type: str = "USDT-FUTURES", symbol: str = "") -> Dict[str, Any]: """ @@ -515,10 +517,7 @@ class BitgetMixClient(BaseRestClient): data = resp.get("data") if not isinstance(data, list): return resp - filtered = [ - p for p in data - if isinstance(p, dict) and str(p.get("symbol") or "").strip().upper() == sym_key - ] + filtered = [p for p in data if isinstance(p, dict) and str(p.get("symbol") or "").strip().upper() == sym_key] out = dict(resp) out["data"] = filtered return out @@ -688,9 +687,13 @@ class BitgetMixClient(BaseRestClient): raw = self._post_mix_place_order(body, original_side=sd, reduce_only=reduce_only) data = raw.get("data") if isinstance(raw, dict) else None exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "") if isinstance(data, dict) else "" - return LiveOrderResult(exchange_id="bitget", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw) + return LiveOrderResult( + exchange_id="bitget", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw + ) - def cancel_order(self, *, symbol: str, product_type: str, margin_coin: str = "USDT", order_id: str = "", client_oid: str = "") -> Dict[str, Any]: + def cancel_order( + self, *, symbol: str, product_type: str, margin_coin: str = "USDT", order_id: str = "", client_oid: str = "" + ) -> Dict[str, Any]: body: Dict[str, Any] = { "symbol": to_bitget_um_symbol(symbol), "productType": str(product_type or "USDT-FUTURES"), @@ -771,7 +774,9 @@ class BitgetMixClient(BaseRestClient): ct = Decimal("0") try: contract = self.get_contract(symbol=symbol, product_type=product_type) or {} - ct = self._to_dec(contract.get("contractSize") or contract.get("contractSz") or contract.get("ctVal") or "0") + ct = self._to_dec( + contract.get("contractSize") or contract.get("contractSz") or contract.get("ctVal") or "0" + ) except Exception: ct = Decimal("0") @@ -839,17 +844,47 @@ class BitgetMixClient(BaseRestClient): d = last_detail.get("data") if isinstance(last_detail, dict) else None if isinstance(d, dict): state = str(d.get("state") or d.get("status") or "") - avg = float(d.get("priceAvg") or d.get("fillPrice") or 0.0) if (d.get("priceAvg") or d.get("fillPrice")) else 0.0 - filled = float(d.get("baseVolume") or d.get("filledQty") or 0.0) if (d.get("baseVolume") or d.get("filledQty")) else 0.0 + avg = ( + float(d.get("priceAvg") or d.get("fillPrice") or 0.0) + if (d.get("priceAvg") or d.get("fillPrice")) + else 0.0 + ) + filled = ( + float(d.get("baseVolume") or d.get("filledQty") or 0.0) + if (d.get("baseVolume") or d.get("filledQty")) + else 0.0 + ) if filled > 0 and avg > 0: - return {"filled": filled, "avg_price": avg, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills} + return { + "filled": filled, + "avg_price": avg, + "fee": 0.0, + "fee_ccy": "", + "state": state, + "detail": last_detail, + "fills": last_fills, + } if state in ("filled", "canceled", "cancelled"): - return {"filled": filled, "avg_price": avg, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills} + return { + "filled": filled, + "avg_price": avg, + "fee": 0.0, + "fee_ccy": "", + "state": state, + "detail": last_detail, + "fills": last_fills, + } except Exception: pass if time.time() >= end_ts: - return {"filled": 0.0, "avg_price": 0.0, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills} + return { + "filled": 0.0, + "avg_price": 0.0, + "fee": 0.0, + "fee_ccy": "", + "state": state, + "detail": last_detail, + "fills": last_fills, + } time.sleep(float(poll_interval_sec or 0.5)) - - diff --git a/backend_api_python/app/services/live_trading/bitget_spot.py b/backend_api_python/app/services/live_trading/bitget_spot.py index c7cfe6c..3f7e5fe 100644 --- a/backend_api_python/app/services/live_trading/bitget_spot.py +++ b/backend_api_python/app/services/live_trading/bitget_spot.py @@ -14,7 +14,7 @@ import base64 import hashlib import hmac import time -from decimal import Decimal, ROUND_DOWN +from decimal import ROUND_DOWN, Decimal from typing import Any, Dict, Optional, Tuple from urllib.parse import urlencode @@ -69,7 +69,7 @@ class BitgetSpotClient(BaseRestClient): """ Convert Decimal to string with controlled precision. Bitget requires quantities to match quantityStep/quantityScale precision. - + Args: d: Decimal value to format max_decimals: Maximum decimal places (fallback if strict_precision not provided) @@ -79,7 +79,7 @@ class BitgetSpotClient(BaseRestClient): if d == 0: return "0" normalized = d.normalize() - + if strict_precision is not None: try: prec = int(strict_precision) @@ -87,15 +87,15 @@ class BitgetSpotClient(BaseRestClient): q = Decimal("1").scaleb(-prec) quantized = normalized.quantize(q, rounding=ROUND_DOWN) s = format(quantized, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass - + s = format(normalized, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: try: @@ -107,18 +107,18 @@ class BitgetSpotClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: s = str(d) - if 'e' in s.lower() or 'E' in s: + if "e" in s.lower() or "E" in s: try: f = float(s) if strict_precision is not None: @@ -126,14 +126,14 @@ class BitgetSpotClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") except Exception: pass return s if s else "0" @@ -256,7 +256,7 @@ class BitgetSpotClient(BaseRestClient): def _normalize_base_size(self, *, symbol: str, base_size: float) -> Tuple[Decimal, Optional[int]]: """ Normalize spot base size to lot/step constraints (best-effort). - + Returns: Tuple of (normalized_size, precision) where precision is the number of decimal places required. """ @@ -271,7 +271,13 @@ class BitgetSpotClient(BaseRestClient): meta = {} # Try common fields. If unavailable, keep as-is. - step = self._to_dec(meta.get("quantityScale") or meta.get("quantityStep") or meta.get("sizeStep") or meta.get("minTradeIncrement") or "0") + step = self._to_dec( + meta.get("quantityScale") + or meta.get("quantityStep") + or meta.get("sizeStep") + or meta.get("minTradeIncrement") + or "0" + ) size_precision = None if step <= 0: # Some endpoints expose decimals instead of step. @@ -291,8 +297,8 @@ class BitgetSpotClient(BaseRestClient): try: step_normalized = step.normalize() step_str = str(step_normalized) - if '.' in step_str: - decimal_part = step_str.split('.')[1] + if "." in step_str: + decimal_part = step_str.split(".")[1] size_precision = len(decimal_part) if size_precision < 0: size_precision = 0 @@ -303,12 +309,16 @@ class BitgetSpotClient(BaseRestClient): except Exception: pass - mn = self._to_dec(meta.get("minTradeAmount") or meta.get("minTradeNum") or meta.get("minQty") or meta.get("minSize") or "0") + mn = self._to_dec( + meta.get("minTradeAmount") or meta.get("minTradeNum") or meta.get("minQty") or meta.get("minSize") or "0" + ) if mn > 0 and req < mn: return (Decimal("0"), size_precision) return (req, size_precision) - def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + def place_limit_order( + self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: sym = to_bitget_um_symbol(symbol) sd = (side or "").lower() if sd not in ("buy", "sell"): @@ -336,7 +346,9 @@ class BitgetSpotClient(BaseRestClient): order_id = str(data.get("orderId") or "") if isinstance(data, dict) else "" return LiveOrderResult(exchange_id="bitget", exchange_order_id=order_id, filled=0.0, avg_price=0.0, raw=raw) - def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + def place_market_order( + self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: """ NOTE: Bitget spot market BUY may expect quote amount. We accept `size` as base size, but the caller can also pass a quote-sized value if desired. @@ -438,7 +450,9 @@ class BitgetSpotClient(BaseRestClient): fee = float(fee_v or 0.0) except Exception: fee = 0.0 - ccy = str(f.get("feeCoin") or f.get("feeCcy") or f.get("fillFeeCoin") or f.get("fillFeeCcy") or "").strip() + ccy = str( + f.get("feeCoin") or f.get("feeCcy") or f.get("fillFeeCoin") or f.get("fillFeeCcy") or "" + ).strip() if fee != 0.0: total_fee += abs(float(fee)) if (not fee_ccy) and ccy: @@ -453,13 +467,15 @@ class BitgetSpotClient(BaseRestClient): "fee_ccy": str(fee_ccy or ""), "state": state, "order": last_order, - "fills": last_fills + "fills": last_fills, } except Exception: pass try: - last_order = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) + last_order = self.get_order( + symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "") + ) od = last_order.get("data") if isinstance(last_order, dict) else None if isinstance(od, dict): state = str(od.get("status") or od.get("state") or "") @@ -487,5 +503,3 @@ class BitgetSpotClient(BaseRestClient): if isinstance(data, dict): return data return {} - - diff --git a/backend_api_python/app/services/live_trading/bybit.py b/backend_api_python/app/services/live_trading/bybit.py index 5422895..1e70dc5 100644 --- a/backend_api_python/app/services/live_trading/bybit.py +++ b/backend_api_python/app/services/live_trading/bybit.py @@ -13,7 +13,7 @@ from __future__ import annotations import hashlib import hmac import time -from decimal import Decimal, ROUND_DOWN +from decimal import ROUND_DOWN, Decimal from typing import Any, Dict, Optional, Tuple from urllib.parse import urlencode @@ -79,7 +79,7 @@ class BybitClient(BaseRestClient): """ Convert Decimal to string with controlled precision. Bybit requires quantities to match qtyStep precision. - + Args: d: Decimal value to format max_decimals: Maximum decimal places (fallback if strict_precision not provided) @@ -89,7 +89,7 @@ class BybitClient(BaseRestClient): if d == 0: return "0" normalized = d.normalize() - + if strict_precision is not None: try: prec = int(strict_precision) @@ -97,15 +97,15 @@ class BybitClient(BaseRestClient): q = Decimal("1").scaleb(-prec) quantized = normalized.quantize(q, rounding=ROUND_DOWN) s = format(quantized, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass - + s = format(normalized, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: try: @@ -117,18 +117,18 @@ class BybitClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: s = str(d) - if 'e' in s.lower() or 'E' in s: + if "e" in s.lower() or "E" in s: try: f = float(s) if strict_precision is not None: @@ -136,14 +136,14 @@ class BybitClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") except Exception: pass return s if s else "0" @@ -414,7 +414,9 @@ class BybitClient(BaseRestClient): return {} def get_wallet_balance(self, *, account_type: str = "UNIFIED") -> Dict[str, Any]: - return self._signed_request("GET", "/v5/account/wallet-balance", params={"accountType": str(account_type or "UNIFIED")}) + return self._signed_request( + "GET", "/v5/account/wallet-balance", params={"accountType": str(account_type or "UNIFIED")} + ) def get_instrument_info(self, *, category: str, symbol: str) -> Dict[str, Any]: cat = str(category or self.category or "linear").strip().lower() @@ -449,15 +451,15 @@ class BybitClient(BaseRestClient): mn = self._to_dec((lot or {}).get("minOrderQty") or "0") if step > 0: q = self._floor_to_step(q, step) - + # Infer precision from qtyStep qty_precision = None if step > 0: try: step_normalized = step.normalize() step_str = str(step_normalized) - if '.' in step_str: - decimal_part = step_str.split('.')[1] + if "." in step_str: + decimal_part = step_str.split(".")[1] qty_precision = len(decimal_part) if qty_precision < 0: qty_precision = 0 @@ -467,7 +469,7 @@ class BybitClient(BaseRestClient): qty_precision = 0 except Exception: pass - + if mn > 0 and q < mn: return (Decimal("0"), qty_precision) return (q, qty_precision) @@ -627,7 +629,9 @@ class BybitClient(BaseRestClient): last: Dict[str, Any] = {} while True: try: - last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) + last = self.get_order( + symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "") + ) except Exception: last = last or {} status = str(last.get("orderStatus") or last.get("order_status") or "") @@ -666,11 +670,32 @@ class BybitClient(BaseRestClient): if fee > 0 and self.category == "linear": fee_ccy = "USDT" if filled > 0 and avg_price > 0: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if status.lower() in ("filled", "cancelled", "canceled", "rejected"): - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } time.sleep(float(poll_interval_sec or 0.5)) def get_positions( @@ -714,5 +739,3 @@ class BybitClient(BaseRestClient): return bool(ok) except Exception: return False - - diff --git a/backend_api_python/app/services/live_trading/coinbase_exchange.py b/backend_api_python/app/services/live_trading/coinbase_exchange.py index 47dc383..792855a 100644 --- a/backend_api_python/app/services/live_trading/coinbase_exchange.py +++ b/backend_api_python/app/services/live_trading/coinbase_exchange.py @@ -55,7 +55,14 @@ class CoinbaseExchangeClient(BaseRestClient): "Content-Type": "application/json", } - def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any: + def _signed_request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + ) -> Any: m = str(method or "GET").upper() ts = str(int(time.time())) body_str = self._json_dumps(json_body) if json_body is not None else "" @@ -75,7 +82,9 @@ class CoinbaseExchangeClient(BaseRestClient): signed_path = f"{path}?{'&'.join(items)}" prehash = f"{ts}{m}{signed_path}{body_str}" sign = self._sign(prehash) - code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts, sign)) + code, data, text = self._request( + m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts, sign) + ) if code >= 400: raise LiveTradingError(f"CoinbaseExchange HTTP {code}: {text[:500]}") return data @@ -96,7 +105,9 @@ class CoinbaseExchangeClient(BaseRestClient): def get_accounts(self) -> Any: return self._signed_request("GET", "/accounts") - def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + def place_market_order( + self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: sd = (side or "").strip().lower() if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") @@ -113,9 +124,17 @@ class CoinbaseExchangeClient(BaseRestClient): body["client_oid"] = str(client_order_id) raw = self._signed_request("POST", "/orders", json_body=body) oid = str(raw.get("id") or raw.get("order_id") or raw.get("client_oid") or "") - return LiveOrderResult(exchange_id="coinbaseexchange", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + return LiveOrderResult( + exchange_id="coinbaseexchange", + exchange_order_id=oid, + filled=0.0, + avg_price=0.0, + raw=raw if isinstance(raw, dict) else {"raw": raw}, + ) - def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + def place_limit_order( + self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: sd = (side or "").strip().lower() if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") @@ -135,7 +154,13 @@ class CoinbaseExchangeClient(BaseRestClient): body["client_oid"] = str(client_order_id) raw = self._signed_request("POST", "/orders", json_body=body) oid = str(raw.get("id") or raw.get("order_id") or raw.get("client_oid") or "") - return LiveOrderResult(exchange_id="coinbaseexchange", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + return LiveOrderResult( + exchange_id="coinbaseexchange", + exchange_order_id=oid, + filled=0.0, + avg_price=0.0, + raw=raw if isinstance(raw, dict) else {"raw": raw}, + ) def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any: if order_id: @@ -191,11 +216,30 @@ class CoinbaseExchangeClient(BaseRestClient): if fee > 0: fee_ccy = "USD" if filled > 0 and avg_price > 0: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if status.lower() in ("done", "rejected", "canceled", "cancelled"): - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } time.sleep(float(poll_interval_sec or 0.5)) - - diff --git a/backend_api_python/app/services/live_trading/deepcoin.py b/backend_api_python/app/services/live_trading/deepcoin.py index 79b30d4..95c43d0 100644 --- a/backend_api_python/app/services/live_trading/deepcoin.py +++ b/backend_api_python/app/services/live_trading/deepcoin.py @@ -18,9 +18,8 @@ import hashlib import hmac import json import time -from decimal import Decimal, ROUND_DOWN +from decimal import ROUND_DOWN, Decimal from typing import Any, Dict, Optional, Tuple -from urllib.parse import urlencode import requests @@ -31,11 +30,11 @@ from app.services.live_trading.symbols import to_deepcoin_symbol class DeepcoinClient(BaseRestClient): """ Deepcoin REST client for spot and perpetual swap trading. - + Based on official Deepcoin Python SDK. Supports both spot and swap (perpetual futures) markets. """ - + def __init__( self, *, @@ -53,7 +52,7 @@ class DeepcoinClient(BaseRestClient): self.market_type = (market_type or "swap").strip().lower() if self.market_type not in ("swap", "spot"): self.market_type = "swap" - + if not self.api_key or not self.secret_key: raise LiveTradingError("Missing Deepcoin api_key/secret_key") @@ -78,7 +77,7 @@ class DeepcoinClient(BaseRestClient): """ Convert Decimal to string with controlled precision. Deepcoin requires quantities to match lotSz/qtyStep precision. - + Args: d: Decimal value to format max_decimals: Maximum decimal places (fallback if strict_precision not provided) @@ -88,24 +87,25 @@ class DeepcoinClient(BaseRestClient): if d == 0: return "0" normalized = d.normalize() - + if strict_precision is not None: try: prec = int(strict_precision) if 0 <= prec <= 18: from decimal import ROUND_DOWN + q = Decimal("1").scaleb(-prec) quantized = normalized.quantize(q, rounding=ROUND_DOWN) s = format(quantized, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass - + s = format(normalized, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: try: @@ -117,18 +117,18 @@ class DeepcoinClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: s = str(d) - if 'e' in s.lower() or 'E' in s: + if "e" in s.lower() or "E" in s: try: f = float(s) if strict_precision is not None: @@ -136,14 +136,14 @@ class DeepcoinClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") except Exception: pass return s if s else "0" @@ -194,23 +194,23 @@ class DeepcoinClient(BaseRestClient): def _sign(self, iso_time: str, method: str, uri: str, data: Optional[Dict[str, Any]] = None) -> str: """ Generate HMAC-SHA256 signature for request authentication. - + For POST: message = timestamp + method + uri + json_body For GET: message = timestamp + method + uri (with query params) """ method_upper = method.upper() if method_upper == "POST" and data: # Convert dict to JSON string with double quotes - data_str = json.dumps(data, separators=(',', ':')) + data_str = json.dumps(data, separators=(",", ":")) message = f"{iso_time}{method_upper}{uri}{data_str}" else: message = f"{iso_time}{method_upper}{uri}" - - message_bytes = message.encode('utf-8') - key_bytes = self.secret_key.encode('utf-8') - sign = base64.b64encode( - hmac.new(key=key_bytes, msg=message_bytes, digestmod=hashlib.sha256).digest() - ).decode('utf-8') + + message_bytes = message.encode("utf-8") + key_bytes = self.secret_key.encode("utf-8") + sign = base64.b64encode(hmac.new(key=key_bytes, msg=message_bytes, digestmod=hashlib.sha256).digest()).decode( + "utf-8" + ) return sign def _headers(self, iso_time: str, sign: str) -> Dict[str, str]: @@ -233,16 +233,16 @@ class DeepcoinClient(BaseRestClient): """ full_uri = self._build_uri_with_params(uri, params, method) url = f"{self.base_url}{full_uri}" - + try: if method.upper() == "GET": resp = requests.get(url=url, timeout=self.timeout_sec) else: resp = requests.post(url=url, json=params, timeout=self.timeout_sec) - + if resp.status_code >= 400: raise LiveTradingError(f"Deepcoin HTTP {resp.status_code}: {resp.text[:500]}") - + data = resp.json() if isinstance(data, dict): code = data.get("code") or data.get("retCode") @@ -261,35 +261,35 @@ class DeepcoinClient(BaseRestClient): ) -> Dict[str, Any]: """ Make authenticated API request following Deepcoin signing spec. - + For GET requests: params are appended to URI as query string For POST requests: params are sent as JSON body """ iso_time = self._get_iso_time() method_upper = method.upper() - + # Build full URI (with query params for GET) full_uri = self._build_uri_with_params(uri, params, method) - + # Generate signature if method_upper == "POST": sign = self._sign(iso_time, method_upper, uri, params) else: sign = self._sign(iso_time, method_upper, full_uri, None) - + headers = self._headers(iso_time, sign) url = f"{self.base_url}{full_uri}" - + try: if method_upper == "POST": - body_str = json.dumps(params, separators=(',', ':')) if params else "" + body_str = json.dumps(params, separators=(",", ":")) if params else "" resp = requests.post(url=url, headers=headers, data=body_str, timeout=self.timeout_sec) else: resp = requests.get(url=url, headers=headers, timeout=self.timeout_sec) - + if resp.status_code >= 400: raise LiveTradingError(f"Deepcoin HTTP {resp.status_code}: {resp.text[:500]}") - + data = resp.json() if isinstance(data, dict): code = data.get("code") or data.get("retCode") @@ -314,7 +314,7 @@ class DeepcoinClient(BaseRestClient): def get_balance(self) -> Dict[str, Any]: """ Get account balance. - + Endpoint: GET /deepcoin/account/balances """ params = {"instType": "SWAP" if self.market_type == "swap" else "SPOT"} @@ -323,7 +323,7 @@ class DeepcoinClient(BaseRestClient): def get_positions(self, *, symbol: str = "") -> Dict[str, Any]: """ Get open positions. - + Endpoint: GET /deepcoin/account/positions """ params: Dict[str, Any] = {"instType": "SWAP" if self.market_type == "swap" else "SPOT"} @@ -334,20 +334,20 @@ class DeepcoinClient(BaseRestClient): def set_leverage(self, *, symbol: str, leverage: float, mgn_mode: str = "cross") -> bool: """ Set leverage for a trading pair. - + Endpoint: POST /deepcoin/account/set-leverage """ sym = to_deepcoin_symbol(symbol) if not sym: return False - + try: lv = int(float(leverage or 1.0)) except Exception: lv = 1 if lv < 1: lv = 1 - + mm = str(mgn_mode or "cross").strip().lower() if mm not in ("cross", "isolated"): mm = "cross" @@ -367,7 +367,7 @@ class DeepcoinClient(BaseRestClient): "mgnMode": mm, "mrgPosition": "merge", } - + try: self._signed_request("POST", "/deepcoin/account/set-leverage", params=params) self._lev_cache[cache_key] = (now, True) @@ -378,13 +378,13 @@ class DeepcoinClient(BaseRestClient): def get_instrument_info(self, *, symbol: str) -> Dict[str, Any]: """ Get instrument metadata (min qty, qty step, etc.). - + Endpoint: GET /deepcoin/market/instruments """ sym = to_deepcoin_symbol(symbol) if not sym: return {} - + key = f"{self.market_type}:{sym}" now = time.time() cached = self._inst_cache.get(key) @@ -395,7 +395,7 @@ class DeepcoinClient(BaseRestClient): inst_type = "SWAP" if self.market_type == "swap" else "SPOT" params = {"instType": inst_type, "instId": sym} - + try: raw = self._public_request("GET", "/deepcoin/market/instruments", params=params) data = (raw.get("data") or []) if isinstance(raw, dict) else [] @@ -409,35 +409,35 @@ class DeepcoinClient(BaseRestClient): def _normalize_qty(self, *, symbol: str, qty: float) -> Tuple[Decimal, Optional[int]]: """ Normalize order quantity to exchange requirements. - + Returns: Tuple of (normalized_quantity, precision) where precision is the number of decimal places required. """ q = self._to_dec(qty) if q <= 0: return (Decimal("0"), None) - + sym = to_deepcoin_symbol(symbol) try: info = self.get_instrument_info(symbol=sym) or {} except Exception: info = {} - + # Extract lot size filter step = self._to_dec(info.get("lotSz") or info.get("qtyStep") or "0") mn = self._to_dec(info.get("minSz") or info.get("minOrderQty") or "0") - + if step > 0: q = self._floor_to_step(q, step) - + # Infer precision from step qty_precision = None if step > 0: try: step_normalized = step.normalize() step_str = str(step_normalized) - if '.' in step_str: - decimal_part = step_str.split('.')[1] + if "." in step_str: + decimal_part = step_str.split(".")[1] qty_precision = len(decimal_part) if qty_precision < 0: qty_precision = 0 @@ -447,7 +447,7 @@ class DeepcoinClient(BaseRestClient): qty_precision = 0 except Exception: pass - + if mn > 0 and q < mn: return (Decimal("0"), qty_precision) return (q, qty_precision) @@ -464,9 +464,9 @@ class DeepcoinClient(BaseRestClient): ) -> LiveOrderResult: """ Place a market order. - + Endpoint: POST /deepcoin/trade/order - + Args: symbol: Trading pair (e.g., "BTC/USDT:USDT" or "BTCUSDT") side: "buy" or "sell" @@ -479,7 +479,7 @@ class DeepcoinClient(BaseRestClient): sd = (side or "").strip().lower() if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") - + q_req = float(qty or 0.0) q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req) if float(q_dec or 0) <= 0: @@ -492,7 +492,7 @@ class DeepcoinClient(BaseRestClient): "ordType": "market", "sz": self._dec_str(q_dec, strict_precision=qty_precision), } - + if self.market_type != "spot": ps = (pos_side or "").strip().lower() if ps in ("long", "short", "net"): @@ -507,7 +507,7 @@ class DeepcoinClient(BaseRestClient): data = (raw.get("data") or []) if isinstance(raw, dict) else [] first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {} oid = str(first.get("ordId") or first.get("orderId") or first.get("clOrdId") or "") - + return LiveOrderResult( exchange_id="deepcoin", exchange_order_id=oid, @@ -529,19 +529,19 @@ class DeepcoinClient(BaseRestClient): ) -> LiveOrderResult: """ Place a limit order. - + Endpoint: POST /deepcoin/trade/order """ sym = to_deepcoin_symbol(symbol) sd = (side or "").strip().lower() if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") - + q_req = float(qty or 0.0) px = float(price or 0.0) if q_req <= 0 or px <= 0: raise LiveTradingError("Invalid qty/price") - + q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req) if float(q_dec or 0) <= 0: raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}") @@ -554,7 +554,7 @@ class DeepcoinClient(BaseRestClient): "sz": self._dec_str(q_dec, strict_precision=qty_precision), "px": str(px), } - + if self.market_type != "spot": ps = (pos_side or "").strip().lower() if ps in ("long", "short", "net"): @@ -569,7 +569,7 @@ class DeepcoinClient(BaseRestClient): data = (raw.get("data") or []) if isinstance(raw, dict) else [] first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {} oid = str(first.get("ordId") or first.get("orderId") or first.get("clOrdId") or "") - + return LiveOrderResult( exchange_id="deepcoin", exchange_order_id=oid, @@ -581,12 +581,12 @@ class DeepcoinClient(BaseRestClient): def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]: """ Cancel an order. - + Endpoint: POST /deepcoin/trade/cancel-order """ sym = to_deepcoin_symbol(symbol) params: Dict[str, Any] = {"instId": sym} - + if order_id: params["ordId"] = str(order_id) elif client_order_id: @@ -599,12 +599,12 @@ class DeepcoinClient(BaseRestClient): def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]: """ Get order details. - + Endpoint: GET /deepcoin/trade/order """ sym = to_deepcoin_symbol(symbol) params: Dict[str, Any] = {"instId": sym} - + if order_id: params["ordId"] = str(order_id) elif client_order_id: @@ -620,7 +620,7 @@ class DeepcoinClient(BaseRestClient): def get_open_orders(self, *, symbol: str = "") -> Dict[str, Any]: """ Get open orders. - + Endpoint: GET /deepcoin/trade/orders-pending """ params: Dict[str, Any] = {"instType": "SWAP" if self.market_type == "swap" else "SPOT"} @@ -631,7 +631,7 @@ class DeepcoinClient(BaseRestClient): def get_order_history(self, *, symbol: str = "", limit: int = 100) -> Dict[str, Any]: """ Get order history. - + Endpoint: GET /deepcoin/trade/orders-history """ params: Dict[str, Any] = { @@ -653,7 +653,7 @@ class DeepcoinClient(BaseRestClient): ) -> Dict[str, Any]: """ Poll order status until filled or timeout. - + Returns: { "filled": float, @@ -666,7 +666,7 @@ class DeepcoinClient(BaseRestClient): """ end_ts = time.time() + float(max_wait_sec or 0.0) last: Dict[str, Any] = {} - + while True: try: last = self.get_order( @@ -676,19 +676,19 @@ class DeepcoinClient(BaseRestClient): ) except Exception: last = last or {} - + status = str(last.get("state") or last.get("status") or last.get("orderStatus") or "") - + try: filled = float(last.get("accFillSz") or last.get("fillSz") or last.get("cumExecQty") or 0.0) except Exception: filled = 0.0 - + try: avg_price = float(last.get("avgPx") or last.get("fillPx") or last.get("avgPrice") or 0.0) except Exception: avg_price = 0.0 - + # Extract fee fee = 0.0 fee_ccy = "" @@ -697,7 +697,7 @@ class DeepcoinClient(BaseRestClient): fee_ccy = str(last.get("feeCcy") or "") except Exception: pass - + if filled > 0 and avg_price > 0: return { "filled": filled, @@ -707,7 +707,7 @@ class DeepcoinClient(BaseRestClient): "status": status, "order": last, } - + if status.lower() in ("filled", "cancelled", "canceled", "rejected"): return { "filled": filled, @@ -717,7 +717,7 @@ class DeepcoinClient(BaseRestClient): "status": status, "order": last, } - + if time.time() >= end_ts: return { "filled": filled, @@ -727,5 +727,5 @@ class DeepcoinClient(BaseRestClient): "status": status, "order": last, } - + time.sleep(float(poll_interval_sec or 0.5)) diff --git a/backend_api_python/app/services/live_trading/execution.py b/backend_api_python/app/services/live_trading/execution.py index f7bc9d5..061b419 100644 --- a/backend_api_python/app/services/live_trading/execution.py +++ b/backend_api_python/app/services/live_trading/execution.py @@ -14,17 +14,16 @@ from typing import Any, Dict, Optional, Tuple from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError from app.services.live_trading.binance import BinanceFuturesClient from app.services.live_trading.binance_spot import BinanceSpotClient -from app.services.live_trading.okx import OkxClient +from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient from app.services.live_trading.bitget import BitgetMixClient from app.services.live_trading.bitget_spot import BitgetSpotClient from app.services.live_trading.bybit import BybitClient from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient +from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient from app.services.live_trading.kraken import KrakenClient from app.services.live_trading.kraken_futures import KrakenFuturesClient -from app.services.live_trading.kucoin import KucoinSpotClient -from app.services.live_trading.kucoin import KucoinFuturesClient -from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient -from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient +from app.services.live_trading.kucoin import KucoinFuturesClient, KucoinSpotClient +from app.services.live_trading.okx import OkxClient # Lazy import Deepcoin DeepcoinClient = None @@ -42,43 +41,43 @@ MT5Client = None def _normalize_symbol_for_order(symbol: str, market_type: str = "swap") -> str: """ Standardize symbol formats to ensure symbols comply with exchange requirements. - + Handles various input formats: - BTC/USDT -> BTC/USDT - BTCUSDT -> BTC/USDT - BTC/USDT:USDT -> BTC/USDT - PI, TRX -> PI/USDT, TRX/USDT (/USDT is added by default) - + Args: symbol: original symbol market_type: market type (spot/swap) - + Returns: normalized symbols """ if not symbol: return symbol - + sym = symbol.strip() - + # Remove swap/futures suffix - if ':' in sym: - sym = sym.split(':', 1)[0] - + if ":" in sym: + sym = sym.split(":", 1)[0] + sym = sym.upper() - + # If there is already a separator, return it directly (assuming the format is correct) - if '/' in sym: + if "/" in sym: return sym - + # Try to identify from common quote currencies - common_quotes = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC'] + common_quotes = ["USDT", "USD", "BTC", "ETH", "BUSD", "USDC"] for quote in common_quotes: if sym.endswith(quote) and len(sym) > len(quote): - base = sym[:-len(quote)] + base = sym[: -len(quote)] if base: return f"{base}/{quote}" - + # If not recognized, USDT will be used by default. return f"{sym}/USDT" @@ -113,7 +112,9 @@ def _quote_amount_from_base_qty(client: BaseRestClient, *, symbol: str, base_qty if not isinstance(ticker, dict): return float(base_qty or 0.0) try: - price = float(ticker.get("last") or ticker.get("lastPr") or ticker.get("lastPrice") or ticker.get("price") or 0.0) + price = float( + ticker.get("last") or ticker.get("lastPr") or ticker.get("lastPrice") or ticker.get("price") or 0.0 + ) except Exception: price = 0.0 if price <= 0: @@ -147,7 +148,7 @@ def place_order_from_signal( # Spot does not support short signals in this system. if mt == "spot" and ("short" in (signal_type or "").lower()): raise LiveTradingError("spot market does not support short signals") - + # Standardized symbol format (unified processing of bare symbols such as PI, TRX, etc.) symbol = _normalize_symbol_for_order(symbol, market_type=mt) @@ -161,7 +162,7 @@ def place_order_from_signal( client_order_id=client_order_id, ) if isinstance(client, OkxClient): - td_mode = (cfg.get("margin_mode") or cfg.get("td_mode") or "cross") + td_mode = cfg.get("margin_mode") or cfg.get("td_mode") or "cross" return client.place_market_order( symbol=symbol, side=side, @@ -222,28 +223,37 @@ def place_order_from_signal( if side == "buy": kucoin_size = _quote_amount_from_base_qty(client, symbol=symbol, base_qty=qty) quote_size = kucoin_size > 0 and kucoin_size != qty - return client.place_market_order(symbol=symbol, side=side, size=kucoin_size, client_order_id=client_order_id, quote_size=quote_size) + return client.place_market_order( + symbol=symbol, side=side, size=kucoin_size, client_order_id=client_order_id, quote_size=quote_size + ) if isinstance(client, KucoinFuturesClient): - return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id) + return client.place_market_order( + symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id + ) if isinstance(client, GateSpotClient): gate_size = qty if side == "buy": gate_size = _quote_amount_from_base_qty(client, symbol=symbol, base_qty=qty) return client.place_market_order(symbol=symbol, side=side, size=gate_size, client_order_id=client_order_id) if isinstance(client, GateUsdtFuturesClient): - return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id) + return client.place_market_order( + symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id + ) if isinstance(client, BitfinexClient): return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id) if isinstance(client, BitfinexDerivativesClient): return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id) if isinstance(client, KrakenFuturesClient): - return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id) + return client.place_market_order( + symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id + ) # Check for Deepcoin client (lazy import to avoid circular dependency) global DeepcoinClient if DeepcoinClient is None: try: from app.services.live_trading.deepcoin import DeepcoinClient as _DeepcoinClient + DeepcoinClient = _DeepcoinClient except ImportError: pass @@ -262,6 +272,7 @@ def place_order_from_signal( if HtxClient is None: try: from app.services.live_trading.htx import HtxClient as _HtxClient + HtxClient = _HtxClient except ImportError: pass @@ -281,6 +292,7 @@ def place_order_from_signal( if IBKRClient is None: try: from app.services.ibkr_trading import IBKRClient as _IBKRClient + IBKRClient = _IBKRClient except ImportError: pass @@ -299,6 +311,7 @@ def place_order_from_signal( if MT5Client is None: try: from app.services.mt5_trading import MT5Client as _MT5Client + MT5Client = _MT5Client except ImportError: pass @@ -404,8 +417,9 @@ def _place_mt5_order( # Normalize symbol before placing order (MT5 requires specific format) from app.services.mt5_trading.symbols import normalize_symbol + normalized_symbol = normalize_symbol(symbol) - + # Place market order result = client.place_market_order( symbol=normalized_symbol, @@ -427,4 +441,3 @@ def _place_mt5_order( "raw": result.raw, }, ) - diff --git a/backend_api_python/app/services/live_trading/factory.py b/backend_api_python/app/services/live_trading/factory.py index 7721197..f3dffc8 100644 --- a/backend_api_python/app/services/live_trading/factory.py +++ b/backend_api_python/app/services/live_trading/factory.py @@ -9,23 +9,23 @@ Supports: from __future__ import annotations -from typing import Any, Dict, Union +from typing import Any, Dict from app.services.live_trading.base import BaseRestClient, LiveTradingError from app.services.live_trading.binance import BinanceFuturesClient from app.services.live_trading.binance_spot import BinanceSpotClient -from app.services.live_trading.okx import OkxClient +from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient from app.services.live_trading.bitget import BitgetMixClient from app.services.live_trading.bitget_spot import BitgetSpotClient from app.services.live_trading.bybit import BybitClient from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient +from app.services.live_trading.deepcoin import DeepcoinClient +from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient +from app.services.live_trading.htx import HtxClient from app.services.live_trading.kraken import KrakenClient from app.services.live_trading.kraken_futures import KrakenFuturesClient -from app.services.live_trading.kucoin import KucoinSpotClient, KucoinFuturesClient -from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient -from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient -from app.services.live_trading.deepcoin import DeepcoinClient -from app.services.live_trading.htx import HtxClient +from app.services.live_trading.kucoin import KucoinFuturesClient, KucoinSpotClient +from app.services.live_trading.okx import OkxClient # Lazy import IBKR to avoid ImportError if ib_insync not installed IBKRClient = None @@ -62,7 +62,11 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") secret_key = _get(exchange_config, "secret_key", "secret") passphrase = _get(exchange_config, "passphrase", "password") - mt = (market_type or exchange_config.get("market_type") or exchange_config.get("defaultType") or "swap").strip().lower() + mt = ( + (market_type or exchange_config.get("market_type") or exchange_config.get("defaultType") or "swap") + .strip() + .lower() + ) if mt in ("futures", "future", "perp", "perpetual"): mt = "swap" @@ -70,15 +74,29 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") if exchange_id == "binance": spot_broker_id = _get(exchange_config, "spot_broker_id", "spotBrokerId", "broker_id", "brokerId") or "A2NAPZAC" - futures_broker_id = _get(exchange_config, "futures_broker_id", "futuresBrokerId", "broker_id", "brokerId") or "HBpUbQjT" + futures_broker_id = ( + _get(exchange_config, "futures_broker_id", "futuresBrokerId", "broker_id", "brokerId") or "HBpUbQjT" + ) if mt == "spot": default_url = "https://demo-api.binance.com" if is_demo else "https://api.binance.com" base_url = _get(exchange_config, "base_url", "baseUrl") or default_url - return BinanceSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo, broker_id=spot_broker_id) + return BinanceSpotClient( + api_key=api_key, + secret_key=secret_key, + base_url=base_url, + enable_demo_trading=is_demo, + broker_id=spot_broker_id, + ) # Default to USDT-M futures default_url = "https://demo-fapi.binance.com" if is_demo else "https://fapi.binance.com" base_url = _get(exchange_config, "base_url", "baseUrl") or default_url - return BinanceFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo, broker_id=futures_broker_id) + return BinanceFuturesClient( + api_key=api_key, + secret_key=secret_key, + base_url=base_url, + enable_demo_trading=is_demo, + broker_id=futures_broker_id, + ) if exchange_id == "okx": base_url = _get(exchange_config, "base_url", "baseUrl") or "https://www.okx.com" broker_code = "56fa80b0ce8cBCDE" @@ -140,7 +158,9 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") ) if exchange_id in ("coinbaseexchange", "coinbase_exchange"): - default_cb = "https://api-public.sandbox.exchange.coinbase.com" if is_demo else "https://api.exchange.coinbase.com" + default_cb = ( + "https://api-public.sandbox.exchange.coinbase.com" if is_demo else "https://api.exchange.coinbase.com" + ) base_url = _get(exchange_config, "base_url", "baseUrl") or default_cb if mt != "spot": raise LiveTradingError("CoinbaseExchange only supports spot market_type in this project") @@ -172,7 +192,9 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") return GateSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url, channel_id=gate_channel_id) default_fut = "https://fx-api-testnet.gateio.ws" if is_demo else "https://fx-api.gateio.ws" base_url = _get(exchange_config, "base_url", "baseUrl") or default_fut - return GateUsdtFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url, channel_id=gate_channel_id) + return GateUsdtFuturesClient( + api_key=api_key, secret_key=secret_key, base_url=base_url, channel_id=gate_channel_id + ) if exchange_id == "bitfinex": # Same REST host; use keys from Bitfinex paper/sub-account where applicable. @@ -183,7 +205,9 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") if exchange_id == "deepcoin": if is_demo and not (_get(exchange_config, "base_url", "baseUrl")): - raise LiveTradingError("Deepcoin demo/testnet is not configured in this project yet. Please disable demo mode or provide an explicit testnet base_url.") + raise LiveTradingError( + "Deepcoin demo/testnet is not configured in this project yet. Please disable demo mode or provide an explicit testnet base_url." + ) base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.deepcoin.com" return DeepcoinClient( api_key=api_key, @@ -194,8 +218,12 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") ) if exchange_id == "htx": - if is_demo and not (_get(exchange_config, "base_url", "baseUrl") or _get(exchange_config, "futures_base_url", "futuresBaseUrl")): - raise LiveTradingError("HTX demo/testnet is not configured in this project yet. Please disable demo mode or provide explicit testnet base_url/futures_base_url.") + if is_demo and not ( + _get(exchange_config, "base_url", "baseUrl") or _get(exchange_config, "futures_base_url", "futuresBaseUrl") + ): + raise LiveTradingError( + "HTX demo/testnet is not configured in this project yet. Please disable demo mode or provide explicit testnet base_url/futures_base_url." + ) spot_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.huobi.pro" futures_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or "https://api.hbdm.com" broker_id = _get(exchange_config, "broker_id", "brokerId") or "AA7b890547" @@ -238,7 +266,9 @@ def create_ibkr_client(exchange_config: Dict[str, Any]): # Lazy import to avoid ImportError if ib_insync not installed if IBKRClient is None or IBKRConfig is None: try: - from app.services.ibkr_trading import IBKRClient as _IBKRClient, IBKRConfig as _IBKRConfig + from app.services.ibkr_trading import IBKRClient as _IBKRClient + from app.services.ibkr_trading import IBKRConfig as _IBKRConfig + IBKRClient = _IBKRClient IBKRConfig = _IBKRConfig except ImportError: @@ -276,7 +306,7 @@ def create_mt5_client(exchange_config: Dict[str, Any]): - mt5_server: Broker server name (e.g., "ICMarkets-Demo") - mt5_terminal_path: Optional path to terminal64.exe - market_category: Must be "Forex" (validated) - + Note: MT5 is ONLY for Forex trading, not for Crypto or Stocks. """ global MT5Client, MT5Config @@ -292,7 +322,9 @@ def create_mt5_client(exchange_config: Dict[str, Any]): # Lazy import to avoid ImportError if MetaTrader5 not installed if MT5Client is None or MT5Config is None: try: - from app.services.mt5_trading import MT5Client as _MT5Client, MT5Config as _MT5Config + from app.services.mt5_trading import MT5Client as _MT5Client + from app.services.mt5_trading import MT5Config as _MT5Config + MT5Client = _MT5Client MT5Config = _MT5Config except ImportError: @@ -311,7 +343,7 @@ def create_mt5_client(exchange_config: Dict[str, Any]): login = int(str(login_raw).strip()) except (ValueError, TypeError): login = 0 - + password = str(exchange_config.get("mt5_password") or "").strip() server = str(exchange_config.get("mt5_server") or "").strip() terminal_path = str(exchange_config.get("mt5_terminal_path") or "").strip() @@ -338,5 +370,3 @@ def create_mt5_client(exchange_config: Dict[str, Any]): ) return client - - diff --git a/backend_api_python/app/services/live_trading/gate.py b/backend_api_python/app/services/live_trading/gate.py index 4e1b75d..23836f5 100644 --- a/backend_api_python/app/services/live_trading/gate.py +++ b/backend_api_python/app/services/live_trading/gate.py @@ -17,8 +17,8 @@ import hashlib import hmac import logging import time -from decimal import Decimal, ROUND_DOWN, ROUND_UP -from typing import Any, Dict, Optional, Tuple, Union +from decimal import ROUND_DOWN, Decimal +from typing import Any, Dict, Optional, Tuple from urllib.parse import urlencode from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError @@ -53,7 +53,15 @@ def _gate_ticker_response_to_normalized(raw: Any) -> Dict[str, Any]: class _GateBase(BaseRestClient): - def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""): + def __init__( + self, + *, + api_key: str, + secret_key: str, + base_url: str = "https://api.gateio.ws", + timeout_sec: float = 15.0, + channel_id: str = "", + ): super().__init__(base_url=base_url, timeout_sec=timeout_sec) self.api_key = (api_key or "").strip() self.secret_key = (secret_key or "").strip() @@ -137,7 +145,9 @@ class GateSpotClient(_GateBase): def get_accounts(self) -> Any: return self._signed_request("GET", "/api/v4/spot/accounts") - def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + def place_limit_order( + self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: sd = (side or "").strip().lower() if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") @@ -158,9 +168,17 @@ class GateSpotClient(_GateBase): body["text"] = text raw = self._signed_request("POST", "/api/v4/spot/orders", json_body=body) oid = str(raw.get("id") or "") if isinstance(raw, dict) else "" - return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + return LiveOrderResult( + exchange_id="gate", + exchange_order_id=oid, + filled=0.0, + avg_price=0.0, + raw=raw if isinstance(raw, dict) else {"raw": raw}, + ) - def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + def place_market_order( + self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: sd = (side or "").strip().lower() if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") @@ -178,7 +196,13 @@ class GateSpotClient(_GateBase): body["text"] = text raw = self._signed_request("POST", "/api/v4/spot/orders", json_body=body) oid = str(raw.get("id") or "") if isinstance(raw, dict) else "" - return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + return LiveOrderResult( + exchange_id="gate", + exchange_order_id=oid, + filled=0.0, + avg_price=0.0, + raw=raw if isinstance(raw, dict) else {"raw": raw}, + ) def cancel_order(self, *, order_id: str) -> Any: if not order_id: @@ -190,7 +214,9 @@ class GateSpotClient(_GateBase): raise LiveTradingError("Gate spot get_order requires order_id") return self._signed_request("GET", f"/api/v4/spot/orders/{str(order_id)}") - def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]: + def wait_for_fill( + self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5 + ) -> Dict[str, Any]: end_ts = time.time() + float(max_wait_sec or 0.0) last: Dict[str, Any] = {} while True: @@ -221,17 +247,48 @@ class GateSpotClient(_GateBase): fee = 0.0 fee_ccy = str(last.get("fee_currency") or "").strip() if filled > 0 and avg_price > 0: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if status.lower() in ("closed", "cancelled", "canceled"): - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } time.sleep(float(poll_interval_sec or 0.5)) class GateUsdtFuturesClient(_GateBase): - def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""): - super().__init__(api_key=api_key, secret_key=secret_key, base_url=base_url, timeout_sec=timeout_sec, channel_id=channel_id) + def __init__( + self, + *, + api_key: str, + secret_key: str, + base_url: str = "https://api.gateio.ws", + timeout_sec: float = 15.0, + channel_id: str = "", + ): + super().__init__( + api_key=api_key, secret_key=secret_key, base_url=base_url, timeout_sec=timeout_sec, channel_id=channel_id + ) # Best-effort cache for contract metadata to convert base qty -> contracts. self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} self._contract_cache_ttl_sec = 300.0 @@ -276,9 +333,12 @@ class GateUsdtFuturesClient(_GateBase): if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0): return obj code, data, text = self._request( - "GET", f"/api/v4/futures/usdt/contracts/{c}", - params=None, headers={"X-Gate-Size-Decimal": "1"}, - json_body=None, data=None, + "GET", + f"/api/v4/futures/usdt/contracts/{c}", + params=None, + headers={"X-Gate-Size-Decimal": "1"}, + json_body=None, + data=None, ) if code >= 400: raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}") @@ -293,7 +353,9 @@ class GateUsdtFuturesClient(_GateBase): sign, digits, exponent = d.as_tuple() return max(0, -int(exponent)) - def _resolve_order_size(self, *, contract: str, side: str, base_size: float) -> Tuple[str, Optional[Dict[str, str]]]: + def _resolve_order_size( + self, *, contract: str, side: str, base_size: float + ) -> Tuple[str, Optional[Dict[str, str]]]: """ Convert base-asset qty to a signed Gate ``size`` string and determine whether to use the ``X-Gate-Size-Decimal`` header. @@ -337,8 +399,7 @@ class GateUsdtFuturesClient(_GateBase): s = format(signed_q, "f") if "." in s: s = s.rstrip("0").rstrip(".") - return (s if s and s not in ("-", "+", "-0", "+0", "0") else "0", - {"X-Gate-Size-Decimal": "1"}) + return (s if s and s not in ("-", "+", "-0", "+0", "0") else "0", {"X-Gate-Size-Decimal": "1"}) else: iv = int(self._floor(contracts)) int_min = max(1, int(order_min)) @@ -388,7 +449,8 @@ class GateUsdtFuturesClient(_GateBase): def get_positions(self) -> Any: return self._signed_request( - "GET", "/api/v4/futures/usdt/positions", + "GET", + "/api/v4/futures/usdt/positions", extra_headers={"X-Gate-Size-Decimal": "1"}, ) @@ -442,8 +504,14 @@ class GateUsdtFuturesClient(_GateBase): size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty) if size_str in ("0", "-0", ""): raise LiveTradingError("Invalid size (resolved contracts == 0)") - logger.info("Gate futures market: contract=%s side=%s base_qty=%s size_str=%s decimal_hdr=%s", - contract, sd, base_qty, size_str, extra_headers is not None) + logger.info( + "Gate futures market: contract=%s side=%s base_qty=%s size_str=%s decimal_hdr=%s", + contract, + sd, + base_qty, + size_str, + extra_headers is not None, + ) body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": "0", "tif": "ioc"} if reduce_only: body["reduce_only"] = True @@ -457,7 +525,13 @@ class GateUsdtFuturesClient(_GateBase): extra_headers=extra_headers, ) oid = str(raw.get("id") or "") if isinstance(raw, dict) else "" - return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + return LiveOrderResult( + exchange_id="gate", + exchange_order_id=oid, + filled=0.0, + avg_price=0.0, + raw=raw if isinstance(raw, dict) else {"raw": raw}, + ) def place_limit_order( self, @@ -495,7 +569,13 @@ class GateUsdtFuturesClient(_GateBase): extra_headers=extra_headers, ) oid = str(raw.get("id") or "") if isinstance(raw, dict) else "" - return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + return LiveOrderResult( + exchange_id="gate", + exchange_order_id=oid, + filled=0.0, + avg_price=0.0, + raw=raw if isinstance(raw, dict) else {"raw": raw}, + ) def cancel_order(self, *, order_id: str) -> Any: if not order_id: @@ -507,7 +587,9 @@ class GateUsdtFuturesClient(_GateBase): raise LiveTradingError("Gate futures get_order requires order_id") return self._signed_request("GET", f"/api/v4/futures/usdt/orders/{str(order_id)}") - def wait_for_fill(self, *, order_id: str, contract: str, max_wait_sec: float = 3.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]: + def wait_for_fill( + self, *, order_id: str, contract: str, max_wait_sec: float = 3.0, poll_interval_sec: float = 0.5 + ) -> Dict[str, Any]: end_ts = time.time() + float(max_wait_sec or 0.0) last: Dict[str, Any] = {} qm = Decimal("1") @@ -548,11 +630,30 @@ class GateUsdtFuturesClient(_GateBase): if fee > 0: fee_ccy = "USDT" if filled > 0 and avg_price > 0: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if str(status).lower() in ("finished", "cancelled", "canceled"): - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } time.sleep(float(poll_interval_sec or 0.5)) - - diff --git a/backend_api_python/app/services/live_trading/htx.py b/backend_api_python/app/services/live_trading/htx.py index a5f6d8a..dfd8cf9 100644 --- a/backend_api_python/app/services/live_trading/htx.py +++ b/backend_api_python/app/services/live_trading/htx.py @@ -11,15 +11,14 @@ References: from __future__ import annotations import base64 +import datetime import hashlib import hmac -from decimal import Decimal, ROUND_DOWN +import logging +import time +from decimal import ROUND_DOWN, Decimal from typing import Any, Dict, Optional, Tuple from urllib.parse import urlencode, urlparse -import datetime -import time - -import logging from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError from app.services.live_trading.symbols import to_htx_contract_code, to_htx_spot_symbol @@ -125,7 +124,9 @@ class HtxClient(BaseRestClient): signed["Signature"] = base64.b64encode(digest).decode("utf-8") return signed - def _spot_public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def _spot_public_request( + self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: old_base = self.base_url self.base_url = self.spot_base_url try: @@ -138,7 +139,14 @@ class HtxClient(BaseRestClient): raise LiveTradingError(f"HTX spot error: {data}") return data if isinstance(data, dict) else {"raw": data} - def _spot_private_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def _spot_private_request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: signed_params = self._sign_params(method=method, base_url=self.spot_base_url, path=path, params=params or {}) old_base = self.base_url self.base_url = self.spot_base_url @@ -152,7 +160,14 @@ class HtxClient(BaseRestClient): raise LiveTradingError(f"HTX spot error: {data}") return data if isinstance(data, dict) else {"raw": data} - def _swap_private_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def _swap_private_request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: signed_params = self._sign_params(method=method, base_url=self.futures_base_url, path=path, params=params or {}) old_base = self.base_url self.base_url = self.futures_base_url @@ -166,7 +181,9 @@ class HtxClient(BaseRestClient): raise LiveTradingError(f"HTX swap error: {data}") return data if isinstance(data, dict) else {"raw": data} - def _swap_public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def _swap_public_request( + self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: old_base = self.base_url self.base_url = self.futures_base_url try: @@ -198,7 +215,10 @@ class HtxClient(BaseRestClient): for item in data: if not isinstance(item, dict): continue - if str(item.get("type") or "").lower() == "spot" and str(item.get("state") or "").lower() in ("working", ""): + if str(item.get("type") or "").lower() == "spot" and str(item.get("state") or "").lower() in ( + "working", + "", + ): self._spot_account_id = str(item.get("id") or "") if self._spot_account_id: return self._spot_account_id @@ -219,7 +239,9 @@ class HtxClient(BaseRestClient): return self._spot_private_request("GET", f"/v1/account/accounts/{account_id}/balance") # 1) v1 cross try: - raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"}) + raw = self._swap_private_request( + "POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"} + ) data = raw.get("data") if data: return raw @@ -262,13 +284,15 @@ class HtxClient(BaseRestClient): bal = self._to_dec(item.get("balance") or "0") if bal <= 0: continue - rows.append({ - "symbol": f"{ccy}/USDT", - "bal": float(bal), - "availBal": float(self._to_dec(item.get("balance") or "0")), - "cost_open": 0, - "profit_unreal": 0, - }) + rows.append( + { + "symbol": f"{ccy}/USDT", + "bal": float(bal), + "availBal": float(self._to_dec(item.get("balance") or "0")), + "cost_open": 0, + "profit_unreal": 0, + } + ) return {"data": rows} body = {"contract_code": to_htx_contract_code(symbol)} if symbol else {} @@ -304,9 +328,13 @@ class HtxClient(BaseRestClient): def get_ticker(self, *, symbol: str) -> Dict[str, Any]: if self.market_type == "spot": - raw = self._spot_public_request("GET", "/market/detail/merged", params={"symbol": to_htx_spot_symbol(symbol)}) + raw = self._spot_public_request( + "GET", "/market/detail/merged", params={"symbol": to_htx_spot_symbol(symbol)} + ) else: - raw = self._swap_public_request("GET", "/linear-swap-ex/market/detail/merged", params={"contract_code": to_htx_contract_code(symbol)}) + raw = self._swap_public_request( + "GET", "/linear-swap-ex/market/detail/merged", params={"contract_code": to_htx_contract_code(symbol)} + ) tick = raw.get("tick") if isinstance(raw, dict) else {} return tick if isinstance(tick, dict) else {} @@ -516,7 +544,11 @@ class HtxClient(BaseRestClient): if order_id: return self._spot_private_request("POST", f"/v1/order/orders/{str(order_id)}/submitcancel") if client_order_id: - return self._spot_private_request("POST", "/v1/order/orders/submitCancelClientOrder", json_body={"client-order-id": str(client_order_id)}) + return self._spot_private_request( + "POST", + "/v1/order/orders/submitCancelClientOrder", + json_body={"client-order-id": str(client_order_id)}, + ) raise LiveTradingError("HTX cancel_order requires order_id or client_order_id") body: Dict[str, Any] = {"contract_code": to_htx_contract_code(symbol)} @@ -535,7 +567,9 @@ class HtxClient(BaseRestClient): data = raw.get("data") if isinstance(raw, dict) else {} return data if isinstance(data, dict) else {} if client_order_id: - raw = self._spot_private_request("GET", "/v1/order/orders/getClientOrder", params={"clientOrderId": str(client_order_id)}) + raw = self._spot_private_request( + "GET", "/v1/order/orders/getClientOrder", params={"clientOrderId": str(client_order_id)} + ) data = raw.get("data") if isinstance(raw, dict) else {} return data if isinstance(data, dict) else {} raise LiveTradingError("HTX get_order requires order_id or client_order_id") @@ -566,7 +600,12 @@ class HtxClient(BaseRestClient): last: Dict[str, Any] = {} while True: try: - last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) or {} + last = ( + self.get_order( + symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "") + ) + or {} + ) except Exception: last = last or {} @@ -577,22 +616,22 @@ class HtxClient(BaseRestClient): status = str(last.get("status") or last.get("state") or "") try: filled = float( - last.get("field-amount") or - last.get("filled_amount") or - last.get("trade_volume") or - last.get("trade_volume_avg") or - 0.0 + last.get("field-amount") + or last.get("filled_amount") + or last.get("trade_volume") + or last.get("trade_volume_avg") + or 0.0 ) except Exception: filled = 0.0 try: - avg_price = float( - last.get("field-cash-amount") or 0.0 - ) + avg_price = float(last.get("field-cash-amount") or 0.0) if filled > 0 and avg_price > 0: avg_price = avg_price / filled else: - avg_price = float(last.get("field-avg-price") or last.get("trade_avg_price") or last.get("price") or 0.0) + avg_price = float( + last.get("field-avg-price") or last.get("trade_avg_price") or last.get("price") or 0.0 + ) except Exception: avg_price = 0.0 try: @@ -602,9 +641,30 @@ class HtxClient(BaseRestClient): fee_ccy = str(last.get("fee_asset") or last.get("fee_currency") or fee_ccy or "").strip() or "USDT" if filled > 0 and avg_price > 0: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if str(status).lower() in ("filled", "partial-filled", "submitted", "canceled", "cancelled", "6", "7"): - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } time.sleep(float(poll_interval_sec or 0.5)) diff --git a/backend_api_python/app/services/live_trading/kraken.py b/backend_api_python/app/services/live_trading/kraken.py index 8d25401..71a64c1 100644 --- a/backend_api_python/app/services/live_trading/kraken.py +++ b/backend_api_python/app/services/live_trading/kraken.py @@ -24,7 +24,9 @@ from app.services.live_trading.symbols import to_kraken_pair class KrakenClient(BaseRestClient): - def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.kraken.com", timeout_sec: float = 15.0): + def __init__( + self, *, api_key: str, secret_key: str, base_url: str = "https://api.kraken.com", timeout_sec: float = 15.0 + ): super().__init__(base_url=base_url, timeout_sec=timeout_sec) self.api_key = (api_key or "").strip() self.secret_key = (secret_key or "").strip() @@ -105,9 +107,17 @@ class KrakenClient(BaseRestClient): pass return self._signed_request("POST", "/0/private/AddOrder", data=body) - def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + def place_market_order( + self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: pair = to_kraken_pair(symbol) - raw = self.add_order(pair=pair, side=side, ordertype="market", volume=float(size or 0.0), client_order_id=str(client_order_id or "")) + raw = self.add_order( + pair=pair, + side=side, + ordertype="market", + volume=float(size or 0.0), + client_order_id=str(client_order_id or ""), + ) txid = "" try: tx = ((raw.get("result") or {}).get("txid")) if isinstance(raw, dict) else None @@ -117,9 +127,18 @@ class KrakenClient(BaseRestClient): txid = "" return LiveOrderResult(exchange_id="kraken", exchange_order_id=txid, filled=0.0, avg_price=0.0, raw=raw) - def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + def place_limit_order( + self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: pair = to_kraken_pair(symbol) - raw = self.add_order(pair=pair, side=side, ordertype="limit", volume=float(size or 0.0), price=float(price or 0.0), client_order_id=str(client_order_id or "")) + raw = self.add_order( + pair=pair, + side=side, + ordertype="limit", + volume=float(size or 0.0), + price=float(price or 0.0), + client_order_id=str(client_order_id or ""), + ) txid = "" try: tx = ((raw.get("result") or {}).get("txid")) if isinstance(raw, dict) else None @@ -142,7 +161,9 @@ class KrakenClient(BaseRestClient): od = (res.get(str(order_id)) if isinstance(res, dict) else None) or {} return od if isinstance(od, dict) else {} - def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]: + def wait_for_fill( + self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5 + ) -> Dict[str, Any]: end_ts = time.time() + float(max_wait_sec or 0.0) last: Dict[str, Any] = {} while True: @@ -175,11 +196,30 @@ class KrakenClient(BaseRestClient): if fee > 0: fee_ccy = "USD" if filled > 0 and avg_price > 0: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if status.lower() in ("closed", "canceled", "cancelled", "expired"): - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } time.sleep(float(poll_interval_sec or 0.5)) - - diff --git a/backend_api_python/app/services/live_trading/kraken_futures.py b/backend_api_python/app/services/live_trading/kraken_futures.py index e0c6728..8e10bae 100644 --- a/backend_api_python/app/services/live_trading/kraken_futures.py +++ b/backend_api_python/app/services/live_trading/kraken_futures.py @@ -29,7 +29,9 @@ from app.services.live_trading.symbols import to_kraken_futures_symbol class KrakenFuturesClient(BaseRestClient): - def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://futures.kraken.com", timeout_sec: float = 15.0): + def __init__( + self, *, api_key: str, secret_key: str, base_url: str = "https://futures.kraken.com", timeout_sec: float = 15.0 + ): super().__init__(base_url=base_url, timeout_sec=timeout_sec) self.api_key = (api_key or "").strip() self.secret_key = (secret_key or "").strip() @@ -41,7 +43,12 @@ class KrakenFuturesClient(BaseRestClient): return base64.b64encode(mac).decode("utf-8") def _headers(self, nonce: str, authent: str) -> Dict[str, str]: - return {"APIKey": self.api_key, "Nonce": nonce, "Authent": authent, "Content-Type": "application/x-www-form-urlencoded"} + return { + "APIKey": self.api_key, + "Nonce": nonce, + "Authent": authent, + "Content-Type": "application/x-www-form-urlencoded", + } def _signed_request(self, method: str, path: str, *, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: m = str(method or "POST").upper() @@ -52,7 +59,14 @@ class KrakenFuturesClient(BaseRestClient): # Sign with endpoint path (not including domain) prehash = f"{nonce}{postdata}{path}" authent = self._b64_hmac_sha256(prehash) - code, resp, text = self._request(m, path, params=None, json_body=None, data=postdata if postdata else None, headers=self._headers(nonce, authent)) + code, resp, text = self._request( + m, + path, + params=None, + json_body=None, + data=postdata if postdata else None, + headers=self._headers(nonce, authent), + ) if code >= 400: raise LiveTradingError(f"KrakenFutures HTTP {code}: {text[:500]}") if isinstance(resp, dict): @@ -109,7 +123,11 @@ class KrakenFuturesClient(BaseRestClient): if client_order_id: body["cliOrdId"] = str(client_order_id)[:32] raw = self._signed_request("POST", "/derivatives/api/v3/sendorder", data=body) - oid = str((raw.get("sendStatus") or {}).get("order_id") or (raw.get("order_id") or "")) if isinstance(raw, dict) else "" + oid = ( + str((raw.get("sendStatus") or {}).get("order_id") or (raw.get("order_id") or "")) + if isinstance(raw, dict) + else "" + ) return LiveOrderResult(exchange_id="kraken", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw) def place_limit_order( @@ -145,7 +163,11 @@ class KrakenFuturesClient(BaseRestClient): if client_order_id: body["cliOrdId"] = str(client_order_id)[:32] raw = self._signed_request("POST", "/derivatives/api/v3/sendorder", data=body) - oid = str((raw.get("sendStatus") or {}).get("order_id") or (raw.get("order_id") or "")) if isinstance(raw, dict) else "" + oid = ( + str((raw.get("sendStatus") or {}).get("order_id") or (raw.get("order_id") or "")) + if isinstance(raw, dict) + else "" + ) return LiveOrderResult(exchange_id="kraken", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw) def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]: @@ -205,11 +227,30 @@ class KrakenFuturesClient(BaseRestClient): if fee > 0: fee_ccy = "USD" if filled > 0 and avg_price > 0: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if status.lower() in ("filled", "cancelled", "canceled", "rejected"): - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } time.sleep(float(poll_interval_sec or 0.5)) - - diff --git a/backend_api_python/app/services/live_trading/kucoin.py b/backend_api_python/app/services/live_trading/kucoin.py index 72076cf..2febfcb 100644 --- a/backend_api_python/app/services/live_trading/kucoin.py +++ b/backend_api_python/app/services/live_trading/kucoin.py @@ -13,7 +13,7 @@ import base64 import hashlib import hmac import time -from decimal import Decimal, ROUND_DOWN +from decimal import ROUND_DOWN, Decimal from typing import Any, Dict, Optional, Tuple from urllib.parse import urlencode @@ -54,7 +54,14 @@ class KucoinSpotClient(BaseRestClient): "Content-Type": "application/json", } - def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any: + def _signed_request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + ) -> Any: m = str(method or "GET").upper() ts_ms = str(int(time.time() * 1000)) body_str = self._json_dumps(json_body) if json_body is not None else "" @@ -65,7 +72,9 @@ class KucoinSpotClient(BaseRestClient): signed_path = f"{path}?{qs}" if qs else path prehash = f"{ts_ms}{m}{signed_path}{body_str}" sign = self._b64_hmac_sha256(self.secret_key, prehash) - code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign)) + code, data, text = self._request( + m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign) + ) if code >= 400: raise LiveTradingError(f"KuCoin HTTP {code}: {text[:500]}") return data @@ -87,11 +96,15 @@ class KucoinSpotClient(BaseRestClient): return self._signed_request("GET", "/api/v1/accounts") def get_ticker(self, *, symbol: str) -> Dict[str, Any]: - raw = self._public_request("GET", "/api/v1/market/orderbook/level1", params={"symbol": to_kucoin_symbol(symbol)}) + raw = self._public_request( + "GET", "/api/v1/market/orderbook/level1", params={"symbol": to_kucoin_symbol(symbol)} + ) data = raw.get("data") if isinstance(raw, dict) else None return data if isinstance(data, dict) else {} - def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + def place_limit_order( + self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None + ) -> LiveOrderResult: sd = (side or "").strip().lower() if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") @@ -116,7 +129,13 @@ class KucoinSpotClient(BaseRestClient): oid = str(d.get("orderId") or "") elif isinstance(d, str): oid = str(d) - return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + return LiveOrderResult( + exchange_id="kucoin", + exchange_order_id=oid, + filled=0.0, + avg_price=0.0, + raw=raw if isinstance(raw, dict) else {"raw": raw}, + ) def place_market_order( self, @@ -156,7 +175,13 @@ class KucoinSpotClient(BaseRestClient): oid = str(d.get("orderId") or "") elif isinstance(d, str): oid = str(d) - return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + return LiveOrderResult( + exchange_id="kucoin", + exchange_order_id=oid, + filled=0.0, + avg_price=0.0, + raw=raw if isinstance(raw, dict) else {"raw": raw}, + ) def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any: if order_id: @@ -175,7 +200,9 @@ class KucoinSpotClient(BaseRestClient): def get_fills(self, *, order_id: str) -> Any: return self._signed_request("GET", "/api/v1/fills", params={"orderId": str(order_id)}) - def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]: + def wait_for_fill( + self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5 + ) -> Dict[str, Any]: end_ts = time.time() + float(max_wait_sec or 0.0) last: Dict[str, Any] = {} while True: @@ -207,16 +234,37 @@ class KucoinSpotClient(BaseRestClient): fee = 0.0 fee_ccy = str(od.get("feeCurrency") or "").strip() if filled > 0 and avg_price > 0: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } # If order is inactive, consider it terminal try: is_active = bool(od.get("isActive")) except Exception: is_active = False if not is_active: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } time.sleep(float(poll_interval_sec or 0.5)) @@ -266,7 +314,14 @@ class KucoinFuturesClient(BaseRestClient): "Content-Type": "application/json", } - def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any: + def _signed_request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + ) -> Any: m = str(method or "GET").upper() ts_ms = str(int(time.time() * 1000)) body_str = self._json_dumps(json_body) if json_body is not None else "" @@ -277,7 +332,9 @@ class KucoinFuturesClient(BaseRestClient): signed_path = f"{path}?{qs}" if qs else path prehash = f"{ts_ms}{m}{signed_path}{body_str}" sign = self._b64_hmac_sha256(self.secret_key, prehash) - code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign)) + code, data, text = self._request( + m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign) + ) if code >= 400: raise LiveTradingError(f"KuCoinFutures HTTP {code}: {text[:500]}") return data @@ -406,7 +463,13 @@ class KucoinFuturesClient(BaseRestClient): oid = str(d.get("orderId") or "") elif isinstance(d, str): oid = str(d) - return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + return LiveOrderResult( + exchange_id="kucoin", + exchange_order_id=oid, + filled=0.0, + avg_price=0.0, + raw=raw if isinstance(raw, dict) else {"raw": raw}, + ) def place_limit_order( self, @@ -451,7 +514,13 @@ class KucoinFuturesClient(BaseRestClient): oid = str(d.get("orderId") or "") elif isinstance(d, str): oid = str(d) - return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + return LiveOrderResult( + exchange_id="kucoin", + exchange_order_id=oid, + filled=0.0, + avg_price=0.0, + raw=raw if isinstance(raw, dict) else {"raw": raw}, + ) def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any: if order_id: @@ -464,10 +533,12 @@ class KucoinFuturesClient(BaseRestClient): if order_id: return self._signed_request("GET", f"/api/v1/orders/{str(order_id)}") if client_order_id: - return self._signed_request("GET", f"/api/v1/orders/byClientOid", params={"clientOid": str(client_order_id)}) + return self._signed_request("GET", "/api/v1/orders/byClientOid", params={"clientOid": str(client_order_id)}) raise LiveTradingError("KuCoinFutures get_order requires order_id or client_order_id") - def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 3.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]: + def wait_for_fill( + self, *, order_id: str, max_wait_sec: float = 3.0, poll_interval_sec: float = 0.5 + ) -> Dict[str, Any]: end_ts = time.time() + float(max_wait_sec or 0.0) last: Dict[str, Any] = {} while True: @@ -513,11 +584,30 @@ class KucoinFuturesClient(BaseRestClient): if fee > 0: fee_ccy = "USDT" if filled > 0 and avg_price > 0: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if status.lower() in ("done", "canceled", "cancelled", "filled"): - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last} + return { + "filled": filled, + "avg_price": avg_price, + "fee": fee, + "fee_ccy": fee_ccy, + "status": status, + "order": last, + } time.sleep(float(poll_interval_sec or 0.5)) - - diff --git a/backend_api_python/app/services/live_trading/okx.py b/backend_api_python/app/services/live_trading/okx.py index fda5ad4..37d7917 100644 --- a/backend_api_python/app/services/live_trading/okx.py +++ b/backend_api_python/app/services/live_trading/okx.py @@ -11,12 +11,12 @@ import base64 import hashlib import hmac import time -from decimal import Decimal, ROUND_DOWN +from decimal import ROUND_DOWN, Decimal from typing import Any, Dict, Optional, Tuple from urllib.parse import urlencode from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError -from app.services.live_trading.symbols import to_okx_swap_inst_id, to_okx_spot_inst_id +from app.services.live_trading.symbols import to_okx_spot_inst_id, to_okx_swap_inst_id class OkxClient(BaseRestClient): @@ -63,7 +63,7 @@ class OkxClient(BaseRestClient): """ Convert Decimal to a non-scientific string with controlled precision. OKX expects plain decimal strings matching lotSz precision. - + Args: d: Decimal value to format max_decimals: Maximum decimal places (fallback if strict_precision not provided) @@ -74,7 +74,7 @@ class OkxClient(BaseRestClient): return "0" # Normalize to remove unnecessary trailing zeros normalized = d.normalize() - + # If strict_precision is provided, use it and strictly limit decimal places if strict_precision is not None: try: @@ -85,19 +85,20 @@ class OkxClient(BaseRestClient): prec = 18 # Use quantize to ensure exact precision from decimal import ROUND_DOWN + q = Decimal("1").scaleb(-prec) quantized = normalized.quantize(q, rounding=ROUND_DOWN) s = format(quantized, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass - + # Format with max_decimals and remove trailing zeros s = format(normalized, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: try: @@ -109,18 +110,18 @@ class OkxClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: s = str(d) - if 'e' in s.lower() or 'E' in s: + if "e" in s.lower() or "E" in s: try: f = float(s) if strict_precision is not None: @@ -128,14 +129,14 @@ class OkxClient(BaseRestClient): prec = int(strict_precision) if 0 <= prec <= 18: s = format(f, f".{prec}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") return s if s else "0" except Exception: pass s = format(f, f".{max_decimals}f") - if '.' in s: - s = s.rstrip('0').rstrip('.') + if "." in s: + s = s.rstrip("0").rstrip(".") except Exception: pass return s if s else "0" @@ -205,7 +206,7 @@ class OkxClient(BaseRestClient): - Swap: OKX sz is in contracts; convert base qty -> contracts using ctVal, then align to lotSz/minSz. Note: this system passes `amount` around as base-asset quantity across exchanges. - + Returns: Tuple of (normalized_size, precision) where precision is the number of decimal places required. """ @@ -235,15 +236,15 @@ class OkxClient(BaseRestClient): # Align to lot size step. if lot_sz > 0: req = self._floor_to_step(req, lot_sz) - + # Infer precision from lotSz size_precision = None if lot_sz > 0: try: lot_sz_normalized = lot_sz.normalize() lot_sz_str = str(lot_sz_normalized) - if '.' in lot_sz_str: - decimal_part = lot_sz_str.split('.')[1] + if "." in lot_sz_str: + decimal_part = lot_sz_str.split(".")[1] size_precision = len(decimal_part) if size_precision < 0: size_precision = 0 @@ -312,7 +313,7 @@ class OkxClient(BaseRestClient): signed_path = f"{path}?{qs}" if qs else path sign = self._sign(ts, method, signed_path, body_str) - + # For GET requests with query params, we need to ensure the actual request URL matches the signed path # OKX requires exact match between signed path and actual request path if method.upper() == "GET" and qs: @@ -323,7 +324,7 @@ class OkxClient(BaseRestClient): else: request_path = path request_params = params - + code, data, text = self._request( method, request_path, @@ -348,14 +349,14 @@ class OkxClient(BaseRestClient): if isinstance(data, dict) and str(data.get("code") or "") not in ("0", ""): error_code = str(data.get("code") or "") error_msg = str(data.get("msg") or data) - + # Check for specific error codes in data array data_array = data.get("data", []) if isinstance(data_array, list) and data_array: first_item = data_array[0] if isinstance(data_array[0], dict) else {} s_code = str(first_item.get("sCode") or "") s_msg = str(first_item.get("sMsg") or "") - + # Error code 51008: Insufficient margin if s_code == "51008" or "insufficient" in s_msg.lower() or "margin" in s_msg.lower(): raise LiveTradingError( @@ -369,7 +370,7 @@ class OkxClient(BaseRestClient): f"Solution: Please enable 'Trade' permission for your API key in OKX account.\n" f"Path: OKX website -> API Management -> Edit API Key -> Enable 'Trade' permission" ) - + # Fallback for permission errors if error_code == "50120" or "permission" in str(error_msg).lower(): raise LiveTradingError( @@ -387,7 +388,7 @@ class OkxClient(BaseRestClient): def get_ticker(self, *, inst_id: str) -> Dict[str, Any]: """ Get ticker price for an instrument. - + Endpoint: GET /api/v5/market/ticker?instId=... """ if not inst_id: @@ -406,7 +407,7 @@ class OkxClient(BaseRestClient): def get_positions(self, *, inst_id: str = "", inst_type: str = "SWAP") -> Dict[str, Any]: """ Get positions (best-effort). - + Args: inst_id: Instrument ID (optional, for filtering) inst_type: Instrument type - "SPOT" or "SWAP" (default: "SWAP") @@ -417,7 +418,7 @@ class OkxClient(BaseRestClient): it = str(inst_type or "SWAP").strip().upper() if it not in ("SPOT", "SWAP", "FUTURES", "OPTION"): it = "SWAP" - + params: Dict[str, Any] = {"instType": it} # Only add instId if it's not empty if inst_id and str(inst_id).strip(): @@ -662,7 +663,9 @@ class OkxClient(BaseRestClient): data = (raw.get("data") or []) if isinstance(raw, dict) else [] first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {} exchange_order_id = str(first.get("ordId") or first.get("clOrdId") or "") - return LiveOrderResult(exchange_id="okx", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw) + return LiveOrderResult( + exchange_id="okx", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw + ) def cancel_order(self, *, market_type: str, symbol: str, ord_id: str = "", cl_ord_id: str = "") -> Dict[str, Any]: mt = (market_type or "swap").strip().lower() @@ -836,10 +839,24 @@ class OkxClient(BaseRestClient): # Terminal states: return whatever we have. if state in ("filled", "canceled", "cancelled"): if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": 0.0, "fee_ccy": "", "state": state, "order": last_order, "fills": last_fills} + return { + "filled": filled, + "avg_price": avg_price, + "fee": 0.0, + "fee_ccy": "", + "state": state, + "order": last_order, + "fills": last_fills, + } if time.time() >= end_ts: - return {"filled": filled, "avg_price": avg_price, "fee": 0.0, "fee_ccy": "", "state": state, "order": last_order, "fills": last_fills} + return { + "filled": filled, + "avg_price": avg_price, + "fee": 0.0, + "fee_ccy": "", + "state": state, + "order": last_order, + "fills": last_fills, + } time.sleep(float(poll_interval_sec or 0.5)) - - diff --git a/backend_api_python/app/services/live_trading/records.py b/backend_api_python/app/services/live_trading/records.py index 3adb76d..7c12d7e 100644 --- a/backend_api_python/app/services/live_trading/records.py +++ b/backend_api_python/app/services/live_trading/records.py @@ -8,7 +8,6 @@ Important: from __future__ import annotations -import time from typing import Any, Dict, Optional, Tuple from app.utils.db import get_db_connection @@ -22,7 +21,7 @@ def _get_user_id_from_strategy(strategy_id: int) -> int: cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,)) row = cur.fetchone() cur.close() - return int((row or {}).get('user_id') or 1) + return int((row or {}).get("user_id") or 1) except Exception: return 1 @@ -121,7 +120,17 @@ def upsert_position( lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE qd_strategy_positions.lowest_price END, updated_at = NOW() """, - (int(user_id), int(strategy_id), str(symbol), str(side), float(size or 0.0), float(entry_price or 0.0), float(current_price or 0.0), float(highest_price or 0.0), float(lowest_price or 0.0)), + ( + int(user_id), + int(strategy_id), + str(symbol), + str(side), + float(size or 0.0), + float(entry_price or 0.0), + float(current_price or 0.0), + float(highest_price or 0.0), + float(lowest_price or 0.0), + ), ) db.commit() cur.close() @@ -217,5 +226,3 @@ def apply_fill_to_local_position( return profit, _fetch_position(strategy_id, symbol, side) return None, None - - diff --git a/backend_api_python/app/services/live_trading/symbols.py b/backend_api_python/app/services/live_trading/symbols.py index 400e6d3..697a789 100644 --- a/backend_api_python/app/services/live_trading/symbols.py +++ b/backend_api_python/app/services/live_trading/symbols.py @@ -16,7 +16,7 @@ from typing import Dict, Tuple def _split_base_quote(symbol: str) -> Tuple[str, str]: """ The split symbols are base currency and quote currency. - + Handle various formats: - BTC/USDT -> (BTC, USDT) - BTCUSDT -> (BTCUSDT, "") - requires further processing @@ -28,10 +28,10 @@ def _split_base_quote(symbol: str) -> Tuple[str, str]: if "/" not in s: # Try to identify the quote currency (common format: BASEQUOTE) s_upper = s.upper() - common_quotes = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC', 'BNB'] + common_quotes = ["USDT", "USD", "BTC", "ETH", "BUSD", "USDC", "BNB"] for quote in common_quotes: if s_upper.endswith(quote) and len(s_upper) > len(quote): - base = s_upper[:-len(quote)] + base = s_upper[: -len(quote)] if base: return base, quote # Unrecognized, the original symbol and empty quote are returned. @@ -207,22 +207,22 @@ def to_deepcoin_symbol(symbol: str) -> str: Examples: - Spot: BTC-USDT - Perpetual: BTC-USDT-SWAP - + If symbol already contains '-', return as-is (already in Deepcoin format). """ s = (symbol or "").strip() if not s: return s - + # Already in Deepcoin format if "-" in s: return s.upper() - + base, quote = _split_base_quote(symbol) if not base or not quote: # Best effort: remove slashes and colons return s.replace("/", "-").replace(":", "-").upper() - + # Return BASE-QUOTE format (caller adds -SWAP if needed for futures) return f"{base}-{quote}" @@ -260,4 +260,3 @@ def to_htx_contract_code(symbol: str) -> str: if not base or not quote: return s.replace("/", "-").replace(":", "-").upper() return f"{base}-{quote}" - diff --git a/backend_api_python/app/services/llm.py b/backend_api_python/app/services/llm.py index 797bd2c..6243e44 100644 --- a/backend_api_python/app/services/llm.py +++ b/backend_api_python/app/services/llm.py @@ -3,21 +3,24 @@ LLM service. 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 typing import Any, Dict, List, Optional + +import requests -from app.utils.logger import get_logger from app.config import APIKeys from app.utils.config_loader import load_addon_config +from app.utils.logger import get_logger logger = get_logger(__name__) class LLMProvider(Enum): """Supported LLM providers""" + OPENROUTER = "openrouter" OPENAI = "openai" GOOGLE = "google" @@ -61,7 +64,7 @@ class LLMService: def __init__(self, provider: str = None): """ Initialize LLM service. - + Args: provider: Override the default provider (openrouter, openai, google, deepseek, grok) """ @@ -75,11 +78,11 @@ class LLMService: 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', '') - + provider_name = config.get("llm", {}).get("provider") or os.getenv("LLM_PROVIDER", "") + if provider_name: try: # Explicit selection should always be respected. @@ -88,7 +91,7 @@ class LLMService: return selected except ValueError: pass - + # Auto-detect: find any provider with a configured API key # Priority: DeepSeek > Grok > OpenAI > Google > OpenRouter priority_order = [ @@ -98,19 +101,19 @@ class LLMService: 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, @@ -124,32 +127,32 @@ class LLMService: """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() - + 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 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() - + 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"] def get_code_generation_model(self, provider: LLMProvider = None) -> str: """Get model for AI code generation; fallback to provider default when unset.""" - model = os.getenv('AI_CODE_GEN_MODEL', '').strip() + model = os.getenv("AI_CODE_GEN_MODEL", "").strip() if model: return model return self.get_default_model(provider) @@ -163,17 +166,24 @@ class LLMService: def base_url(self): return self.get_base_url() - 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: + 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 {api_key}", "Content-Type": "application/json", } - + # OpenRouter specific headers if "openrouter" in base_url: headers["HTTP-Referer"] = "https://quantdinger.com" @@ -184,12 +194,12 @@ class LLMService: "messages": messages, "temperature": temperature, } - + if use_json_mode: data["response_format"] = {"type": "json_object"} response = requests.post(url, headers=headers, json=data, timeout=timeout) - + # Handle non-2xx with provider/model-aware details if response.status_code >= 400: provider_name = "OpenRouter" if "openrouter" in (base_url or "").lower() else "LLM" @@ -211,6 +221,7 @@ class LLMService: # OpenRouter targeted hints if "openrouter" in (base_url or "").lower(): from app.config.api_keys import APIKeys + if not APIKeys.OPENROUTER_API_KEY: error_msg += ". OPENROUTER_API_KEY is not configured. Set it in backend_api_python/.env" elif response.status_code == 403: @@ -219,7 +230,7 @@ class LLMService: error_msg += ". Possible causes: the model is unavailable or blocked by account privacy/data-policy settings. Check https://openrouter.ai/settings/privacy" raise ValueError(error_msg) - + result = response.json() if "choices" in result and len(result["choices"]) > 0: content = result["choices"][0]["message"]["content"] @@ -229,42 +240,43 @@ class LLMService: 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: + 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] @@ -272,54 +284,54 @@ class LLMService: 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) + + 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, + "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 @@ -328,30 +340,37 @@ class LLMService: Detect which provider a model belongs to based on its name. Returns None if detection fails. """ - if not model or '/' not in model: + if not model or "/" not in model: return None - - prefix = model.split('/')[0].lower() - + + 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 + "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, try_alternative_providers: bool = True) -> str: + 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, + try_alternative_providers: 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') @@ -360,10 +379,10 @@ class LLMService: provider: Override the service's default provider use_json_mode: Whether to request JSON output format (default True for analysis, False for code generation) try_alternative_providers: Whether to try alternative providers when current provider fails with 403/402 - + 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 @@ -378,10 +397,12 @@ class LLMService: 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 cfg = load_addon_config() - explicit_provider_name = str(cfg.get('llm', {}).get('provider') or os.getenv('LLM_PROVIDER', '')).strip().lower() + explicit_provider_name = ( + str(cfg.get("llm", {}).get("provider") or os.getenv("LLM_PROVIDER", "")).strip().lower() + ) explicit_provider = None if explicit_provider_name: try: @@ -389,7 +410,7 @@ class LLMService: except ValueError: explicit_provider = None api_key = self.get_api_key(p) - + if not api_key: # If provider is explicitly configured by user, don't silently switch. if explicit_provider is not None and p == explicit_provider: @@ -399,102 +420,106 @@ class LLMService: ) # If no API key for current provider, try to find any available provider if try_alternative_providers: - for alt_provider in [LLMProvider.DEEPSEEK, LLMProvider.GROK, LLMProvider.OPENAI, LLMProvider.GOOGLE, LLMProvider.OPENROUTER]: + for alt_provider in [ + LLMProvider.DEEPSEEK, + LLMProvider.GROK, + LLMProvider.OPENAI, + LLMProvider.GOOGLE, + LLMProvider.OPENROUTER, + ]: if alt_provider != p and self.get_api_key(alt_provider): logger.warning(f"No API key for {p.value}, switching to {alt_provider.value}") p = alt_provider api_key = self.get_api_key(p) break - + if not api_key: - raise ValueError(f"API key not configured for provider: {p.value}. Please configure at least one LLM provider API key.") - + raise ValueError( + f"API key not configured for provider: {p.value}. Please configure at least one LLM provider API key." + ) + base_url = self.get_base_url(p) - + # Normalize model name for the provider original_model = model model = self._normalize_model_for_provider(model, p) - + config = load_addon_config() - timeout = int(config.get(p.value, {}).get('timeout', 120)) - + 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) - + last_error = None last_status_code = None - + for current_model in models_to_try: try: if p == LLMProvider.GOOGLE: - return self._call_google_gemini( - messages, current_model, temperature, - api_key, base_url, timeout - ) + return self._call_google_gemini(messages, current_model, temperature, api_key, base_url, timeout) else: # OpenAI-compatible providers return self._call_openai_compatible( - messages, current_model, temperature, - api_key, base_url, timeout, - use_json_mode=use_json_mode + messages, current_model, temperature, api_key, base_url, timeout, use_json_mode=use_json_mode ) - + except requests.exceptions.HTTPError as e: error_detail = e.response.text if e.response else str(e) status_code = e.response.status_code if e.response else None last_status_code = status_code - + logger.error(f"{p.value} API HTTP error ({current_model}): {status_code} - {error_detail}") last_error = str(e) - + # 403/402 errors usually mean API key issue - try alternative provider if status_code in (402, 403) and try_alternative_providers and current_model == models_to_try[-1]: # Only try alternative providers after all models in current provider failed - logger.warning(f"{p.value} returned {status_code} (likely API key issue). Trying alternative providers...") - return self._try_alternative_providers( - messages, original_model, temperature, - use_json_mode, excluded_provider=p + logger.warning( + f"{p.value} returned {status_code} (likely API key issue). Trying alternative providers..." ) - + return self._try_alternative_providers( + messages, original_model, temperature, use_json_mode, excluded_provider=p + ) + # Check for recoverable errors - try fallback model # 402: Payment required, 403: Forbidden (invalid key), 404: Model not found, 429: Rate limit if status_code in (402, 403, 404, 429): logger.warning(f"{p.value} returned {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"{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 current_model == models_to_try[-1]: raise - + error_msg = f"All model calls failed for {p.value}. Last error: {last_error}" if last_status_code in (402, 403): error_msg += f"\nStatus {last_status_code} usually means: API key invalid/expired, insufficient balance, or no access to model." error_msg += f"\nPlease check your {p.value} API key configuration and account balance." - + logger.error(error_msg) raise Exception(error_msg) - - def _try_alternative_providers(self, messages: list, model: str, temperature: float, - use_json_mode: bool, excluded_provider: LLMProvider = None) -> str: + + def _try_alternative_providers( + self, messages: list, model: str, temperature: float, use_json_mode: bool, excluded_provider: LLMProvider = None + ) -> str: """ Try alternative providers when current provider fails. - + Priority: DeepSeek > Grok > OpenAI > Google > OpenRouter """ priority_order = [ @@ -504,91 +529,107 @@ class LLMService: LLMProvider.GOOGLE, LLMProvider.OPENROUTER, ] - + for alt_provider in priority_order: if alt_provider == excluded_provider: continue - + api_key = self.get_api_key(alt_provider) if not api_key: continue - + logger.info(f"Trying alternative provider: {alt_provider.value}") try: return self.call_llm_api( - messages, model, temperature, - use_fallback=True, provider=alt_provider, + messages, + model, + temperature, + use_fallback=True, + provider=alt_provider, use_json_mode=use_json_mode, - try_alternative_providers=False # Prevent infinite recursion + try_alternative_providers=False, # Prevent infinite recursion ) except Exception as e: logger.warning(f"Alternative provider {alt_provider.value} also failed: {str(e)}") continue - - raise Exception(f"All LLM providers failed. Please check your API key configurations.") + + raise Exception("All LLM providers failed. Please check your API key configurations.") # Legacy method for backward compatibility - def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str: + 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]: + 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_llm_api([ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt} - ], model=model, provider=provider) - + response_text = self.call_llm_api( + [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], + model=model, + provider=provider, + ) + # Strip markdown fences if present clean_text = response_text.strip() if clean_text.startswith("```"): first_newline = clean_text.find("\n") if first_newline != -1: - clean_text = clean_text[first_newline+1:] + clean_text = clean_text[first_newline + 1 :] if clean_text.endswith("```"): clean_text = clean_text[:-3] clean_text = clean_text.strip() - + # Parse JSON result = json.loads(clean_text) return result except json.JSONDecodeError: logger.error(f"JSON parse failed. Raw text: {response_text[:200] if response_text else 'N/A'}") - + # Try extracting JSON substring try: if response_text: - start = response_text.find('{') - end = response_text.rfind('}') + 1 + start = response_text.find("{") + end = response_text.rfind("}") + 1 if start >= 0 and end > start: result = json.loads(response_text[start:end]) return result - except: - pass - - default_structure['report'] = f"Failed to parse analysis result JSON. Raw output (partial): {response_text[:500] if response_text else 'N/A'}" + except Exception as e: + logger.debug(f"Failed to extract JSON substring: {e}") + + default_structure["report"] = ( + f"Failed to parse analysis result JSON. Raw output (partial): {response_text[:500] if response_text else 'N/A'}" + ) return default_structure except Exception as e: logger.error(f"LLM call failed: {str(e)}") - default_structure['report'] = f"Analysis 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"], - }) - + 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/market_data_collector.py b/backend_api_python/app/services/market_data_collector.py index 5c4b8da..e773077 100644 --- a/backend_api_python/app/services/market_data_collector.py +++ b/backend_api_python/app/services/market_data_collector.py @@ -15,17 +15,17 @@ Data source mapping: """ import time -from typing import Dict, List, Any, Optional +from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed from datetime import datetime, timedelta -from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError +from typing import Any, Dict, List, Optional -import yfinance as yf import pandas as pd +import yfinance as yf +from app.config import APIKeys from app.data_sources import DataSourceFactory from app.services.kline import KlineService from app.utils.logger import get_logger -from app.config import APIKeys logger = get_logger(__name__) @@ -33,22 +33,22 @@ logger = get_logger(__name__) class MarketDataCollector: """ Market data collector - + Responsibilities: Provide complete, accurate and timely market data for AI analysis - + Data level: 1. Core data (must succeed): price, K-line 2. Analyze data (enhanced): technical indicators, fundamentals 3. Macro data (optional): reuse global_market.py (VIX, DXY, TNX, Fear&Greed, etc.) 4. Sentiment data (optional): news, market sentiment """ - + def __init__(self): self.kline_service = KlineService() self._finnhub_client = None self._ak = None self._init_clients() - + def _init_clients(self): """Initialize external API client""" # Finnhub @@ -56,17 +56,19 @@ class MarketDataCollector: if finnhub_key: try: import finnhub + self._finnhub_client = finnhub.Client(api_key=finnhub_key) except Exception as e: logger.warning(f"Finnhub client init failed: {e}") - + # akshare (optional, for supplementary data) try: import akshare as ak + self._ak = ak except ImportError: logger.info("akshare not installed") - + def collect_all( self, market: str, @@ -75,11 +77,11 @@ class MarketDataCollector: include_macro: bool = True, include_news: bool = True, include_polymarket: bool = True, # New: Whether to include prediction market data - timeout: int = 30 + timeout: int = 30, ) -> Dict[str, Any]: """ Collect all market data - + Args: market: market type (USStock, Crypto, Forex, Futures) symbol: target code @@ -88,12 +90,12 @@ class MarketDataCollector: include_news: whether to include news include_polymarket: whether to include prediction market data timeout: total timeout (seconds) - + Returns: Complete Market Data Dictionary """ start_time = time.time() - + data = { "market": market, "symbol": symbol, @@ -114,28 +116,24 @@ class MarketDataCollector: # prediction market "polymarket": [], # metadata - "_meta": { - "success_items": [], - "failed_items": [], - "duration_ms": 0 - } + "_meta": {"success_items": [], "failed_items": [], "duration_ms": 0}, } - + # === Phase 1: Core data (parallel acquisition) === with ThreadPoolExecutor(max_workers=4) as executor: core_futures = { executor.submit(self._get_price, market, symbol): "price", executor.submit(self._get_kline, market, symbol, timeframe, 60): "kline", } - + # If fundamentals are needed, also obtain them in parallel - if market in ('USStock', 'CNStock', 'HKStock'): + if market in ("USStock"): core_futures[executor.submit(self._get_fundamental, market, symbol)] = "fundamental" core_futures[executor.submit(self._get_company, market, symbol)] = "company" - elif market == 'Crypto': + elif market == "Crypto": # Cryptocurrency 'fundamentals' are fixed description core_futures[executor.submit(self._get_crypto_info, symbol)] = "fundamental" - + try: for future in as_completed(core_futures, timeout=15): key = core_futures[future] @@ -151,12 +149,12 @@ class MarketDataCollector: data["_meta"]["failed_items"].append(key) except TimeoutError: logger.warning(f"Core data fetch timed out for {market}:{symbol}") - + # Calculate technical indicators (local calculation, no external API required) if data.get("kline"): data["indicators"] = self._calculate_indicators(data["kline"]) data["_meta"]["success_items"].append("indicators") - + # === Stage 2: Macro data (if required) === if include_macro: try: @@ -166,7 +164,7 @@ class MarketDataCollector: except Exception as e: logger.warning(f"Macro data fetch failed: {e}") data["_meta"]["failed_items"].append("macro") - + # === Stage 3: News/Sentiment (if needed) === if include_news: try: @@ -174,17 +172,17 @@ class MarketDataCollector: company_name = None if data.get("company"): company_name = data["company"].get("name") - + news_result = self._get_news(market, symbol, company_name, timeout=8) data["news"] = news_result.get("news", []) data["sentiment"] = news_result.get("sentiment", {}) - + if data["news"]: data["_meta"]["success_items"].append("news") except Exception as e: logger.warning(f"News fetch failed: {e}") data["_meta"]["failed_items"].append("news") - + # === Stage 4: Prediction market data (if required) === if include_polymarket: try: @@ -195,24 +193,24 @@ class MarketDataCollector: except Exception as e: logger.debug(f"Polymarket data fetch failed: {e}") data["_meta"]["failed_items"].append("polymarket") - + # Record the total time spent data["_meta"]["duration_ms"] = int((time.time() - start_time) * 1000) logger.info(f"Market data collection completed for {market}:{symbol} in {data['_meta']['duration_ms']}ms") logger.info(f" Success: {data['_meta']['success_items']}") logger.info(f" Failed: {data['_meta']['failed_items']}") - + return data - + # ==================== Core data acquisition ==================== - + def _get_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]: """ Get live prices - use kline_service (same as watchlist) """ try: price_data = self.kline_service.get_realtime_price(market, symbol, force_refresh=True) - if price_data and price_data.get('price', 0) > 0: + if price_data and price_data.get("price", 0) > 0: # Safe conversion to float, handling None values def safe_float(val, default=0.0): if val is None: @@ -221,51 +219,49 @@ class MarketDataCollector: return float(val) except (ValueError, TypeError): return default - - price = safe_float(price_data.get('price')) + + price = safe_float(price_data.get("price")) return { "price": price, - "change": safe_float(price_data.get('change')), - "changePercent": safe_float(price_data.get('changePercent')), - "high": safe_float(price_data.get('high'), price), - "low": safe_float(price_data.get('low'), price), - "open": safe_float(price_data.get('open'), price), - "previousClose": safe_float(price_data.get('previousClose'), price), - "source": price_data.get('source', 'unknown') + "change": safe_float(price_data.get("change")), + "changePercent": safe_float(price_data.get("changePercent")), + "high": safe_float(price_data.get("high"), price), + "low": safe_float(price_data.get("low"), price), + "open": safe_float(price_data.get("open"), price), + "previousClose": safe_float(price_data.get("previousClose"), price), + "source": price_data.get("source", "unknown"), } except Exception as e: logger.warning(f"Price fetch failed for {market}:{symbol}: {e}") - + # If kline_service fails, try to get the price from the last K-line try: klines = DataSourceFactory.get_kline(market, symbol, "1D", 2) if klines and len(klines) > 0: latest = klines[-1] - price = float(latest.get('close', 0)) + price = float(latest.get("close", 0)) if price > 0: - prev_close = float(klines[-2].get('close', price)) if len(klines) > 1 else price + prev_close = float(klines[-2].get("close", price)) if len(klines) > 1 else price change = price - prev_close change_pct = (change / prev_close * 100) if prev_close > 0 else 0 - + logger.info(f"Price fetched from K-line fallback for {market}:{symbol}: ${price}") return { "price": price, "change": round(change, 6), "changePercent": round(change_pct, 2), - "high": float(latest.get('high', price)), - "low": float(latest.get('low', price)), - "open": float(latest.get('open', price)), + "high": float(latest.get("high", price)), + "low": float(latest.get("low", price)), + "open": float(latest.get("open", price)), "previousClose": prev_close, - "source": "kline_fallback" + "source": "kline_fallback", } except Exception as e: logger.warning(f"K-line fallback price fetch also failed for {market}:{symbol}: {e}") - + return None - - def _get_kline( - self, market: str, symbol: str, timeframe: str, limit: int = 60 - ) -> Optional[List[Dict[str, Any]]]: + + def _get_kline(self, market: str, symbol: str, timeframe: str, limit: int = 60) -> Optional[List[Dict[str, Any]]]: """ Get K-line data - use DataSourceFactory (consistent with K-line module) """ @@ -276,11 +272,11 @@ class MarketDataCollector: except Exception as e: logger.warning(f"Kline fetch failed for {market}:{symbol}: {e}") return None - + def _calculate_indicators(self, klines: List[Dict[str, Any]]) -> Dict[str, Any]: """ Calculate technical indicators (local calculation, no external dependencies) - + The return format meets the expectations of the front-end FastAnalysisReport.vue. Caliber description (aligned with common market terminals): - RSI(14): Wilder smoothing (the average amplitude of the first period is the simple average of the previous 14 periods, and then recursively). @@ -290,19 +286,19 @@ class MarketDataCollector: """ if not klines or len(klines) < 5: return {} - + try: - closes = [float(k.get('close', 0)) for k in klines] - highs = [float(k.get('high', 0)) for k in klines] - lows = [float(k.get('low', 0)) for k in klines] - volumes = [float(k.get('volume', 0)) for k in klines] - + closes = [float(k.get("close", 0)) for k in klines] + highs = [float(k.get("high", 0)) for k in klines] + lows = [float(k.get("low", 0)) for k in klines] + volumes = [float(k.get("volume", 0)) for k in klines] + if not closes: return {} - + current_price = closes[-1] indicators = {} - + # ========== RSI ========== if len(closes) >= 15: rsi_value = self._calc_rsi(closes, 14) @@ -312,18 +308,18 @@ class MarketDataCollector: rsi_signal = "overbought" else: rsi_signal = "neutral" - indicators['rsi'] = { - 'value': round(rsi_value, 2), - 'signal': rsi_signal, + indicators["rsi"] = { + "value": round(rsi_value, 2), + "signal": rsi_signal, } - + # ========== MACD (SMA seed EMA, consistent with common terminals) ========== if len(closes) >= 34: macd_raw = self._calc_macd(closes) - macd_val = macd_raw.get('MACD', 0) - macd_sig = macd_raw.get('MACD_signal', 0) - macd_hist = macd_raw.get('MACD_histogram', 0) - + macd_val = macd_raw.get("MACD", 0) + macd_sig = macd_raw.get("MACD_signal", 0) + macd_hist = macd_raw.get("MACD_histogram", 0) + if macd_val > macd_sig and macd_hist > 0: macd_signal = "bullish" macd_trend = "golden_cross" if macd_hist > 0 else "bullish" @@ -333,20 +329,20 @@ class MarketDataCollector: else: macd_signal = "neutral" macd_trend = "consolidating" - - indicators['macd'] = { - 'value': round(macd_val, 6), - 'signal_line': round(macd_sig, 6), - 'histogram': round(macd_hist, 6), - 'signal': macd_signal, - 'trend': macd_trend, + + indicators["macd"] = { + "value": round(macd_val, 6), + "signal_line": round(macd_sig, 6), + "histogram": round(macd_hist, 6), + "signal": macd_signal, + "trend": macd_trend, } - + # ========== Moving Average ========== ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else current_price ma10 = sum(closes[-10:]) / 10 if len(closes) >= 10 else current_price ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else current_price - + if current_price > ma5 > ma10 > ma20: ma_trend = "strong_uptrend" elif current_price > ma20: @@ -357,26 +353,26 @@ class MarketDataCollector: ma_trend = "downtrend" else: ma_trend = "sideways" - - indicators['moving_averages'] = { - 'ma5': round(ma5, 6), - 'ma10': round(ma10, 6), - 'ma20': round(ma20, 6), - 'trend': ma_trend, + + indicators["moving_averages"] = { + "ma5": round(ma5, 6), + "ma10": round(ma10, 6), + "ma20": round(ma20, 6), + "trend": ma_trend, } # Calculate the Bollinger Bands first and use them to synthesize support/resistance below (key name BB_upper / BB_lower) bb_for_levels: Dict[str, Any] = {} if len(closes) >= 20: bb_for_levels = self._calc_bollinger(closes, 20, 2) or {} - + # ========== Support/Resistance Level (Several Methods Comprehensive) ========== # Method 1: Pivot Points - Use previous day's data if len(klines) >= 2: - prev_high = float(klines[-2].get('high', highs[-2]) if len(highs) >= 2 else current_price * 1.02) - prev_low = float(klines[-2].get('low', lows[-2]) if len(lows) >= 2 else current_price * 0.98) - prev_close = float(klines[-2].get('close', closes[-2]) if len(closes) >= 2 else current_price) - + prev_high = float(klines[-2].get("high", highs[-2]) if len(highs) >= 2 else current_price * 1.02) + prev_low = float(klines[-2].get("low", lows[-2]) if len(lows) >= 2 else current_price * 0.98) + prev_close = float(klines[-2].get("close", closes[-2]) if len(closes) >= 2 else current_price) + pivot = (prev_high + prev_low + prev_close) / 3 r1 = 2 * pivot - prev_low # Resistance level 1 s1 = 2 * pivot - prev_high # Support level 1 @@ -386,40 +382,40 @@ class MarketDataCollector: pivot = current_price r1 = r2 = current_price * 1.02 s1 = s2 = current_price * 0.98 - + # Method 2: Recent highs and lows recent_highs = highs[-20:] if len(highs) >= 20 else highs recent_lows = lows[-20:] if len(lows) >= 20 else lows swing_high = max(recent_highs) if recent_highs else current_price * 1.05 swing_low = min(recent_lows) if recent_lows else current_price * 0.95 - + # Method 3: Bollinger upper and lower rails (consistent with the _calc_bollinger return field) - bb_upper = bb_for_levels.get('BB_upper', swing_high) - bb_lower = bb_for_levels.get('BB_lower', swing_low) - + bb_upper = bb_for_levels.get("BB_upper", swing_high) + bb_lower = bb_for_levels.get("BB_lower", swing_low) + # Comprehensive value: average/weighted by multiple methods resistance = round((r1 + swing_high + bb_upper) / 3, 6) support = round((s1 + swing_low + bb_lower) / 3, 6) - - indicators['levels'] = { - 'support': support, - 'resistance': resistance, - 'pivot': round(pivot, 6), - 's1': round(s1, 6), - 'r1': round(r1, 6), - 's2': round(s2, 6), - 'r2': round(r2, 6), - 'swing_high': round(swing_high, 6), - 'swing_low': round(swing_low, 6), - 'method': 'pivot_swing_bb_avg' # Label calculation method + + indicators["levels"] = { + "support": support, + "resistance": resistance, + "pivot": round(pivot, 6), + "s1": round(s1, 6), + "r1": round(r1, 6), + "s2": round(s2, 6), + "r2": round(r2, 6), + "swing_high": round(swing_high, 6), + "swing_low": round(swing_low, 6), + "method": "pivot_swing_bb_avg", # Label calculation method } - + # ========== ATR and volatility (Wilder ATR, the whole sequence is recursively recursed to the latest one) ========== atr = 0.0 if len(klines) >= 14: atr = float(self._calc_atr_wilder(klines, period=14)) volatility_pct = (atr / current_price * 100) if current_price > 0 else 0 - + if volatility_pct > 5: volatility_level = "high" elif volatility_pct > 2: @@ -429,66 +425,66 @@ class MarketDataCollector: else: volatility_level = "unknown" volatility_pct = 0 - - indicators['volatility'] = { - 'level': volatility_level, - 'pct': round(volatility_pct, 2), - 'atr': round(atr, 6), # Add ATR absolute value + + indicators["volatility"] = { + "level": volatility_level, + "pct": round(volatility_pct, 2), + "atr": round(atr, 6), # Add ATR absolute value } - + # ========== Take Profit and Stop Loss Recommendations (Based on ATR and Support/Resistance) ========== # Stop Loss: Based on 2x ATR or support, whichever is more conservative atr_stop_loss = current_price - (2 * atr) if atr > 0 else current_price * 0.95 - support_stop = indicators['levels']['support'] + support_stop = indicators["levels"]["support"] suggested_stop_loss = max(atr_stop_loss, support_stop * 0.99) # Just below support - + # Take Profit: Based on 3x ATR or resistance level, considering risk reward ratio atr_take_profit = current_price + (3 * atr) if atr > 0 else current_price * 1.05 - resistance_tp = indicators['levels']['resistance'] + resistance_tp = indicators["levels"]["resistance"] suggested_take_profit = min(atr_take_profit, resistance_tp * 1.01) # Just above resistance - + # risk reward ratio risk = current_price - suggested_stop_loss reward = suggested_take_profit - current_price risk_reward_ratio = round(reward / risk, 2) if risk > 0 else 0 - - indicators['trading_levels'] = { - 'suggested_stop_loss': round(suggested_stop_loss, 6), - 'suggested_take_profit': round(suggested_take_profit, 6), - 'risk_reward_ratio': risk_reward_ratio, - 'atr_multiplier_sl': 2.0, # Stop loss using 2x ATR - 'atr_multiplier_tp': 3.0, # Take profit using 3x ATR - 'method': 'atr_support_resistance' + + indicators["trading_levels"] = { + "suggested_stop_loss": round(suggested_stop_loss, 6), + "suggested_take_profit": round(suggested_take_profit, 6), + "risk_reward_ratio": risk_reward_ratio, + "atr_multiplier_sl": 2.0, # Stop loss using 2x ATR + "atr_multiplier_tp": 3.0, # Take profit using 3x ATR + "method": "atr_support_resistance", } - + # ========== Bollinger Bands (additional, same calculation as bb_for_levels) ========== if bb_for_levels: - indicators['bollinger'] = bb_for_levels - + indicators["bollinger"] = bb_for_levels + # ========== Volume (Additional) ========== if len(volumes) >= 20: avg_vol = sum(volumes[-20:]) / 20 - indicators['volume_ratio'] = round(volumes[-1] / avg_vol, 2) if avg_vol > 0 else 1.0 - + indicators["volume_ratio"] = round(volumes[-1] / avg_vol, 2) if avg_vol > 0 else 1.0 + # ========== Price Position (Additional) ========== if len(closes) >= 20: high_20 = max(highs[-20:]) low_20 = min(lows[-20:]) if high_20 > low_20: - indicators['price_position'] = round((current_price - low_20) / (high_20 - low_20) * 100, 1) + indicators["price_position"] = round((current_price - low_20) / (high_20 - low_20) * 100, 1) else: - indicators['price_position'] = 50.0 - + indicators["price_position"] = 50.0 + # ========== Overall Trends (Additional) ========== - indicators['trend'] = ma_trend - indicators['current_price'] = round(current_price, 6) - + indicators["trend"] = ma_trend + indicators["current_price"] = round(current_price, 6) + return indicators - + except Exception as e: logger.warning(f"Indicator calculation failed: {e}") return {} - + def _calc_rsi(self, closes: List[float], period: int = 14) -> float: """Wilder RSI: The average amplitude of the first period is a simple average of the rise and fall of the previous period, and then it is smoothed by Wilder.""" if len(closes) < period + 1: @@ -541,7 +537,7 @@ class MarketDataCollector: ema12 = self._ema_series_sma_seed(closes, 12) ema26 = self._ema_series_sma_seed(closes, 26) if n < 26 or ema12[-1] is None or ema26[-1] is None: - return {'MACD': 0.0, 'MACD_signal': 0.0, 'MACD_histogram': 0.0} + return {"MACD": 0.0, "MACD_signal": 0.0, "MACD_histogram": 0.0} macd_sub: List[float] = [] for i in range(25, n): @@ -551,7 +547,7 @@ class MarketDataCollector: macd_sub.append(v12 - v26) if not macd_sub: - return {'MACD': 0.0, 'MACD_signal': 0.0, 'MACD_histogram': 0.0} + return {"MACD": 0.0, "MACD_signal": 0.0, "MACD_histogram": 0.0} sig_series = self._ema_series_sma_seed(macd_sub, 9) last_macd = macd_sub[-1] @@ -560,25 +556,25 @@ class MarketDataCollector: last_sig = last_macd return { - 'MACD': round(last_macd, 6), - 'MACD_signal': round(last_sig, 6), - 'MACD_histogram': round(last_macd - last_sig, 6), + "MACD": round(last_macd, 6), + "MACD_signal": round(last_sig, 6), + "MACD_histogram": round(last_macd - last_sig, 6), } def _true_ranges(self, klines: List[Dict[str, Any]]) -> List[float]: """True Range for each root of K (the first root is only H−L).""" trs: List[float] = [] for i, k in enumerate(klines): - h = float(k.get('high', 0)) - l = float(k.get('low', 0)) - if h <= 0 or l <= 0: + h = float(k.get("high", 0)) + low_price = float(k.get("low", 0)) + if h <= 0 or low_price <= 0: trs.append(0.0) continue if i == 0: - trs.append(h - l) + trs.append(h - low_price) else: - pc = float(klines[i - 1].get('close', 0)) - trs.append(max(h - l, abs(h - pc), abs(l - pc))) + pc = float(klines[i - 1].get("close", 0)) + trs.append(max(h - low_price, abs(h - pc), abs(low_price - pc))) return trs def _calc_atr_wilder(self, klines: List[Dict[str, Any]], period: int = 14) -> float: @@ -590,274 +586,227 @@ class MarketDataCollector: for i in range(period, len(trs)): atr = (atr * (period - 1) + trs[i]) / period return atr - + def _calc_bollinger(self, closes: List[float], period: int = 20, std_dev: int = 2) -> Dict[str, float]: """Bollinger Bands: The middle rail is the period closing SMA, σ is the overall standard deviation (variance/period), the upper and lower rails = middle rail ±std_dev×σ.""" if len(closes) < period: return {} - + recent = closes[-period:] middle = sum(recent) / period - + variance = sum((x - middle) ** 2 for x in recent) / period - std = variance ** 0.5 - + std = variance**0.5 + return { - 'BB_upper': round(middle + std_dev * std, 4), - 'BB_middle': round(middle, 4), - 'BB_lower': round(middle - std_dev * std, 4), - 'BB_width': round((std_dev * std * 2) / middle * 100, 2) if middle > 0 else 0 + "BB_upper": round(middle + std_dev * std, 4), + "BB_middle": round(middle, 4), + "BB_lower": round(middle - std_dev * std, 4), + "BB_width": round((std_dev * std * 2) / middle * 100, 2) if middle > 0 else 0, } - + # ==================== Fundamental data ==================== - + def _get_fundamental(self, market: str, symbol: str) -> Optional[Dict[str, Any]]: """Get fundamental data""" try: - if market == 'USStock': + if market == "USStock": return self._get_us_fundamental(symbol) - if market in ('CNStock', 'HKStock'): - return self._get_cn_hk_fundamental(market, symbol) except Exception as e: logger.warning(f"Fundamental data fetch failed for {market}:{symbol}: {e}") return None - def _get_cn_hk_fundamental(self, market: str, symbol: str) -> Optional[Dict[str, Any]]: - """ - CN/HK fundamentals — multi-tier: - Tier 1: Twelve Data /statistics (globally stable, paid) - Tier 2: AkShare / Eastmoney (fragile overseas) - + Tencent quote for live price fields - """ - try: - from app.data_sources.tencent import ( - normalize_cn_code, - normalize_hk_code, - fetch_quote, - parse_quote_to_ticker, - ) - from app.data_sources.cn_hk_fundamentals import ( - fetch_twelvedata_fundamental, - fetch_cn_fundamental_akshare, - fetch_hk_fundamental_akshare, - ) - - code = normalize_cn_code(symbol) if market == 'CNStock' else normalize_hk_code(symbol) - is_hk = market == 'HKStock' - - parts = fetch_quote(code) - t = parse_quote_to_ticker(parts) if parts else {} - result: Dict[str, Any] = { - "pe_ratio": None, - "pb_ratio": None, - "ps_ratio": None, - "market_cap": None, - "dividend_yield": None, - "beta": None, - "52w_high": None, - "52w_low": None, - "roe": None, - "eps": None, - "last": t.get("last"), - "previous_close": t.get("previousClose"), - "change_percent": t.get("changePercent"), - "source": "tencent_quote", - } - - # Tier 1: Twelve Data - td = {} - try: - td = fetch_twelvedata_fundamental(code, is_hk) - except Exception as e: - logger.debug("TwelveData fundamental failed %s:%s: %s", market, symbol, e) - - if td: - result["source"] = "tencent_quote+twelvedata" - for k, v in td.items(): - if k == "source": - continue - if v is not None: - result[k] = v - - # Tier 2: AkShare (fill any remaining None fields) - has_valuation = result.get("pe_ratio") is not None or result.get("pb_ratio") is not None - if not has_valuation: - try: - ak_data = fetch_cn_fundamental_akshare(code) if not is_hk else fetch_hk_fundamental_akshare(code) - except Exception as e: - logger.debug("AkShare CN/HK fundamental failed %s:%s: %s", market, symbol, e) - ak_data = {} - if ak_data: - if "twelvedata" not in result.get("source", ""): - result["source"] = "tencent_quote+akshare_em" - else: - result["source"] += "+akshare_em" - for k, v in ak_data.items(): - if k == "source": - continue - if v is not None and result.get(k) is None: - result[k] = v - - if not parts and not td and not has_valuation: - return None - return result - except Exception as e: - logger.debug(f"CN/HK fundamental failed: {market}:{symbol}: {e}") - return None - def _get_us_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]: """ US stock fundamentals - Finnhub + yfinance Includes: basic financial indicators + financial report data (balance sheet, income statement, cash flow statement) """ result = {} - + # === 1. Basic financial indicators (Finnhub) === if self._finnhub_client: try: - metrics = self._finnhub_client.company_basic_financials(symbol, 'all') - if metrics and metrics.get('metric'): - m = metrics['metric'] - result.update({ - 'pe_ratio': m.get('peBasicExclExtraTTM'), - 'pb_ratio': m.get('pbQuarterly'), - 'ps_ratio': m.get('psTTM'), - 'market_cap': m.get('marketCapitalization'), - 'dividend_yield': m.get('dividendYieldIndicatedAnnual'), - 'beta': m.get('beta'), - '52w_high': m.get('52WeekHigh'), - '52w_low': m.get('52WeekLow'), - 'roe': m.get('roeTTM'), - 'eps': m.get('epsBasicExclExtraItemsTTM'), - 'revenue_growth': m.get('revenueGrowthTTMYoy'), - 'profit_margin': m.get('netProfitMarginTTM'), - 'debt_to_equity': m.get('totalDebtToEquityQuarterly'), - 'current_ratio': m.get('currentRatioQuarterly'), - 'quick_ratio': m.get('quickRatioQuarterly'), - }) + metrics = self._finnhub_client.company_basic_financials(symbol, "all") + if metrics and metrics.get("metric"): + m = metrics["metric"] + result.update( + { + "pe_ratio": m.get("peBasicExclExtraTTM"), + "pb_ratio": m.get("pbQuarterly"), + "ps_ratio": m.get("psTTM"), + "market_cap": m.get("marketCapitalization"), + "dividend_yield": m.get("dividendYieldIndicatedAnnual"), + "beta": m.get("beta"), + "52w_high": m.get("52WeekHigh"), + "52w_low": m.get("52WeekLow"), + "roe": m.get("roeTTM"), + "eps": m.get("epsBasicExclExtraItemsTTM"), + "revenue_growth": m.get("revenueGrowthTTMYoy"), + "profit_margin": m.get("netProfitMarginTTM"), + "debt_to_equity": m.get("totalDebtToEquityQuarterly"), + "current_ratio": m.get("currentRatioQuarterly"), + "quick_ratio": m.get("quickRatioQuarterly"), + } + ) except Exception as e: logger.debug(f"Finnhub fundamental failed for {symbol}: {e}") - + # === 2. yfinance supplements basic indicators === try: ticker = yf.Ticker(symbol) info = ticker.info or {} - + # Supplement missing basic indicators - if not result.get('pe_ratio'): - result['pe_ratio'] = info.get('trailingPE') or info.get('forwardPE') - if not result.get('pb_ratio'): - result['pb_ratio'] = info.get('priceToBook') - if not result.get('market_cap'): - result['market_cap'] = info.get('marketCap') - if not result.get('dividend_yield'): - result['dividend_yield'] = info.get('dividendYield') - if not result.get('beta'): - result['beta'] = info.get('beta') - if not result.get('52w_high'): - result['52w_high'] = info.get('fiftyTwoWeekHigh') - if not result.get('52w_low'): - result['52w_low'] = info.get('fiftyTwoWeekLow') - if not result.get('roe'): - result['roe'] = info.get('returnOnEquity') - if not result.get('eps'): - result['eps'] = info.get('trailingEps') - + if not result.get("pe_ratio"): + result["pe_ratio"] = info.get("trailingPE") or info.get("forwardPE") + if not result.get("pb_ratio"): + result["pb_ratio"] = info.get("priceToBook") + if not result.get("market_cap"): + result["market_cap"] = info.get("marketCap") + if not result.get("dividend_yield"): + result["dividend_yield"] = info.get("dividendYield") + if not result.get("beta"): + result["beta"] = info.get("beta") + if not result.get("52w_high"): + result["52w_high"] = info.get("fiftyTwoWeekHigh") + if not result.get("52w_low"): + result["52w_low"] = info.get("fiftyTwoWeekLow") + if not result.get("roe"): + result["roe"] = info.get("returnOnEquity") + if not result.get("eps"): + result["eps"] = info.get("trailingEps") + # Add more financial indicators - result.update({ - 'revenue': info.get('totalRevenue'), - 'gross_profit': info.get('grossProfits'), - 'operating_margin': info.get('operatingMargins'), - 'profit_margin': result.get('profit_margin') or info.get('profitMargins'), - 'ebitda': info.get('ebitda'), - 'debt': info.get('totalDebt'), - 'cash': info.get('totalCash'), - 'free_cash_flow': info.get('freeCashflow'), - 'operating_cash_flow': info.get('operatingCashflow'), - 'book_value': info.get('bookValue'), - 'enterprise_value': info.get('enterpriseValue'), - }) + result.update( + { + "revenue": info.get("totalRevenue"), + "gross_profit": info.get("grossProfits"), + "operating_margin": info.get("operatingMargins"), + "profit_margin": result.get("profit_margin") or info.get("profitMargins"), + "ebitda": info.get("ebitda"), + "debt": info.get("totalDebt"), + "cash": info.get("totalCash"), + "free_cash_flow": info.get("freeCashflow"), + "operating_cash_flow": info.get("operatingCashflow"), + "book_value": info.get("bookValue"), + "enterprise_value": info.get("enterpriseValue"), + } + ) except Exception as e: logger.debug(f"yfinance fundamental failed for {symbol}: {e}") - + # === 3. Obtain financial report data (balance sheet, income statement, cash flow statement) === financial_statements = self._get_financial_statements(symbol) if financial_statements: - result['financial_statements'] = financial_statements - + result["financial_statements"] = financial_statements + # === 4. Get Earnings === earnings_data = self._get_earnings_data(symbol) if earnings_data: - result['earnings'] = earnings_data - + result["earnings"] = earnings_data + return result if result else None - + def _get_financial_statements(self, symbol: str) -> Optional[Dict[str, Any]]: """ Obtain financial statement data (balance sheet, income statement, cash flow statement) - + Use yfinance to obtain, including data for recent quarters """ try: ticker = yf.Ticker(symbol) statements = {} - + # Balance Sheet try: balance_sheet = ticker.balance_sheet if balance_sheet is not None and not balance_sheet.empty: # Get the last 4 quarters - latest_quarters = balance_sheet.columns[:4] if len(balance_sheet.columns) >= 4 else balance_sheet.columns - statements['balance_sheet'] = { - 'latest_date': str(latest_quarters[0]) if len(latest_quarters) > 0 else None, - 'total_assets': float(balance_sheet.loc['Total Assets', latest_quarters[0]]) if 'Total Assets' in balance_sheet.index and len(latest_quarters) > 0 else None, - 'total_liabilities': float(balance_sheet.loc['Total Liab', latest_quarters[0]]) if 'Total Liab' in balance_sheet.index and len(latest_quarters) > 0 else None, - 'total_equity': float(balance_sheet.loc['Stockholders Equity', latest_quarters[0]]) if 'Stockholders Equity' in balance_sheet.index and len(latest_quarters) > 0 else None, - 'cash': float(balance_sheet.loc['Cash', latest_quarters[0]]) if 'Cash' in balance_sheet.index and len(latest_quarters) > 0 else None, - 'debt': float(balance_sheet.loc['Total Debt', latest_quarters[0]]) if 'Total Debt' in balance_sheet.index and len(latest_quarters) > 0 else None, - 'current_assets': float(balance_sheet.loc['Current Assets', latest_quarters[0]]) if 'Current Assets' in balance_sheet.index and len(latest_quarters) > 0 else None, - 'current_liabilities': float(balance_sheet.loc['Current Liabilities', latest_quarters[0]]) if 'Current Liabilities' in balance_sheet.index and len(latest_quarters) > 0 else None, + latest_quarters = ( + balance_sheet.columns[:4] if len(balance_sheet.columns) >= 4 else balance_sheet.columns + ) + statements["balance_sheet"] = { + "latest_date": str(latest_quarters[0]) if len(latest_quarters) > 0 else None, + "total_assets": float(balance_sheet.loc["Total Assets", latest_quarters[0]]) + if "Total Assets" in balance_sheet.index and len(latest_quarters) > 0 + else None, + "total_liabilities": float(balance_sheet.loc["Total Liab", latest_quarters[0]]) + if "Total Liab" in balance_sheet.index and len(latest_quarters) > 0 + else None, + "total_equity": float(balance_sheet.loc["Stockholders Equity", latest_quarters[0]]) + if "Stockholders Equity" in balance_sheet.index and len(latest_quarters) > 0 + else None, + "cash": float(balance_sheet.loc["Cash", latest_quarters[0]]) + if "Cash" in balance_sheet.index and len(latest_quarters) > 0 + else None, + "debt": float(balance_sheet.loc["Total Debt", latest_quarters[0]]) + if "Total Debt" in balance_sheet.index and len(latest_quarters) > 0 + else None, + "current_assets": float(balance_sheet.loc["Current Assets", latest_quarters[0]]) + if "Current Assets" in balance_sheet.index and len(latest_quarters) > 0 + else None, + "current_liabilities": float(balance_sheet.loc["Current Liabilities", latest_quarters[0]]) + if "Current Liabilities" in balance_sheet.index and len(latest_quarters) > 0 + else None, } except Exception as e: logger.debug(f"Balance sheet fetch failed for {symbol}: {e}") - + # Income Statement try: income_stmt = ticker.financials if income_stmt is not None and not income_stmt.empty: latest_quarters = income_stmt.columns[:4] if len(income_stmt.columns) >= 4 else income_stmt.columns - statements['income_statement'] = { - 'latest_date': str(latest_quarters[0]) if len(latest_quarters) > 0 else None, - 'total_revenue': float(income_stmt.loc['Total Revenue', latest_quarters[0]]) if 'Total Revenue' in income_stmt.index and len(latest_quarters) > 0 else None, - 'gross_profit': float(income_stmt.loc['Gross Profit', latest_quarters[0]]) if 'Gross Profit' in income_stmt.index and len(latest_quarters) > 0 else None, - 'operating_income': float(income_stmt.loc['Operating Income', latest_quarters[0]]) if 'Operating Income' in income_stmt.index and len(latest_quarters) > 0 else None, - 'net_income': float(income_stmt.loc['Net Income', latest_quarters[0]]) if 'Net Income' in income_stmt.index and len(latest_quarters) > 0 else None, - 'eps': float(income_stmt.loc['Basic EPS', latest_quarters[0]]) if 'Basic EPS' in income_stmt.index and len(latest_quarters) > 0 else None, + statements["income_statement"] = { + "latest_date": str(latest_quarters[0]) if len(latest_quarters) > 0 else None, + "total_revenue": float(income_stmt.loc["Total Revenue", latest_quarters[0]]) + if "Total Revenue" in income_stmt.index and len(latest_quarters) > 0 + else None, + "gross_profit": float(income_stmt.loc["Gross Profit", latest_quarters[0]]) + if "Gross Profit" in income_stmt.index and len(latest_quarters) > 0 + else None, + "operating_income": float(income_stmt.loc["Operating Income", latest_quarters[0]]) + if "Operating Income" in income_stmt.index and len(latest_quarters) > 0 + else None, + "net_income": float(income_stmt.loc["Net Income", latest_quarters[0]]) + if "Net Income" in income_stmt.index and len(latest_quarters) > 0 + else None, + "eps": float(income_stmt.loc["Basic EPS", latest_quarters[0]]) + if "Basic EPS" in income_stmt.index and len(latest_quarters) > 0 + else None, } except Exception as e: logger.debug(f"Income statement fetch failed for {symbol}: {e}") - + # Cash Flow Statement try: cashflow = ticker.cashflow if cashflow is not None and not cashflow.empty: latest_quarters = cashflow.columns[:4] if len(cashflow.columns) >= 4 else cashflow.columns - statements['cash_flow'] = { - 'latest_date': str(latest_quarters[0]) if len(latest_quarters) > 0 else None, - 'operating_cash_flow': float(cashflow.loc['Operating Cash Flow', latest_quarters[0]]) if 'Operating Cash Flow' in cashflow.index and len(latest_quarters) > 0 else None, - 'investing_cash_flow': float(cashflow.loc['Capital Expenditure', latest_quarters[0]]) if 'Capital Expenditure' in cashflow.index and len(latest_quarters) > 0 else None, - 'financing_cash_flow': float(cashflow.loc['Financing Cash Flow', latest_quarters[0]]) if 'Financing Cash Flow' in cashflow.index and len(latest_quarters) > 0 else None, - 'free_cash_flow': float(cashflow.loc['Free Cash Flow', latest_quarters[0]]) if 'Free Cash Flow' in cashflow.index and len(latest_quarters) > 0 else None, + statements["cash_flow"] = { + "latest_date": str(latest_quarters[0]) if len(latest_quarters) > 0 else None, + "operating_cash_flow": float(cashflow.loc["Operating Cash Flow", latest_quarters[0]]) + if "Operating Cash Flow" in cashflow.index and len(latest_quarters) > 0 + else None, + "investing_cash_flow": float(cashflow.loc["Capital Expenditure", latest_quarters[0]]) + if "Capital Expenditure" in cashflow.index and len(latest_quarters) > 0 + else None, + "financing_cash_flow": float(cashflow.loc["Financing Cash Flow", latest_quarters[0]]) + if "Financing Cash Flow" in cashflow.index and len(latest_quarters) > 0 + else None, + "free_cash_flow": float(cashflow.loc["Free Cash Flow", latest_quarters[0]]) + if "Free Cash Flow" in cashflow.index and len(latest_quarters) > 0 + else None, } except Exception as e: logger.debug(f"Cash flow statement fetch failed for {symbol}: {e}") - + return statements if statements else None - + except Exception as e: logger.debug(f"Financial statements fetch failed for {symbol}: {e}") return None - + def _get_earnings_data(self, symbol: str) -> Optional[Dict[str, Any]]: """ Get earnings report data (Earnings) @@ -865,6 +814,7 @@ class MarketDataCollector: Use quarterly_income_stmt instead of deprecated Ticker.earnings / quarterly_earnings, Historical quarterly summaries are derived from the income statement; the earnings calendar still uses ticker.calendar (if available). """ + def _pick_float(stmt: pd.DataFrame, row_names: tuple, col) -> Optional[float]: for name in row_names: if name in stmt.index: @@ -913,12 +863,14 @@ class MarketDataCollector: earnings_data["history"] = [] for col in cols: eps = _pick_float(q_inc, ("Diluted EPS", "Basic EPS"), col) - earnings_data["history"].append({ - "date": str(col), - "eps_actual": eps, - "eps_estimate": None, - "surprise": None, - }) + earnings_data["history"].append( + { + "date": str(col), + "eps_actual": eps, + "eps_estimate": None, + "surprise": None, + } + ) except Exception as e: logger.debug(f"Quarterly income statement (earnings) fetch failed for {symbol}: {e}") @@ -944,157 +896,83 @@ class MarketDataCollector: except Exception as e: logger.debug(f"Earnings data fetch failed for {symbol}: {e}") return None - + def _get_crypto_info(self, symbol: str) -> Optional[Dict[str, Any]]: """Cryptocurrency information (mainly fixed description)""" # Descriptions of common cryptocurrencies crypto_info = { - 'BTC': { - 'name': 'Bitcoin', - 'description': 'Bitcoin, the leading cryptocurrency by market cap, often viewed as digital gold and a store-of-value asset.', - 'category': 'Store of Value', + "BTC": { + "name": "Bitcoin", + "description": "Bitcoin, the leading cryptocurrency by market cap, often viewed as digital gold and a store-of-value asset.", + "category": "Store of Value", }, - 'ETH': { - 'name': 'Ethereum', - 'description': 'Ethereum, a smart-contract platform and core infrastructure for the DeFi and NFT ecosystem.', - 'category': 'Smart Contract Platform', + "ETH": { + "name": "Ethereum", + "description": "Ethereum, a smart-contract platform and core infrastructure for the DeFi and NFT ecosystem.", + "category": "Smart Contract Platform", }, - 'BNB': { - 'name': 'Binance Coin', - 'description': 'Binance Coin, the platform token of one of the world\'s largest exchanges.', - 'category': 'Exchange Token', + "BNB": { + "name": "Binance Coin", + "description": "Binance Coin, the platform token of one of the world's largest exchanges.", + "category": "Exchange Token", }, - 'SOL': { - 'name': 'Solana', - 'description': 'A high-performance public chain focused on high throughput and low transaction fees.', - 'category': 'Smart Contract Platform', + "SOL": { + "name": "Solana", + "description": "A high-performance public chain focused on high throughput and low transaction fees.", + "category": "Smart Contract Platform", }, - 'XRP': { - 'name': 'Ripple', - 'description': 'Ripple, focused on cross-border payment solutions.', - 'category': 'Payment', + "XRP": { + "name": "Ripple", + "description": "Ripple, focused on cross-border payment solutions.", + "category": "Payment", }, - 'DOGE': { - 'name': 'Dogecoin', - 'description': 'Dogecoin, a community-driven meme coin.', - 'category': 'Meme', + "DOGE": { + "name": "Dogecoin", + "description": "Dogecoin, a community-driven meme coin.", + "category": "Meme", }, } - + # Extract base token name - base = symbol.split('/')[0] if '/' in symbol else symbol + base = symbol.split("/")[0] if "/" in symbol else symbol base = base.upper() - + if base in crypto_info: return crypto_info[base] - + return { - 'name': base, - 'description': f'{base} is a cryptocurrency.', - 'category': 'Unknown', + "name": base, + "description": f"{base} is a cryptocurrency.", + "category": "Unknown", } - + def _get_company(self, market: str, symbol: str) -> Optional[Dict[str, Any]]: """Get company information""" try: - if market == 'USStock' and self._finnhub_client: + if market == "USStock" and self._finnhub_client: profile = self._finnhub_client.company_profile2(symbol=symbol) if profile: return { - 'name': profile.get('name'), - 'industry': profile.get('finnhubIndustry'), - 'country': profile.get('country'), - 'exchange': profile.get('exchange'), - 'ipo_date': profile.get('ipo'), - 'market_cap': profile.get('marketCapitalization'), - 'website': profile.get('weburl'), + "name": profile.get("name"), + "industry": profile.get("finnhubIndustry"), + "country": profile.get("country"), + "exchange": profile.get("exchange"), + "ipo_date": profile.get("ipo"), + "market_cap": profile.get("marketCapitalization"), + "website": profile.get("weburl"), } - if market in ('CNStock', 'HKStock'): - return self._get_cn_hk_company(market, symbol) - + except Exception as e: logger.debug(f"Company info fetch failed for {market}:{symbol}: {e}") - + return None - def _get_cn_hk_company(self, market: str, symbol: str) -> Optional[Dict[str, Any]]: - """ - CN/HK company info — multi-tier: - Tier 1: Twelve Data /profile (globally stable) - Tier 2: AkShare / Eastmoney (fragile overseas) - + Tencent quote for Chinese name - """ - try: - from app.data_sources.tencent import ( - normalize_cn_code, - normalize_hk_code, - fetch_quote, - ) - from app.data_sources.cn_hk_fundamentals import ( - fetch_twelvedata_profile, - fetch_cn_company_extras, - fetch_hk_company_extras, - ) - - code = normalize_cn_code(symbol) if market == 'CNStock' else normalize_hk_code(symbol) - is_hk = market == 'HKStock' - - parts = fetch_quote(code) - cn_name = "" - if parts: - cn_name = (parts[1] or "").strip() if len(parts) > 1 else "" - - row: Dict[str, Any] = { - "name": cn_name or code, - "country": "CN" if market == "CNStock" else "HK", - "exchange": "SSE/SZSE" if market == "CNStock" else "HKEX", - "symbol": code, - "source": "tencent_quote", - } - - # Tier 1: Twelve Data /profile - td_profile = {} - try: - td_profile = fetch_twelvedata_profile(code, is_hk) - except Exception as e: - logger.debug("TwelveData profile failed %s:%s: %s", market, symbol, e) - - if td_profile: - row["source"] = "tencent_quote+twelvedata" - for k in ("industry", "sector", "website", "description", "employees", "full_name"): - v = td_profile.get(k) - if v is not None: - row[k] = v - if not cn_name and td_profile.get("name"): - row["name"] = td_profile["name"] - - # Tier 2: AkShare (fill remaining gaps) - if not row.get("industry"): - try: - ex = fetch_cn_company_extras(code) if not is_hk else fetch_hk_company_extras(code) - except Exception: - ex = {} - if ex: - if "twelvedata" not in row.get("source", ""): - row["source"] = "tencent_quote+akshare_em" - else: - row["source"] += "+akshare_em" - for k in ("industry", "ipo_date", "website", "full_name"): - if ex.get(k) and not row.get(k): - row[k] = ex[k] - - if not parts and not td_profile and not row.get("industry"): - return None - return row - except Exception: - return None - # ==================== Macro data (reusing the global financial sector) ==================== - + def _get_macro_data(self, market: str, timeout: int = 10) -> Dict[str, Any]: """ Get macroeconomic data - reuse global_market.py functions and cache - + Advantages: 1. The data is consistent with the global financial page 2. Reuse 30 seconds/5 minutes cache to reduce API calls @@ -1103,69 +981,71 @@ class MarketDataCollector: try: # Reuse market sentiment data from global_market.py (with 5-minute cache) from app.routes.global_market import ( - _fetch_vix, _fetch_dollar_index, _fetch_yield_curve, + _fetch_dollar_index, _fetch_fear_greed_index, - _get_cached, _set_cached + _fetch_vix, + _fetch_yield_curve, + _get_cached, ) - + result = {} - + # 1) Try to get it from cache (global_market cache, valid for 6 hours) MACRO_CACHE_TTL = 21600 # 6 hours cached_sentiment = _get_cached("market_sentiment", MACRO_CACHE_TTL) if cached_sentiment: logger.info("Using cached sentiment data from global_market (6h cache)") # Convert format - if cached_sentiment.get('vix'): - vix = cached_sentiment['vix'] - result['VIX'] = { - 'name': 'VIX Fear Index', - 'description': vix.get('interpretation', ''), - 'price': vix.get('value', 0), - 'change': vix.get('change', 0), - 'changePercent': vix.get('change', 0), - 'level': vix.get('level', 'unknown'), + if cached_sentiment.get("vix"): + vix = cached_sentiment["vix"] + result["VIX"] = { + "name": "VIX Fear Index", + "description": vix.get("interpretation", ""), + "price": vix.get("value", 0), + "change": vix.get("change", 0), + "changePercent": vix.get("change", 0), + "level": vix.get("level", "unknown"), } - - if cached_sentiment.get('dxy'): - dxy = cached_sentiment['dxy'] - result['DXY'] = { - 'name': 'US Dollar Index', - 'description': dxy.get('interpretation', ''), - 'price': dxy.get('value', 0), - 'change': dxy.get('change', 0), - 'changePercent': dxy.get('change', 0), - 'level': dxy.get('level', 'unknown'), + + if cached_sentiment.get("dxy"): + dxy = cached_sentiment["dxy"] + result["DXY"] = { + "name": "US Dollar Index", + "description": dxy.get("interpretation", ""), + "price": dxy.get("value", 0), + "change": dxy.get("change", 0), + "changePercent": dxy.get("change", 0), + "level": dxy.get("level", "unknown"), } - - if cached_sentiment.get('yield_curve'): - yc = cached_sentiment['yield_curve'] - result['TNX'] = { - 'name': 'US 10Y Treasury Yield', - 'description': yc.get('interpretation', ''), - 'price': yc.get('yield_10y', 0), - 'change': yc.get('change', 0), - 'changePercent': 0, - 'spread': yc.get('spread', 0), - 'level': yc.get('level', 'unknown'), + + if cached_sentiment.get("yield_curve"): + yc = cached_sentiment["yield_curve"] + result["TNX"] = { + "name": "US 10Y Treasury Yield", + "description": yc.get("interpretation", ""), + "price": yc.get("yield_10y", 0), + "change": yc.get("change", 0), + "changePercent": 0, + "spread": yc.get("spread", 0), + "level": yc.get("level", "unknown"), } - - if cached_sentiment.get('fear_greed'): - fg = cached_sentiment['fear_greed'] - result['FEAR_GREED'] = { - 'name': 'Fear & Greed Index', - 'description': fg.get('classification', 'Neutral'), - 'price': fg.get('value', 50), - 'change': 0, - 'changePercent': 0, + + if cached_sentiment.get("fear_greed"): + fg = cached_sentiment["fear_greed"] + result["FEAR_GREED"] = { + "name": "Fear & Greed Index", + "description": fg.get("classification", "Neutral"), + "price": fg.get("value", 50), + "change": 0, + "changePercent": 0, } - + if result: return result - + # 2) If there is no cache, quickly obtain key indicators in parallel logger.info("Fetching macro data from global_market functions") - + with ThreadPoolExecutor(max_workers=4) as executor: futures = { executor.submit(_fetch_vix): "VIX", @@ -1173,7 +1053,7 @@ class MarketDataCollector: executor.submit(_fetch_yield_curve): "TNX", executor.submit(_fetch_fear_greed_index): "FEAR_GREED", } - + try: for future in as_completed(futures, timeout=timeout): key = futures[future] @@ -1181,69 +1061,67 @@ class MarketDataCollector: data = future.result(timeout=5) if data: # Convert to unified format - if key == 'VIX': + if key == "VIX": result[key] = { - 'name': 'VIX Fear Index', - 'description': data.get('interpretation', ''), - 'price': data.get('value', 0), - 'change': data.get('change', 0), - 'changePercent': data.get('change', 0), - 'level': data.get('level', 'unknown'), + "name": "VIX Fear Index", + "description": data.get("interpretation", ""), + "price": data.get("value", 0), + "change": data.get("change", 0), + "changePercent": data.get("change", 0), + "level": data.get("level", "unknown"), } - elif key == 'DXY': + elif key == "DXY": result[key] = { - 'name': 'US Dollar Index', - 'description': data.get('interpretation', ''), - 'price': data.get('value', 0), - 'change': data.get('change', 0), - 'changePercent': data.get('change', 0), - 'level': data.get('level', 'unknown'), + "name": "US Dollar Index", + "description": data.get("interpretation", ""), + "price": data.get("value", 0), + "change": data.get("change", 0), + "changePercent": data.get("change", 0), + "level": data.get("level", "unknown"), } - elif key == 'TNX': + elif key == "TNX": result[key] = { - 'name': 'US 10Y Treasury Yield', - 'description': data.get('interpretation', ''), - 'price': data.get('yield_10y', 0), - 'change': data.get('change', 0), - 'changePercent': 0, - 'spread': data.get('spread', 0), - 'level': data.get('level', 'unknown'), + "name": "US 10Y Treasury Yield", + "description": data.get("interpretation", ""), + "price": data.get("yield_10y", 0), + "change": data.get("change", 0), + "changePercent": 0, + "spread": data.get("spread", 0), + "level": data.get("level", "unknown"), } - elif key == 'FEAR_GREED': + elif key == "FEAR_GREED": result[key] = { - 'name': 'Fear & Greed Index', - 'description': data.get('classification', 'Neutral'), - 'price': data.get('value', 50), - 'change': 0, - 'changePercent': 0, + "name": "Fear & Greed Index", + "description": data.get("classification", "Neutral"), + "price": data.get("value", 50), + "change": 0, + "changePercent": 0, } except Exception as e: logger.debug(f"Macro indicator {key} fetch failed: {e}") except TimeoutError: logger.warning("Macro data fetch timed out") - + # Note: Gold and other commodity data are no longer available as macro indicators # Reasons: 1) If the analysis is gold, the price has been obtained in _get_price # 2) Reduce API calls and improve stability pass - + return result - + except ImportError as e: logger.warning(f"Could not import from global_market: {e}") return {} except Exception as e: logger.error(f"_get_macro_data failed: {e}") return {} - + # ==================== News/Sentiment Data ==================== - - def _get_news( - self, market: str, symbol: str, company_name: str = None, timeout: int = 8 - ) -> Dict[str, Any]: + + def _get_news(self, market: str, symbol: str, company_name: str = None, timeout: int = 8) -> Dict[str, Any]: """ Get news and sentiment data - + Strategies (by priority): 1. Structured API (Finnhub) - the first choice for US stocks 2. Search Engine (Tavily/Google/Bing/SerpAPI) - Supplementary Search @@ -1251,220 +1129,246 @@ class MarketDataCollector: """ news_list = [] sentiment = {} - + # === 1) Finnhub News (Preferred for US stocks) === if self._finnhub_client: try: - end_date = datetime.now().strftime('%Y-%m-%d') - start_date = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d') - + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + raw_news = [] - - if market == 'USStock': + + if market == "USStock": raw_news = self._finnhub_client.company_news(symbol, _from=start_date, to=end_date) - elif market == 'Crypto': + elif market == "Crypto": # General Cryptocurrency News - raw_news = self._finnhub_client.general_news('crypto', min_id=0) + raw_news = self._finnhub_client.general_news("crypto", min_id=0) else: # Other general market news - raw_news = self._finnhub_client.general_news('general', min_id=0) - + raw_news = self._finnhub_client.general_news("general", min_id=0) + if raw_news: for item in raw_news[:10]: - if not item.get('headline'): + if not item.get("headline"): continue - news_list.append({ - "datetime": datetime.fromtimestamp(item.get('datetime', 0)).strftime('%Y-%m-%d %H:%M'), - "headline": item.get('headline', ''), - "summary": item.get('summary', '')[:300] if item.get('summary') else '', - "source": item.get('source', 'Finnhub'), - "url": item.get('url', ''), - "sentiment": item.get('sentiment', 'neutral'), - }) + news_list.append( + { + "datetime": datetime.fromtimestamp(item.get("datetime", 0)).strftime("%Y-%m-%d %H:%M"), + "headline": item.get("headline", ""), + "summary": item.get("summary", "")[:300] if item.get("summary") else "", + "source": item.get("source", "Finnhub"), + "url": item.get("url", ""), + "sentiment": item.get("sentiment", "neutral"), + } + ) logger.info(f"Finnhub news fetched successfully: {len(news_list)} items") except Exception as e: logger.debug(f"Finnhub news fetch failed: {e}") - + # === 2) Finnhub Sentiment Score (U.S. stock social media sentiment) === - if self._finnhub_client and market == 'USStock': + if self._finnhub_client and market == "USStock": try: social = self._finnhub_client.stock_social_sentiment(symbol) if social: - sentiment['reddit'] = social.get('reddit', {}) - sentiment['twitter'] = social.get('twitter', {}) + sentiment["reddit"] = social.get("reddit", {}) + sentiment["twitter"] = social.get("twitter", {}) except Exception as e: logger.debug(f"Finnhub sentiment fetch failed: {e}") - + # === 3) Search engine supplement (if there is too little news) === if len(news_list) < 5: search_news = self._get_news_from_search(market, symbol, company_name) news_list.extend(search_news) - + # === 4) Get news on major global events (geopolitics, wars, etc.) === # These events affect all markets, especially cryptocurrencies global_events = self._get_global_major_events() if global_events: news_list.extend(global_events) logger.info(f"Added {len(global_events)} global major events to news list") - + # Remove duplicates (by title) seen_titles = set() unique_news = [] for item in news_list: - title = item.get('headline', '') + title = item.get("headline", "") if title and title not in seen_titles: seen_titles.add(title) unique_news.append(item) - + # Sort by time - unique_news.sort(key=lambda x: x.get('datetime', ''), reverse=True) - + unique_news.sort(key=lambda x: x.get("datetime", ""), reverse=True) + return { "news": unique_news[:15], # Maximum 15 articles "sentiment": sentiment, } - - def _get_news_from_search( - self, market: str, symbol: str, company_name: str = None - ) -> List[Dict[str, Any]]: + + def _get_news_from_search(self, market: str, symbol: str, company_name: str = None) -> List[Dict[str, Any]]: """ Get news from search engines - + Use enhanced search services (Tavily/Google/Bing/SerpAPI) """ news_list = [] - + try: from app.services.search import get_search_service + search_service = get_search_service() - + if not search_service.is_available: return news_list - + # Build search name search_name = company_name or symbol - + # Search stock news response = search_service.search_stock_news( - stock_code=symbol, - stock_name=search_name, - market=market, - max_results=5 + stock_code=symbol, stock_name=search_name, market=market, max_results=5 ) - + if response.success and response.results: for result in response.results: - news_list.append({ - "datetime": result.published_date or datetime.now().strftime('%Y-%m-%d'), - "headline": result.title, - "summary": result.snippet[:200] if result.snippet else '', - "source": f"Search:{result.source}", - "url": result.url, - "sentiment": result.sentiment, - }) + news_list.append( + { + "datetime": result.published_date or datetime.now().strftime("%Y-%m-%d"), + "headline": result.title, + "summary": result.snippet[:200] if result.snippet else "", + "source": f"Search:{result.source}", + "url": result.url, + "sentiment": result.sentiment, + } + ) logger.info(f"Search-engine news supplement: {len(news_list)} items (provider: {response.provider})") except Exception as e: logger.debug(f"Search-engine news fetch failed: {e}") - + return news_list - + def _get_global_major_events(self) -> List[Dict]: """ Get news on major global events (geopolitics, wars, major policies, etc.) These events affect all markets, especially cryptocurrencies - + Returns: News list of major global events """ news_list = [] - + try: from app.services.search import get_search_service + search_service = get_search_service() - + if not search_service.is_available: return news_list - + # Search for major global events (last 24 hours) # Optimize: Reduce the number of searches and search only the most important queries global_event_queries = [ "war conflict breaking news today" # Search only the most important queries and reduce API calls ] - + for query in global_event_queries: try: response = search_service.search_with_fallback( query=query, max_results=2, - days=1 # Search only the news of the last 1 day + days=1, # Search only the news of the last 1 day ) - + if response.success and response.results: for result in response.results: # Check if it is a major event (including keywords) title_lower = result.title.lower() snippet_lower = (result.snippet or "").lower() text = f"{title_lower} {snippet_lower}" - + # Major event keywords major_event_keywords = [ - "war", "conflict", "military", "attack", "strike", "sanctions", - "geopolitical", "crisis", "tension", "iran", "israel", "russia", - "ukraine", "middle east", "nato", "united states", - "战争", "冲突", "军事", "袭击", "制裁", "地缘政治", "危机" + "war", + "conflict", + "military", + "attack", + "strike", + "sanctions", + "geopolitical", + "crisis", + "tension", + "iran", + "israel", + "russia", + "ukraine", + "middle east", + "nato", + "united states", + "战争", + "冲突", + "军事", + "袭击", + "制裁", + "地缘政治", + "危机", ] - + if any(keyword in text for keyword in major_event_keywords): - news_list.append({ - "datetime": result.published_date or datetime.now().strftime('%Y-%m-%d %H:%M'), - "headline": result.title, - "summary": result.snippet[:300] if result.snippet else '', - "source": f"Global event:{result.source}", - "url": result.url, - "sentiment": "negative" if any(kw in text for kw in ["war", "conflict", "attack", "战争", "冲突", "袭击"]) else "neutral", - "is_global_event": True # Flag as global event - }) + news_list.append( + { + "datetime": result.published_date or datetime.now().strftime("%Y-%m-%d %H:%M"), + "headline": result.title, + "summary": result.snippet[:300] if result.snippet else "", + "source": f"Global event:{result.source}", + "url": result.url, + "sentiment": "negative" + if any( + kw in text for kw in ["war", "conflict", "attack", "战争", "冲突", "袭击"] + ) + else "neutral", + "is_global_event": True, # Flag as global event + } + ) logger.info(f"Found global major event: {result.title[:60]}") except Exception as e: logger.debug(f"Failed to search global events with query '{query}': {e}") continue - + # Remove duplicates seen_titles = set() unique_events = [] for item in news_list: - title = item.get('headline', '') + title = item.get("headline", "") if title and title not in seen_titles: seen_titles.add(title) unique_events.append(item) - + return unique_events[:5] # Returns up to 5 major global events - + except Exception as e: logger.debug(f"Failed to get global major events: {e}") return [] - + def _get_polymarket_events(self, symbol: str, market: str) -> List[Dict]: """ Get prediction market events related to an asset Directly call Polymarket API to obtain real-time data without relying on local database - + Args: symbol: asset symbol market: market type - + Returns: List of related prediction market events """ try: from app.data_sources.polymarket import PolymarketDataSource - + polymarket_source = PolymarketDataSource() - + # Extract keywords keywords = self._extract_polymarket_keywords(symbol, market) logger.info(f"Extracted Polymarket keywords for {symbol}: {keywords}") - + # Optimization: Use cache acceleration to reduce API call time # For AI analysis, use short-term cache (5 minutes) to ensure timeliness and improve performance. # Further optimization: limit the number of keywords and search only the most important keywords (up to 2) @@ -1479,78 +1383,84 @@ class MarketDataCollector: except Exception as e: logger.warning(f"Failed to search Polymarket for keyword '{keyword}': {e}") continue - + # Remove duplicates seen = set() result = [] for market_data in related_markets: - market_id = market_data.get('market_id') + market_id = market_data.get("market_id") if market_id and market_id not in seen: seen.add(market_id) # Build the correct Polymarket URL # Prioritize using the existing polymarket_url, if not, build it based on slug or market_id - polymarket_url = market_data.get('polymarket_url') + polymarket_url = market_data.get("polymarket_url") if not polymarket_url: - slug = market_data.get('slug') - if slug and not str(slug).isdigit() and ('-' in str(slug) or any(c.isalpha() for c in str(slug))): + slug = market_data.get("slug") + if ( + slug + and not str(slug).isdigit() + and ("-" in str(slug) or any(c.isalpha() for c in str(slug))) + ): # Use a valid slug polymarket_url = f"https://polymarket.com/event/{slug}" else: # Use markets endpoint (more reliable) polymarket_url = f"https://polymarket.com/markets/{market_id}" - - result.append({ - "market_id": market_id, - "question": market_data.get('question', ''), - "current_probability": market_data.get('current_probability', 50.0), - "volume_24h": market_data.get('volume_24h', 0), - "liquidity": market_data.get('liquidity', 0), - "category": market_data.get('category', 'other'), - "polymarket_url": polymarket_url - }) - + + result.append( + { + "market_id": market_id, + "question": market_data.get("question", ""), + "current_probability": market_data.get("current_probability", 50.0), + "volume_24h": market_data.get("volume_24h", 0), + "liquidity": market_data.get("liquidity", 0), + "category": market_data.get("category", "other"), + "polymarket_url": polymarket_url, + } + ) + logger.info(f"Total {len(result)} unique Polymarket events found for {symbol}") return result except Exception as e: logger.debug(f"Failed to get polymarket events for {symbol}: {e}") return [] - + def _extract_polymarket_keywords(self, symbol: str, market: str) -> List[str]: """ Extract keywords used to search prediction markets Optimization: Only retain the most important keywords and reduce the number of API calls """ keywords = [] - + # Basic symbols (most important) - if '/' in symbol: - base = symbol.split('/')[0] + if "/" in symbol: + base = symbol.split("/")[0] keywords.append(base) else: keywords.append(symbol) - + # Cryptocurrency full name mapping (retain only the most important full name to avoid duplication) crypto_names = { - 'BTC': 'Bitcoin', - 'ETH': 'Ethereum', - 'SOL': 'Solana', - 'BNB': 'Binance', - 'XRP': 'Ripple', - 'ADA': 'Cardano', - 'DOGE': 'Dogecoin', - 'AVAX': 'Avalanche', - 'DOT': 'Polkadot', - 'MATIC': 'Polygon' + "BTC": "Bitcoin", + "ETH": "Ethereum", + "SOL": "Solana", + "BNB": "Binance", + "XRP": "Ripple", + "ADA": "Cardano", + "DOGE": "Dogecoin", + "AVAX": "Avalanche", + "DOT": "Polkadot", + "MATIC": "Polygon", } - - base_symbol = symbol.split('/')[0] if '/' in symbol else symbol + + base_symbol = symbol.split("/")[0] if "/" in symbol else symbol if base_symbol in crypto_names: # Add only one full name to avoid duplication of upper and lower case keywords.append(crypto_names[base_symbol]) - + # Optimization: Remove generic keywords (such as '$100k', 'ETF', 'approval'), which will match many irrelevant markets # Only keep keywords directly related to assets, up to 2-3 - + # Remove duplicates and limit the number (maximum 3 keywords) unique_keywords = [] seen = set() @@ -1561,14 +1471,17 @@ class MarketDataCollector: unique_keywords.append(kw) if len(unique_keywords) >= 3: # Up to 3 keywords break - - logger.info(f"Extracted {len(unique_keywords)} Polymarket keywords (optimized from {len(keywords)}): {unique_keywords}") + + logger.info( + f"Extracted {len(unique_keywords)} Polymarket keywords (optimized from {len(keywords)}): {unique_keywords}" + ) return unique_keywords # global instance _collector: Optional[MarketDataCollector] = None + def get_market_data_collector() -> MarketDataCollector: """Get market data collector singleton""" global _collector diff --git a/backend_api_python/app/services/mt5_trading/client.py b/backend_api_python/app/services/mt5_trading/client.py index f4017f9..9c1a36a 100644 --- a/backend_api_python/app/services/mt5_trading/client.py +++ b/backend_api_python/app/services/mt5_trading/client.py @@ -5,14 +5,13 @@ Uses official MetaTrader5 Python library to connect to MT5 terminal for trading. Note: Requires Windows platform and MT5 terminal installed. """ -import time import threading from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List from datetime import datetime +from typing import Any, Dict, List, Optional +from app.services.mt5_trading.symbols import normalize_symbol from app.utils.logger import get_logger -from app.services.mt5_trading.symbols import normalize_symbol, parse_symbol logger = get_logger(__name__) @@ -26,6 +25,7 @@ def _ensure_mt5(): if mt5 is None: try: import MetaTrader5 as _mt5 + mt5 = _mt5 except ImportError: raise ImportError( @@ -38,6 +38,7 @@ def _ensure_mt5(): @dataclass class MT5Config: """MT5 connection configuration.""" + login: int = 0 # MT5 account number password: str = "" # MT5 password server: str = "" # Broker server name (e.g., "ICMarkets-Demo") @@ -49,6 +50,7 @@ class MT5Config: @dataclass class OrderResult: """Order execution result.""" + success: bool order_id: int = 0 deal_id: int = 0 @@ -62,7 +64,7 @@ class OrderResult: class MT5Client: """ MetaTrader 5 Trading Client - + Usage: config = MT5Config( login=12345678, @@ -70,22 +72,22 @@ class MT5Client: server="ICMarkets-Demo" ) client = MT5Client(config) - + if client.connect(): # Place order result = client.place_market_order("EURUSD", "buy", 0.1) - + # Get positions positions = client.get_positions() - + client.disconnect() """ - + def __init__(self, config: Optional[MT5Config] = None): self.config = config or MT5Config() self._connected = False self._lock = threading.Lock() - + @property def connected(self) -> bool: """Check if connected to MT5 terminal.""" @@ -97,63 +99,65 @@ class MT5Client: return info is not None and info.connected except Exception: return False - + def connect(self) -> bool: """ Connect to MT5 terminal. - + Returns: True if connected successfully """ with self._lock: if self.connected: return True - + try: _ensure_mt5() - + # Initialize MT5 connection init_params = {} - + if self.config.terminal_path: init_params["path"] = self.config.terminal_path - + if self.config.login and self.config.password and self.config.server: init_params["login"] = self.config.login init_params["password"] = self.config.password init_params["server"] = self.config.server init_params["timeout"] = self.config.timeout - + logger.info(f"Connecting to MT5: server={self.config.server}, login={self.config.login}") - + if init_params: initialized = mt5.initialize(**init_params) else: # Connect to already running terminal initialized = mt5.initialize() - + if not initialized: error = mt5.last_error() logger.error(f"MT5 initialization failed: {error}") return False - + self._connected = True - + # Log account info account_info = mt5.account_info() if account_info: - logger.info(f"MT5 connected: account={account_info.login}, " - f"server={account_info.server}, balance={account_info.balance}") + logger.info( + f"MT5 connected: account={account_info.login}, " + f"server={account_info.server}, balance={account_info.balance}" + ) else: logger.warning("MT5 connected but account info not available") - + return True - + except Exception as e: logger.error(f"MT5 connection failed: {e}") self._connected = False return False - + def disconnect(self): """Disconnect from MT5 terminal.""" with self._lock: @@ -166,15 +170,15 @@ class MT5Client: finally: self._connected = False logger.info("MT5 disconnected") - + def _ensure_connected(self): """Ensure connection is established.""" if not self.connected: if not self.connect(): raise ConnectionError("Cannot connect to MT5 terminal") - + # ==================== Order Methods ==================== - + def place_market_order( self, symbol: str, @@ -185,65 +189,54 @@ class MT5Client: ) -> OrderResult: """ Place a market order. - + Args: symbol: Trading symbol (e.g., "EURUSD") side: Direction ("buy" or "sell") volume: Lot size (e.g., 0.1 = 1 mini lot) deviation: Maximum price deviation in points comment: Order comment - + Returns: OrderResult """ try: self._ensure_connected() _ensure_mt5() - + # Normalize symbol symbol = normalize_symbol(symbol) - + # Get symbol info symbol_info = mt5.symbol_info(symbol) if symbol_info is None: - return OrderResult( - success=False, - message=f"Symbol not found: {symbol}" - ) - + return OrderResult(success=False, message=f"Symbol not found: {symbol}") + # Validate volume against symbol constraints volume_float = float(volume) if volume_float < symbol_info.volume_min: return OrderResult( - success=False, - message=f"Volume {volume_float} is less than minimum {symbol_info.volume_min}" + success=False, message=f"Volume {volume_float} is less than minimum {symbol_info.volume_min}" ) if volume_float > symbol_info.volume_max: return OrderResult( - success=False, - message=f"Volume {volume_float} exceeds maximum {symbol_info.volume_max}" + success=False, message=f"Volume {volume_float} exceeds maximum {symbol_info.volume_max}" ) # Round volume to lot step volume_step = symbol_info.volume_step if volume_step > 0: volume_float = round(volume_float / volume_step) * volume_step - + if not symbol_info.visible: # Enable symbol in Market Watch if not mt5.symbol_select(symbol, True): - return OrderResult( - success=False, - message=f"Failed to select symbol: {symbol}" - ) - + return OrderResult(success=False, message=f"Failed to select symbol: {symbol}") + # Get current price tick = mt5.symbol_info_tick(symbol) if tick is None: - return OrderResult( - success=False, - message=f"Failed to get tick for: {symbol}" - ) - + return OrderResult(success=False, message=f"Failed to get tick for: {symbol}") + # Determine order type and price if side.lower() == "buy": order_type = mt5.ORDER_TYPE_BUY @@ -251,7 +244,7 @@ class MT5Client: else: order_type = mt5.ORDER_TYPE_SELL price = tick.bid - + # Determine filling mode based on symbol properties # Different brokers support different filling modes filling_mode = mt5.ORDER_FILLING_IOC # Default @@ -261,7 +254,7 @@ class MT5Client: filling_mode = mt5.ORDER_FILLING_FOK elif symbol_info.filling_mode & mt5.ORDER_FILLING_RETURN: filling_mode = mt5.ORDER_FILLING_RETURN - + # Prepare order request request = { "action": mt5.TRADE_ACTION_DEAL, @@ -275,26 +268,23 @@ class MT5Client: "type_time": mt5.ORDER_TIME_GTC, "type_filling": filling_mode, } - + # Send order result = mt5.order_send(request) - + if result is None: error = mt5.last_error() - return OrderResult( - success=False, - message=f"Order send failed: {error}" - ) - + return OrderResult(success=False, message=f"Order send failed: {error}") + if result.retcode != mt5.TRADE_RETCODE_DONE: return OrderResult( success=False, - order_id=result.order if hasattr(result, 'order') else 0, + order_id=result.order if hasattr(result, "order") else 0, status=str(result.retcode), message=f"Order rejected: {result.comment}", - raw=result._asdict() if hasattr(result, '_asdict') else {} + raw=result._asdict() if hasattr(result, "_asdict") else {}, ) - + return OrderResult( success=True, order_id=result.order, @@ -303,16 +293,13 @@ class MT5Client: price=result.price, status="filled", message="Order executed", - raw=result._asdict() if hasattr(result, '_asdict') else {} + raw=result._asdict() if hasattr(result, "_asdict") else {}, ) - + except Exception as e: logger.error(f"Market order failed: {e}") - return OrderResult( - success=False, - message=str(e) - ) - + return OrderResult(success=False, message=str(e)) + def place_limit_order( self, symbol: str, @@ -323,40 +310,34 @@ class MT5Client: ) -> OrderResult: """ Place a pending limit order. - + Args: symbol: Trading symbol side: Direction ("buy" or "sell") volume: Lot size price: Limit price comment: Order comment - + Returns: OrderResult """ try: self._ensure_connected() _ensure_mt5() - + symbol = normalize_symbol(symbol) - + symbol_info = mt5.symbol_info(symbol) if symbol_info is None: - return OrderResult( - success=False, - message=f"Symbol not found: {symbol}" - ) - + return OrderResult(success=False, message=f"Symbol not found: {symbol}") + if not symbol_info.visible: mt5.symbol_select(symbol, True) - + tick = mt5.symbol_info_tick(symbol) if tick is None: - return OrderResult( - success=False, - message=f"Failed to get tick for: {symbol}" - ) - + return OrderResult(success=False, message=f"Failed to get tick for: {symbol}") + # Determine order type based on side and price relative to market if side.lower() == "buy": if price < tick.ask: @@ -368,7 +349,7 @@ class MT5Client: order_type = mt5.ORDER_TYPE_SELL_LIMIT else: order_type = mt5.ORDER_TYPE_SELL_STOP - + request = { "action": mt5.TRADE_ACTION_PENDING, "symbol": symbol, @@ -379,39 +360,33 @@ class MT5Client: "comment": comment, "type_time": mt5.ORDER_TIME_GTC, } - + result = mt5.order_send(request) - + if result is None: error = mt5.last_error() - return OrderResult( - success=False, - message=f"Order send failed: {error}" - ) - + return OrderResult(success=False, message=f"Order send failed: {error}") + if result.retcode != mt5.TRADE_RETCODE_DONE: return OrderResult( success=False, status=str(result.retcode), message=f"Order rejected: {result.comment}", ) - + return OrderResult( success=True, order_id=result.order, price=price, status="pending", message="Pending order placed", - raw=result._asdict() if hasattr(result, '_asdict') else {} + raw=result._asdict() if hasattr(result, "_asdict") else {}, ) - + except Exception as e: logger.error(f"Limit order failed: {e}") - return OrderResult( - success=False, - message=str(e) - ) - + return OrderResult(success=False, message=str(e)) + def close_position( self, ticket: int, @@ -421,47 +396,38 @@ class MT5Client: ) -> OrderResult: """ Close an open position. - + Args: ticket: Position ticket number volume: Volume to close (None = close all) deviation: Maximum price deviation comment: Order comment - + Returns: OrderResult """ try: self._ensure_connected() _ensure_mt5() - + # Get position info position = mt5.positions_get(ticket=ticket) if not position: - return OrderResult( - success=False, - message=f"Position not found: {ticket}" - ) - + return OrderResult(success=False, message=f"Position not found: {ticket}") + pos = position[0] symbol = pos.symbol - + # Get symbol info for filling mode symbol_info = mt5.symbol_info(symbol) if symbol_info is None: - return OrderResult( - success=False, - message=f"Symbol not found: {symbol}" - ) - + return OrderResult(success=False, message=f"Symbol not found: {symbol}") + # Get tick tick = mt5.symbol_info_tick(symbol) if tick is None: - return OrderResult( - success=False, - message=f"Failed to get tick for: {symbol}" - ) - + return OrderResult(success=False, message=f"Failed to get tick for: {symbol}") + # Determine close direction and price if pos.type == mt5.POSITION_TYPE_BUY: order_type = mt5.ORDER_TYPE_SELL @@ -469,9 +435,9 @@ class MT5Client: else: order_type = mt5.ORDER_TYPE_BUY price = tick.ask - + close_volume = volume if volume else pos.volume - + # Determine filling mode based on symbol properties filling_mode = mt5.ORDER_FILLING_IOC # Default if symbol_info.filling_mode & mt5.ORDER_FILLING_IOC: @@ -480,7 +446,7 @@ class MT5Client: filling_mode = mt5.ORDER_FILLING_FOK elif symbol_info.filling_mode & mt5.ORDER_FILLING_RETURN: filling_mode = mt5.ORDER_FILLING_RETURN - + request = { "action": mt5.TRADE_ACTION_DEAL, "symbol": symbol, @@ -494,15 +460,14 @@ class MT5Client: "type_time": mt5.ORDER_TIME_GTC, "type_filling": filling_mode, } - + result = mt5.order_send(request) - + if result is None or result.retcode != mt5.TRADE_RETCODE_DONE: return OrderResult( - success=False, - message=f"Close failed: {result.comment if result else 'Unknown error'}" + success=False, message=f"Close failed: {result.comment if result else 'Unknown error'}" ) - + return OrderResult( success=True, order_id=result.order, @@ -512,63 +477,60 @@ class MT5Client: status="closed", message="Position closed", ) - + except Exception as e: logger.error(f"Close position failed: {e}") - return OrderResult( - success=False, - message=str(e) - ) - + return OrderResult(success=False, message=str(e)) + def cancel_order(self, ticket: int) -> bool: """ Cancel a pending order. - + Args: ticket: Order ticket number - + Returns: True if cancelled successfully """ try: self._ensure_connected() _ensure_mt5() - + request = { "action": mt5.TRADE_ACTION_REMOVE, "order": ticket, } - + result = mt5.order_send(request) - + if result is None or result.retcode != mt5.TRADE_RETCODE_DONE: logger.warning(f"Cancel order failed: {result.comment if result else 'Unknown'}") return False - + logger.info(f"Order {ticket} cancelled") return True - + except Exception as e: logger.error(f"Cancel order failed: {e}") return False - + # ==================== Query Methods ==================== - + def get_account_info(self) -> Dict[str, Any]: """ Get account information. - + Returns: Account info dictionary """ try: self._ensure_connected() _ensure_mt5() - + info = mt5.account_info() if info is None: return {"success": False, "error": "Failed to get account info"} - + return { "success": True, "login": info.login, @@ -585,79 +547,81 @@ class MT5Client: "trade_allowed": info.trade_allowed, "trade_expert": info.trade_expert, } - + except Exception as e: logger.error(f"Get account info failed: {e}") return {"success": False, "error": str(e)} - + def get_positions(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: """ Get open positions. - + Args: symbol: Filter by symbol (optional) - + Returns: List of positions """ try: self._ensure_connected() _ensure_mt5() - + if symbol: positions = mt5.positions_get(symbol=normalize_symbol(symbol)) else: positions = mt5.positions_get() - + if positions is None: return [] - + result = [] for pos in positions: - result.append({ - "ticket": pos.ticket, - "symbol": pos.symbol, - "type": "buy" if pos.type == mt5.POSITION_TYPE_BUY else "sell", - "volume": pos.volume, - "price_open": pos.price_open, - "price_current": pos.price_current, - "sl": pos.sl, - "tp": pos.tp, - "profit": pos.profit, - "swap": pos.swap, - "magic": pos.magic, - "comment": pos.comment, - "time": datetime.fromtimestamp(pos.time).isoformat(), - }) - + result.append( + { + "ticket": pos.ticket, + "symbol": pos.symbol, + "type": "buy" if pos.type == mt5.POSITION_TYPE_BUY else "sell", + "volume": pos.volume, + "price_open": pos.price_open, + "price_current": pos.price_current, + "sl": pos.sl, + "tp": pos.tp, + "profit": pos.profit, + "swap": pos.swap, + "magic": pos.magic, + "comment": pos.comment, + "time": datetime.fromtimestamp(pos.time).isoformat(), + } + ) + return result - + except Exception as e: logger.error(f"Get positions failed: {e}") return [] - + def get_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: """ Get pending orders. - + Args: symbol: Filter by symbol (optional) - + Returns: List of orders """ try: self._ensure_connected() _ensure_mt5() - + if symbol: orders = mt5.orders_get(symbol=normalize_symbol(symbol)) else: orders = mt5.orders_get() - + if orders is None: return [] - + result = [] for order in orders: order_type_map = { @@ -666,56 +630,58 @@ class MT5Client: mt5.ORDER_TYPE_BUY_STOP: "buy_stop", mt5.ORDER_TYPE_SELL_STOP: "sell_stop", } - - result.append({ - "ticket": order.ticket, - "symbol": order.symbol, - "type": order_type_map.get(order.type, str(order.type)), - "volume_initial": order.volume_initial, - "volume_current": order.volume_current, - "price_open": order.price_open, - "price_current": order.price_current, - "sl": order.sl, - "tp": order.tp, - "magic": order.magic, - "comment": order.comment, - "time_setup": datetime.fromtimestamp(order.time_setup).isoformat(), - }) - + + result.append( + { + "ticket": order.ticket, + "symbol": order.symbol, + "type": order_type_map.get(order.type, str(order.type)), + "volume_initial": order.volume_initial, + "volume_current": order.volume_current, + "price_open": order.price_open, + "price_current": order.price_current, + "sl": order.sl, + "tp": order.tp, + "magic": order.magic, + "comment": order.comment, + "time_setup": datetime.fromtimestamp(order.time_setup).isoformat(), + } + ) + return result - + except Exception as e: logger.error(f"Get orders failed: {e}") return [] - + def get_quote(self, symbol: str) -> Dict[str, Any]: """ Get real-time quote. - + Args: symbol: Symbol code - + Returns: Quote data """ try: self._ensure_connected() _ensure_mt5() - + symbol = normalize_symbol(symbol) - + # Select symbol symbol_info = mt5.symbol_info(symbol) if symbol_info is None: return {"success": False, "error": f"Symbol not found: {symbol}"} - + if not symbol_info.visible: mt5.symbol_select(symbol, True) - + tick = mt5.symbol_info_tick(symbol) if tick is None: return {"success": False, "error": f"Failed to get tick: {symbol}"} - + return { "success": True, "symbol": symbol, @@ -726,58 +692,60 @@ class MT5Client: "time": datetime.fromtimestamp(tick.time).isoformat(), "spread": round((tick.ask - tick.bid) / symbol_info.point, 1), } - + except Exception as e: logger.error(f"Get quote failed: {e}") return {"success": False, "error": str(e)} - + def get_symbols(self, group: str = "*") -> List[Dict[str, Any]]: """ Get available symbols. - + Args: group: Filter by group pattern (e.g., "*USD*", "Forex*") - + Returns: List of symbol info """ try: self._ensure_connected() _ensure_mt5() - + symbols = mt5.symbols_get(group=group) if symbols is None: return [] - + result = [] for s in symbols: - result.append({ - "name": s.name, - "description": s.description, - "path": s.path, - "currency_base": s.currency_base, - "currency_profit": s.currency_profit, - "digits": s.digits, - "point": s.point, - "trade_mode": s.trade_mode, - "volume_min": s.volume_min, - "volume_max": s.volume_max, - "volume_step": s.volume_step, - }) - + result.append( + { + "name": s.name, + "description": s.description, + "path": s.path, + "currency_base": s.currency_base, + "currency_profit": s.currency_profit, + "digits": s.digits, + "point": s.point, + "trade_mode": s.trade_mode, + "volume_min": s.volume_min, + "volume_max": s.volume_max, + "volume_step": s.volume_step, + } + ) + return result - + except Exception as e: logger.error(f"Get symbols failed: {e}") return [] - + def get_connection_status(self) -> Dict[str, Any]: """Get connection status.""" try: _ensure_mt5() terminal_info = mt5.terminal_info() if self._connected else None account_info = mt5.account_info() if self._connected else None - + return { "connected": self.connected, "login": self.config.login, @@ -802,15 +770,15 @@ _global_lock = threading.Lock() def get_mt5_client(config: Optional[MT5Config] = None) -> MT5Client: """ Get global MT5 client singleton. - + Args: config: Configuration (only effective on first call) - + Returns: MT5Client instance """ global _global_client - + with _global_lock: if _global_client is None: _global_client = MT5Client(config) @@ -820,7 +788,7 @@ def get_mt5_client(config: Optional[MT5Config] = None) -> MT5Client: def reset_mt5_client(): """Reset global client (disconnect and clear instance).""" global _global_client - + with _global_lock: if _global_client is not None: _global_client.disconnect() diff --git a/backend_api_python/app/services/mt5_trading/symbols.py b/backend_api_python/app/services/mt5_trading/symbols.py index 4af1bd5..e2cd7ec 100644 --- a/backend_api_python/app/services/mt5_trading/symbols.py +++ b/backend_api_python/app/services/mt5_trading/symbols.py @@ -4,90 +4,135 @@ Symbol Mapping and Conversion for MT5 Handles forex symbol normalization and parsing. """ -from typing import Tuple, Optional - +from typing import Optional, Tuple # Common forex pairs with their typical MT5 symbol format FOREX_PAIRS = { # Major pairs - "EURUSD", "GBPUSD", "USDJPY", "USDCHF", "AUDUSD", "USDCAD", "NZDUSD", + "EURUSD", + "GBPUSD", + "USDJPY", + "USDCHF", + "AUDUSD", + "USDCAD", + "NZDUSD", # Cross pairs - "EURGBP", "EURJPY", "EURCHF", "EURAUD", "EURCAD", "EURNZD", - "GBPJPY", "GBPCHF", "GBPAUD", "GBPCAD", "GBPNZD", - "AUDJPY", "AUDCHF", "AUDCAD", "AUDNZD", - "NZDJPY", "NZDCHF", "NZDCAD", - "CADJPY", "CADCHF", + "EURGBP", + "EURJPY", + "EURCHF", + "EURAUD", + "EURCAD", + "EURNZD", + "GBPJPY", + "GBPCHF", + "GBPAUD", + "GBPCAD", + "GBPNZD", + "AUDJPY", + "AUDCHF", + "AUDCAD", + "AUDNZD", + "NZDJPY", + "NZDCHF", + "NZDCAD", + "CADJPY", + "CADCHF", "CHFJPY", # Exotic pairs - "USDMXN", "USDZAR", "USDTRY", "USDHKD", "USDSGD", "USDNOK", "USDSEK", "USDDKK", - "EURTRY", "EURMXN", "EURNOK", "EURSEK", "EURDKK", "EURPLN", "EURHUF", "EURCZK", + "USDMXN", + "USDZAR", + "USDTRY", + "USDHKD", + "USDSGD", + "USDNOK", + "USDSEK", + "USDDKK", + "EURTRY", + "EURMXN", + "EURNOK", + "EURSEK", + "EURDKK", + "EURPLN", + "EURHUF", + "EURCZK", # Metals - "XAUUSD", "XAGUSD", "XAUEUR", + "XAUUSD", + "XAGUSD", + "XAUEUR", # Indices (CFD) - "US30", "US500", "USTEC", "UK100", "DE40", "JP225", "AU200", + "US30", + "US500", + "USTEC", + "UK100", + "DE40", + "JP225", + "AU200", # Crypto (if broker supports) - "BTCUSD", "ETHUSD", "LTCUSD", "XRPUSD", + "BTCUSD", + "ETHUSD", + "LTCUSD", + "XRPUSD", } def normalize_symbol(symbol: str, broker_suffix: str = "") -> str: """ Normalize symbol to MT5 format. - + Different brokers may use different suffixes: - No suffix: "EURUSD" - With suffix: "EURUSDm", "EURUSD.raw", "EURUSD-ECN" - + Args: symbol: Symbol code (e.g., "EUR/USD", "EURUSD", "eurusd") broker_suffix: Broker-specific suffix (e.g., "m", ".raw", "-ECN") - + Returns: Normalized MT5 symbol """ # Remove common separators and convert to uppercase normalized = (symbol or "").strip().upper() normalized = normalized.replace("/", "").replace("-", "").replace("_", "").replace(" ", "") - + # Add broker suffix if provided if broker_suffix: # Check if symbol already has the suffix if not normalized.endswith(broker_suffix.upper()): normalized = normalized + broker_suffix - + return normalized def parse_symbol(symbol: str) -> Tuple[str, Optional[str]]: """ Parse symbol and extract base/quote currencies. - + Args: symbol: MT5 symbol (e.g., "EURUSD", "EURUSDm") - + Returns: (clean_symbol, market_type) """ clean = (symbol or "").strip().upper() - + # Remove common broker suffixes for suffix in ["M", ".RAW", "-ECN", ".STD", ".PRO", ".", "#"]: if clean.endswith(suffix): - clean = clean[:-len(suffix)] - + clean = clean[: -len(suffix)] + # Determine market type based on symbol pattern if clean in FOREX_PAIRS or (len(clean) == 6 and clean.isalpha()): return clean, "forex" - + if clean.startswith("XAU") or clean.startswith("XAG"): return clean, "metal" - + if clean.startswith("BTC") or clean.startswith("ETH") or clean.startswith("LTC"): return clean, "crypto" - + if any(idx in clean for idx in ["US30", "US500", "USTEC", "UK100", "DE40", "JP225"]): return clean, "index" - + # Default to forex return clean, "forex" @@ -95,21 +140,21 @@ def parse_symbol(symbol: str) -> Tuple[str, Optional[str]]: def get_lot_size_info(symbol: str) -> dict: """ Get lot size information for a symbol. - + Standard forex lot sizes: - Standard lot: 100,000 units - Mini lot: 10,000 units - Micro lot: 1,000 units - Nano lot: 100 units - + Args: symbol: MT5 symbol - + Returns: Dict with lot size information """ clean, market_type = parse_symbol(symbol) - + if market_type == "forex": return { "standard_lot": 100000, @@ -117,7 +162,7 @@ def get_lot_size_info(symbol: str) -> dict: "lot_step": 0.01, "max_lot": 100.0, } - + if market_type == "metal": # Gold/Silver typically uses oz return { @@ -126,7 +171,7 @@ def get_lot_size_info(symbol: str) -> dict: "lot_step": 0.01, "max_lot": 50.0, } - + if market_type == "index": return { "standard_lot": 1, @@ -134,7 +179,7 @@ def get_lot_size_info(symbol: str) -> dict: "lot_step": 0.1, "max_lot": 100.0, } - + # Default return { "standard_lot": 1, diff --git a/backend_api_python/app/services/oauth_service.py b/backend_api_python/app/services/oauth_service.py index 87ec9fa..564f2ee 100644 --- a/backend_api_python/app/services/oauth_service.py +++ b/backend_api_python/app/services/oauth_service.py @@ -1,12 +1,15 @@ """ OAuth Service - Handles Google and GitHub OAuth authentication. """ + import os import secrets -import requests -from urllib.parse import urlencode from datetime import datetime -from typing import Tuple, Optional, Dict, Any +from typing import Any, Dict, Tuple +from urllib.parse import urlencode + +import requests + from app.utils.db import get_db_connection from app.utils.logger import get_logger @@ -26,313 +29,308 @@ def get_oauth_service(): class OAuthService: """OAuth service for Google and GitHub authentication""" - + def __init__(self): self._load_config() - + def _load_config(self): """Load OAuth configuration from environment variables""" # Google OAuth - self.google_client_id = os.getenv('GOOGLE_CLIENT_ID', '') - self.google_client_secret = os.getenv('GOOGLE_CLIENT_SECRET', '') - self.google_redirect_uri = os.getenv('GOOGLE_REDIRECT_URI', '') + self.google_client_id = os.getenv("GOOGLE_CLIENT_ID", "") + self.google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET", "") + self.google_redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "") self.google_enabled = bool(self.google_client_id and self.google_client_secret) - + # GitHub OAuth - self.github_client_id = os.getenv('GITHUB_CLIENT_ID', '') - self.github_client_secret = os.getenv('GITHUB_CLIENT_SECRET', '') - self.github_redirect_uri = os.getenv('GITHUB_REDIRECT_URI', '') + self.github_client_id = os.getenv("GITHUB_CLIENT_ID", "") + self.github_client_secret = os.getenv("GITHUB_CLIENT_SECRET", "") + self.github_redirect_uri = os.getenv("GITHUB_REDIRECT_URI", "") self.github_enabled = bool(self.github_client_id and self.github_client_secret) - + # Frontend URL for redirect after OAuth - self.frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:8080') - + self.frontend_url = os.getenv("FRONTEND_URL", "http://localhost:8080") + # State storage (in-memory for simplicity, could use Redis in production) self._states = {} - + # ========================================================================= # Google OAuth # ========================================================================= - + def get_google_auth_url(self, state: str = None) -> Tuple[str, str]: """ Generate Google OAuth authorization URL. - + Returns: (auth_url, state) """ if not self.google_enabled: - return '', '' - + return "", "" + state = state or secrets.token_urlsafe(32) - self._states[state] = {'provider': 'google', 'created_at': datetime.now()} - + self._states[state] = {"provider": "google", "created_at": datetime.now()} + params = { - 'client_id': self.google_client_id, - 'redirect_uri': self.google_redirect_uri, - 'response_type': 'code', - 'scope': 'openid email profile', - 'state': state, - 'access_type': 'offline', - 'prompt': 'select_account' + "client_id": self.google_client_id, + "redirect_uri": self.google_redirect_uri, + "response_type": "code", + "scope": "openid email profile", + "state": state, + "access_type": "offline", + "prompt": "select_account", } - + auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}" return auth_url, state - + def handle_google_callback(self, code: str, state: str) -> Tuple[bool, Dict[str, Any]]: """ Handle Google OAuth callback. - + Args: code: Authorization code from Google state: State parameter for CSRF protection - + Returns: (success, user_info_or_error) """ # Validate state - if state not in self._states or self._states[state].get('provider') != 'google': - return False, {'error': 'Invalid state parameter'} - + if state not in self._states or self._states[state].get("provider") != "google": + return False, {"error": "Invalid state parameter"} + del self._states[state] - + try: # Exchange code for tokens token_response = requests.post( - 'https://oauth2.googleapis.com/token', + "https://oauth2.googleapis.com/token", data={ - 'code': code, - 'client_id': self.google_client_id, - 'client_secret': self.google_client_secret, - 'redirect_uri': self.google_redirect_uri, - 'grant_type': 'authorization_code' + "code": code, + "client_id": self.google_client_id, + "client_secret": self.google_client_secret, + "redirect_uri": self.google_redirect_uri, + "grant_type": "authorization_code", }, - timeout=10 + timeout=10, ) - + if token_response.status_code != 200: logger.error(f"Google token exchange failed: {token_response.text}") - return False, {'error': 'Failed to exchange authorization code'} - + return False, {"error": "Failed to exchange authorization code"} + tokens = token_response.json() - access_token = tokens.get('access_token') - + access_token = tokens.get("access_token") + # Get user info user_response = requests.get( - 'https://www.googleapis.com/oauth2/v2/userinfo', - headers={'Authorization': f'Bearer {access_token}'}, - timeout=10 + "https://www.googleapis.com/oauth2/v2/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=10, ) - + if user_response.status_code != 200: logger.error(f"Google user info failed: {user_response.text}") - return False, {'error': 'Failed to get user information'} - + return False, {"error": "Failed to get user information"} + user_info = user_response.json() - + return True, { - 'provider': 'google', - 'provider_user_id': user_info.get('id'), - 'email': user_info.get('email'), - 'name': user_info.get('name'), - 'avatar': user_info.get('picture'), - 'access_token': access_token, - 'refresh_token': tokens.get('refresh_token') + "provider": "google", + "provider_user_id": user_info.get("id"), + "email": user_info.get("email"), + "name": user_info.get("name"), + "avatar": user_info.get("picture"), + "access_token": access_token, + "refresh_token": tokens.get("refresh_token"), } - + except requests.RequestException as e: logger.error(f"Google OAuth error: {e}") - return False, {'error': 'OAuth service unavailable'} - + return False, {"error": "OAuth service unavailable"} + # ========================================================================= # GitHub OAuth # ========================================================================= - + def get_github_auth_url(self, state: str = None) -> Tuple[str, str]: """ Generate GitHub OAuth authorization URL. - + Returns: (auth_url, state) """ if not self.github_enabled: - return '', '' - + return "", "" + state = state or secrets.token_urlsafe(32) - self._states[state] = {'provider': 'github', 'created_at': datetime.now()} - + self._states[state] = {"provider": "github", "created_at": datetime.now()} + params = { - 'client_id': self.github_client_id, - 'redirect_uri': self.github_redirect_uri, - 'scope': 'user:email read:user', - 'state': state + "client_id": self.github_client_id, + "redirect_uri": self.github_redirect_uri, + "scope": "user:email read:user", + "state": state, } - + auth_url = f"https://github.com/login/oauth/authorize?{urlencode(params)}" return auth_url, state - + def handle_github_callback(self, code: str, state: str) -> Tuple[bool, Dict[str, Any]]: """ Handle GitHub OAuth callback. - + Args: code: Authorization code from GitHub state: State parameter for CSRF protection - + Returns: (success, user_info_or_error) """ # Validate state - if state not in self._states or self._states[state].get('provider') != 'github': - return False, {'error': 'Invalid state parameter'} - + if state not in self._states or self._states[state].get("provider") != "github": + return False, {"error": "Invalid state parameter"} + del self._states[state] - + try: # Exchange code for token token_response = requests.post( - 'https://github.com/login/oauth/access_token', + "https://github.com/login/oauth/access_token", data={ - 'client_id': self.github_client_id, - 'client_secret': self.github_client_secret, - 'code': code, - 'redirect_uri': self.github_redirect_uri + "client_id": self.github_client_id, + "client_secret": self.github_client_secret, + "code": code, + "redirect_uri": self.github_redirect_uri, }, - headers={'Accept': 'application/json'}, - timeout=10 + headers={"Accept": "application/json"}, + timeout=10, ) - + if token_response.status_code != 200: logger.error(f"GitHub token exchange failed: {token_response.text}") - return False, {'error': 'Failed to exchange authorization code'} - + return False, {"error": "Failed to exchange authorization code"} + tokens = token_response.json() - access_token = tokens.get('access_token') - + access_token = tokens.get("access_token") + if not access_token: - error = tokens.get('error_description', 'Unknown error') + error = tokens.get("error_description", "Unknown error") logger.error(f"GitHub token error: {error}") - return False, {'error': error} - + return False, {"error": error} + # Get user info user_response = requests.get( - 'https://api.github.com/user', - headers={ - 'Authorization': f'Bearer {access_token}', - 'Accept': 'application/vnd.github.v3+json' - }, - timeout=10 + "https://api.github.com/user", + headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"}, + timeout=10, ) - + if user_response.status_code != 200: logger.error(f"GitHub user info failed: {user_response.text}") - return False, {'error': 'Failed to get user information'} - + return False, {"error": "Failed to get user information"} + user_info = user_response.json() - + # Get user email (might be private) - email = user_info.get('email') + email = user_info.get("email") if not email: email_response = requests.get( - 'https://api.github.com/user/emails', - headers={ - 'Authorization': f'Bearer {access_token}', - 'Accept': 'application/vnd.github.v3+json' - }, - timeout=10 + "https://api.github.com/user/emails", + headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"}, + timeout=10, ) if email_response.status_code == 200: emails = email_response.json() # Find primary email for e in emails: - if e.get('primary') and e.get('verified'): - email = e.get('email') + if e.get("primary") and e.get("verified"): + email = e.get("email") break # Fallback to any verified email if not email: for e in emails: - if e.get('verified'): - email = e.get('email') + if e.get("verified"): + email = e.get("email") break - + return True, { - 'provider': 'github', - 'provider_user_id': str(user_info.get('id')), - 'email': email, - 'name': user_info.get('name') or user_info.get('login'), - 'avatar': user_info.get('avatar_url'), - 'access_token': access_token, - 'refresh_token': None # GitHub doesn't use refresh tokens + "provider": "github", + "provider_user_id": str(user_info.get("id")), + "email": email, + "name": user_info.get("name") or user_info.get("login"), + "avatar": user_info.get("avatar_url"), + "access_token": access_token, + "refresh_token": None, # GitHub doesn't use refresh tokens } - + except requests.RequestException as e: logger.error(f"GitHub OAuth error: {e}") - return False, {'error': 'OAuth service unavailable'} - + return False, {"error": "OAuth service unavailable"} + # ========================================================================= # OAuth Link Management # ========================================================================= - + def get_or_create_user_from_oauth(self, oauth_info: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]: """ Get existing user or create new user from OAuth info. - + Args: oauth_info: Dict with provider, provider_user_id, email, name, avatar, tokens - + Returns: (success, user_or_error) """ - provider = oauth_info['provider'] - provider_user_id = oauth_info['provider_user_id'] - email = oauth_info.get('email') - name = oauth_info.get('name', '') - avatar = oauth_info.get('avatar', '/avatar2.jpg') - + provider = oauth_info["provider"] + provider_user_id = oauth_info["provider_user_id"] + email = oauth_info.get("email") + name = oauth_info.get("name", "") + avatar = oauth_info.get("avatar", "/avatar2.jpg") + try: with get_db_connection() as db: cur = db.cursor() - + # Check if OAuth link exists cur.execute( """ SELECT user_id FROM qd_oauth_links WHERE provider = ? AND provider_user_id = ? """, - (provider, provider_user_id) + (provider, provider_user_id), ) link = cur.fetchone() - + if link: # Existing OAuth link - get user - user_id = link['user_id'] + user_id = link["user_id"] cur.execute( """ SELECT id, username, email, nickname, avatar, status, role FROM qd_users WHERE id = ? """, - (user_id,) + (user_id,), ) user = cur.fetchone() - + if user: # Update OAuth tokens cur.execute( """ - UPDATE qd_oauth_links + UPDATE qd_oauth_links SET access_token = ?, refresh_token = ?, updated_at = NOW() WHERE provider = ? AND provider_user_id = ? """, - (oauth_info.get('access_token'), oauth_info.get('refresh_token'), - provider, provider_user_id) + ( + oauth_info.get("access_token"), + oauth_info.get("refresh_token"), + provider, + provider_user_id, + ), ) - + # Update last login - cur.execute( - "UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", - (user_id,) - ) + cur.execute("UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", (user_id,)) db.commit() cur.close() return True, dict(user) @@ -340,10 +338,10 @@ class OAuthService: # Orphaned OAuth link - remove it cur.execute( "DELETE FROM qd_oauth_links WHERE provider = ? AND provider_user_id = ?", - (provider, provider_user_id) + (provider, provider_user_id), ) db.commit() - + # Check if user exists with same email if email: cur.execute( @@ -351,37 +349,41 @@ class OAuthService: SELECT id, username, email, nickname, avatar, status, role FROM qd_users WHERE email = ? """, - (email,) + (email,), ) existing_user = cur.fetchone() - + if existing_user: # Link OAuth to existing user cur.execute( """ - INSERT INTO qd_oauth_links - (user_id, provider, provider_user_id, provider_email, + INSERT INTO qd_oauth_links + (user_id, provider, provider_user_id, provider_email, provider_name, provider_avatar, access_token, refresh_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - (existing_user['id'], provider, provider_user_id, email, - name, avatar, oauth_info.get('access_token'), - oauth_info.get('refresh_token')) - ) - cur.execute( - "UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", - (existing_user['id'],) + ( + existing_user["id"], + provider, + provider_user_id, + email, + name, + avatar, + oauth_info.get("access_token"), + oauth_info.get("refresh_token"), + ), ) + cur.execute("UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", (existing_user["id"],)) db.commit() cur.close() return True, dict(existing_user) - + # Create new user # Generate unique username from OAuth name or email - base_username = (name or email.split('@')[0] if email else provider_user_id) - base_username = ''.join(c for c in base_username if c.isalnum() or c in '_-')[:30] + base_username = name or email.split("@")[0] if email else provider_user_id + base_username = "".join(c for c in base_username if c.isalnum() or c in "_-")[:30] username = base_username - + # Ensure username is unique counter = 1 while True: @@ -390,13 +392,15 @@ class OAuthService: break username = f"{base_username}_{counter}" counter += 1 - + # Generate a random password (user won't need it for OAuth login) import secrets + random_password = secrets.token_urlsafe(32) from app.services.user_service import get_user_service + password_hash = get_user_service().hash_password(random_password) - + # Ensure email is unique or generate placeholder if email: cur.execute("SELECT id FROM qd_users WHERE email = ?", (email,)) @@ -404,72 +408,77 @@ class OAuthService: email = f"{provider}_{provider_user_id}@oauth.local" else: email = f"{provider}_{provider_user_id}@oauth.local" - + # Insert new user cur.execute( """ - INSERT INTO qd_users + INSERT INTO qd_users (username, password_hash, email, nickname, avatar, status, role, email_verified) VALUES (?, ?, ?, ?, ?, 'active', 'user', TRUE) """, - (username, password_hash, email, name or username, avatar or '/avatar2.jpg') + (username, password_hash, email, name or username, avatar or "/avatar2.jpg"), ) user_id = cur.lastrowid - + # Create OAuth link cur.execute( """ - INSERT INTO qd_oauth_links - (user_id, provider, provider_user_id, provider_email, + INSERT INTO qd_oauth_links + (user_id, provider, provider_user_id, provider_email, provider_name, provider_avatar, access_token, refresh_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - (user_id, provider, provider_user_id, oauth_info.get('email'), - name, avatar, oauth_info.get('access_token'), - oauth_info.get('refresh_token')) + ( + user_id, + provider, + provider_user_id, + oauth_info.get("email"), + name, + avatar, + oauth_info.get("access_token"), + 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,) - ) - + cur.execute("UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", (user_id,)) + db.commit() cur.close() # Grant registration bonus credits for OAuth-created users # Keep consistent with email/password registration flows (auth.py). try: - register_bonus = int(os.getenv('CREDITS_REGISTER_BONUS', '0')) + register_bonus = int(os.getenv("CREDITS_REGISTER_BONUS", "0")) except (ValueError, TypeError): register_bonus = 0 if register_bonus > 0: try: from app.services.billing_service import get_billing_service + get_billing_service().add_credits( user_id=user_id, amount=register_bonus, - action='register_bonus', - remark=f'Registration bonus (OAuth:{provider})' + action="register_bonus", + remark=f"Registration bonus (OAuth:{provider})", ) except Exception as e: logger.warning(f"Failed to grant OAuth registration bonus: {e}") - + return True, { - 'id': user_id, - 'username': username, - 'email': email, - 'nickname': name or username, - 'avatar': avatar or '/avatar2.jpg', - 'status': 'active', - 'role': 'user' + "id": user_id, + "username": username, + "email": email, + "nickname": name or username, + "avatar": avatar or "/avatar2.jpg", + "status": "active", + "role": "user", } - + except Exception as e: logger.error(f"OAuth user creation failed: {e}") - return False, {'error': 'Failed to create user account'} - + return False, {"error": "Failed to create user account"} + def get_user_oauth_links(self, user_id: int) -> list: """Get all OAuth links for a user""" try: @@ -480,7 +489,7 @@ class OAuthService: SELECT provider, provider_email, provider_name, created_at FROM qd_oauth_links WHERE user_id = ? """, - (user_id,) + (user_id,), ) links = cur.fetchall() cur.close() @@ -488,54 +497,45 @@ class OAuthService: except Exception as e: logger.error(f"Failed to get OAuth links: {e}") return [] - + def unlink_oauth(self, user_id: int, provider: str) -> Tuple[bool, str]: """Unlink an OAuth provider from user account""" try: with get_db_connection() as db: cur = db.cursor() - + # Check if user has password (can't unlink last auth method) - cur.execute( - "SELECT password_hash FROM qd_users WHERE id = ?", - (user_id,) - ) + cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,)) user = cur.fetchone() - - if not user or not user['password_hash']: + + if not user or not user["password_hash"]: # Check if this is the only OAuth link - cur.execute( - "SELECT COUNT(*) as count FROM qd_oauth_links WHERE user_id = ?", - (user_id,) - ) - count = cur.fetchone()['count'] + cur.execute("SELECT COUNT(*) as count FROM qd_oauth_links WHERE user_id = ?", (user_id,)) + count = cur.fetchone()["count"] if count <= 1: cur.close() - return False, 'Cannot unlink the only authentication method' - - cur.execute( - "DELETE FROM qd_oauth_links WHERE user_id = ? AND provider = ?", - (user_id, provider) - ) + return False, "Cannot unlink the only authentication method" + + cur.execute("DELETE FROM qd_oauth_links WHERE user_id = ? AND provider = ?", (user_id, provider)) db.commit() cur.close() - return True, 'unlinked' - + return True, "unlinked" + except Exception as e: logger.error(f"Failed to unlink OAuth: {e}") - return False, 'Failed to unlink account' - + return False, "Failed to unlink account" + # ========================================================================= # Cleanup # ========================================================================= - + def cleanup_expired_states(self, max_age_minutes: int = 10): """Clean up expired OAuth states""" cutoff = datetime.now() from datetime import timedelta + cutoff = cutoff - timedelta(minutes=max_age_minutes) - - expired = [k for k, v in self._states.items() - if v.get('created_at', datetime.now()) < cutoff] + + expired = [k for k, v in self._states.items() if v.get("created_at", datetime.now()) < cutoff] for k in expired: del self._states[k] diff --git a/backend_api_python/app/services/pending_order_worker.py b/backend_api_python/app/services/pending_order_worker.py index 9bac112..612a7a9 100644 --- a/backend_api_python/app/services/pending_order_worker.py +++ b/backend_api_python/app/services/pending_order_worker.py @@ -14,30 +14,26 @@ import threading import time from typing import Any, Dict, List, Optional, Tuple -from app.services.signal_notifier import SignalNotifier from app.services.exchange_execution import load_strategy_configs, resolve_exchange_config, safe_exchange_config_for_log -from app.services.live_trading.execution import place_order_from_signal -from app.services.live_trading.factory import create_client -from app.services.live_trading.records import apply_fill_to_local_position, record_trade from app.services.live_trading.base import LiveTradingError from app.services.live_trading.binance import BinanceFuturesClient from app.services.live_trading.binance_spot import BinanceSpotClient -from app.services.live_trading.okx import OkxClient +from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient from app.services.live_trading.bitget import BitgetMixClient from app.services.live_trading.bitget_spot import BitgetSpotClient from app.services.live_trading.bybit import BybitClient from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient +from app.services.live_trading.deepcoin import DeepcoinClient +from app.services.live_trading.factory import create_client +from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient +from app.services.live_trading.htx import HtxClient from app.services.live_trading.kraken import KrakenClient from app.services.live_trading.kraken_futures import KrakenFuturesClient -from app.services.live_trading.kucoin import KucoinSpotClient -from app.services.live_trading.kucoin import KucoinFuturesClient -from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient -from app.services.live_trading.bitfinex import BitfinexClient -from app.services.live_trading.bitfinex import BitfinexDerivativesClient -from app.services.live_trading.deepcoin import DeepcoinClient -from app.services.live_trading.htx import HtxClient -from app.services.live_trading.symbols import to_okx_swap_inst_id -from app.services.live_trading.symbols import to_gate_currency_pair +from app.services.live_trading.kucoin import KucoinFuturesClient, KucoinSpotClient +from app.services.live_trading.okx import OkxClient +from app.services.live_trading.records import apply_fill_to_local_position, record_trade +from app.services.live_trading.symbols import to_gate_currency_pair, to_okx_swap_inst_id +from app.services.signal_notifier import SignalNotifier from app.utils.db import get_db_connection from app.utils.logger import get_logger @@ -69,7 +65,9 @@ class PendingOrderWorker: self._position_sync_enabled = os.getenv("POSITION_SYNC_ENABLED", "true").lower() == "true" self._position_sync_interval_sec = float(os.getenv("POSITION_SYNC_INTERVAL_SEC", "10")) self._last_position_sync_ts = 0.0 - logger.info(f"PendingOrderWorker: sync_enabled={self._position_sync_enabled}, interval={self._position_sync_interval_sec}s") + logger.info( + f"PendingOrderWorker: sync_enabled={self._position_sync_enabled}, interval={self._position_sync_interval_sec}s" + ) def start(self) -> bool: with self._lock: @@ -150,11 +148,13 @@ class PendingOrderWorker: cur = db.cursor() if target_strategy_id: cur.execute( - "SELECT id, strategy_id, symbol, side, size, entry_price FROM qd_strategy_positions WHERE strategy_id = %s ORDER BY updated_at DESC", - (int(target_strategy_id),) + "SELECT id, strategy_id, symbol, side, size, entry_price FROM qd_strategy_positions WHERE strategy_id = %s ORDER BY updated_at DESC", + (int(target_strategy_id),), ) else: - cur.execute("SELECT id, strategy_id, symbol, side, size, entry_price FROM qd_strategy_positions ORDER BY updated_at DESC") + cur.execute( + "SELECT id, strategy_id, symbol, side, size, entry_price FROM qd_strategy_positions ORDER BY updated_at DESC" + ) rows = cur.fetchall() or [] cur.close() @@ -169,15 +169,15 @@ class PendingOrderWorker: if sid <= 0: continue sid_to_rows.setdefault(sid, []).append(r) - - # If targeted sync but no local rows found, we assume user might have opened position externally - # but DB is empty. However, without knowing *which* symbol to check, we can't easily auto-discover + + # If targeted sync but no local rows found, we assume user might have opened position externally + # but DB is empty. However, without knowing *which* symbol to check, we can't easily auto-discover # unless we fetch ALL positions from exchange for that strategy. - # But `load_strategy_configs(sid)` gives us the exchange keys. + # But `load_strategy_configs(sid)` gives us the exchange keys. # So if target_strategy_id is set but `sid_to_rows` is empty, we SHOULD explicitly add it to `sid_to_rows` # so logic below enters and calls `client.get_positions()`. if target_strategy_id and target_strategy_id not in sid_to_rows: - sid_to_rows[target_strategy_id] = [] + sid_to_rows[target_strategy_id] = [] # [Log Fix] Load all ACTIVE LIVE strategies to ensure we sync/log them even if local DB is empty. # Otherwise, if we have no local positions, we would silently skip the exchange check. @@ -188,7 +188,7 @@ class PendingOrderWorker: cur.execute("SELECT id FROM qd_strategies_trading WHERE status = 'running' AND execution_mode = 'live'") active_rows = cur.fetchall() or [] cur.close() - + logger.debug(f"[PositionSync] Found {len(active_rows)} active live strategies in DB.") for _ar in active_rows: _sid = int(_ar.get("id") or 0) @@ -209,23 +209,27 @@ class PendingOrderWorker: # Modification: Even in signal mode, if target_strategy_id (called when the strategy is started) is specified, it must be synchronized # This can clear up "ghost positions" where users manually close positions on the exchange but still have database records. if exec_mode != "live" and not target_strategy_id: - logger.debug(f"[PositionSync] Strategy {sid} skipped: execution_mode='{exec_mode}' (needs 'live' or explicit target)") + logger.debug( + f"[PositionSync] Strategy {sid} skipped: execution_mode='{exec_mode}' (needs 'live' or explicit target)" + ) continue sync_user_id = int(sc.get("user_id") or 1) exchange_config = resolve_exchange_config(sc.get("exchange_config") or {}, user_id=sync_user_id) safe_cfg = safe_exchange_config_for_log(exchange_config) - + # Check if exchange_id is valid, skip synchronization if it is empty or invalid (signal mode may not have an exchange configured) exchange_id = str(exchange_config.get("exchange_id") or "").strip().lower() if not exchange_id: - logger.debug(f"[PositionSync] Strategy {sid} skipped: exchange_id is empty (signal mode or no exchange config)") + logger.debug( + f"[PositionSync] Strategy {sid} skipped: exchange_id is empty (signal mode or no exchange config)" + ) continue - - market_type = (sc.get("market_type") or exchange_config.get("market_type") or "swap") + + market_type = sc.get("market_type") or exchange_config.get("market_type") or "swap" market_type = str(market_type or "swap").strip().lower() if market_type in ("futures", "future", "perp", "perpetual"): market_type = "swap" - + # Get strategy's trading symbol(s) to filter positions # Only sync positions for symbols that this strategy actually trades strategy_symbol = (sc.get("symbol") or "").strip() @@ -244,6 +248,7 @@ class PendingOrderWorker: if MT5Client is None: try: from app.services.mt5_trading import MT5Client as _MT5Client + MT5Client = _MT5Client except ImportError: pass @@ -252,19 +257,21 @@ class PendingOrderWorker: try: client = create_client(exchange_config, market_type=market_type) except Exception as e: - logger.debug(f"[PositionSync] Strategy {sid} skipped: failed to create client (exchange_id={exchange_id}): {e}") + logger.debug( + f"[PositionSync] Strategy {sid} skipped: failed to create client (exchange_id={exchange_id}): {e}" + ) continue - + # Build an "exchange snapshot" per symbol+side exch_size: Dict[str, Dict[str, float]] = {} # {symbol: {long: size, short: size}} - exch_entry_price: Dict[str, Dict[str, float]] = {} # {symbol: {long: px, short: px}} + exch_entry_price: Dict[str, Dict[str, float]] = {} # {symbol: {long: px, short: px}} if isinstance(client, BinanceFuturesClient) and market_type == "swap": all_pos = client.get_positions() or [] # Handle dict response if needed (wrapper) if isinstance(all_pos, dict) and "raw" in all_pos: - all_pos = all_pos["raw"] - + all_pos = all_pos["raw"] + if isinstance(all_pos, list): for p in all_pos: sym = str(p.get("symbol") or "").strip().upper() @@ -299,7 +306,11 @@ class PendingOrderWorker: continue # instId: BTC-USDT-SWAP -> BTC/USDT hb_sym = inst_id.replace("-SWAP", "").replace("-", "/") - side = "long" if pos_side == "long" else ("short" if pos_side == "short" else ("long" if pos > 0 else "short")) + side = ( + "long" + if pos_side == "long" + else ("short" if pos_side == "short" else ("long" if pos > 0 else "short")) + ) # IMPORTANT: OKX swap positions `pos` is in contracts, but our system uses base-asset quantity. # Convert contracts -> base using ctVal when available. qty_base = abs(float(pos)) @@ -311,7 +322,7 @@ class PendingOrderWorker: except Exception: pass exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = float(qty_base) - + # Extract entry price from OKX position data # OKX API returns avgPx (average price) or avgPxEp (average price in equity) for positions try: @@ -328,18 +339,26 @@ class PendingOrderWorker: # Fallback to last price if available last_px = p.get("last") entry_price = float(last_px) if last_px else 0.0 - + if entry_price > 0: exch_entry_price.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = entry_price - logger.debug(f"[PositionSync] OKX {hb_sym} {side}: entry_price={entry_price} from avgPx={p.get('avgPx')} or avgPxEp={p.get('avgPxEp')}") + logger.debug( + f"[PositionSync] OKX {hb_sym} {side}: entry_price={entry_price} from avgPx={p.get('avgPx')} or avgPxEp={p.get('avgPxEp')}" + ) else: - logger.warning(f"[PositionSync] OKX {hb_sym} {side}: Could not extract entry price from position data: {p}") + logger.warning( + f"[PositionSync] OKX {hb_sym} {side}: Could not extract entry price from position data: {p}" + ) except Exception as e: - logger.warning(f"[PositionSync] Failed to extract entry price for OKX {hb_sym} {side}: {e}") + logger.warning( + f"[PositionSync] Failed to extract entry price for OKX {hb_sym} {side}: {e}" + ) # Don't set entry_price, will remain 0.0 elif isinstance(client, BitgetMixClient) and market_type == "swap": - product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") + product_type = str( + exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES" + ) resp = client.get_positions(product_type=product_type) data = resp.get("data") if isinstance(resp, dict) else None if isinstance(data, list): @@ -378,7 +397,11 @@ class PendingOrderWorker: hb_sym = sym if hb_sym.endswith("USDT") and len(hb_sym) > 4 and "/" not in hb_sym: hb_sym = f"{hb_sym[:-4]}/USDT" - side = "long" if side0 == "buy" else ("short" if side0 == "sell" else ("long" if sz > 0 else "short")) + side = ( + "long" + if side0 == "buy" + else ("short" if side0 == "sell" else ("long" if sz > 0 else "short")) + ) exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = abs(float(sz)) elif isinstance(client, GateUsdtFuturesClient) and market_type == "swap": @@ -436,7 +459,11 @@ class PendingOrderWorker: elif isinstance(client, KrakenFuturesClient) and market_type == "swap": resp = client.get_open_positions() - positions = (resp.get("openPositions") if isinstance(resp, dict) else None) or (resp.get("open_positions") if isinstance(resp, dict) else None) or [] + positions = ( + (resp.get("openPositions") if isinstance(resp, dict) else None) + or (resp.get("open_positions") if isinstance(resp, dict) else None) + or [] + ) if isinstance(positions, list): for p in positions: if not isinstance(p, dict): @@ -490,7 +517,9 @@ class PendingOrderWorker: # Continue to reconciliation logic below else: # Spot reconciliation is optional; skip for now (keeps self-check low-risk). - logger.debug(f"position sync: skip unsupported market/client: sid={sid}, cfg={safe_cfg}, market_type={market_type}, client={type(client)}") + logger.debug( + f"position sync: skip unsupported market/client: sid={sid}, cfg={safe_cfg}, market_type={market_type}, client={type(client)}" + ) continue # [DEBUG] Log all normalized exchange keys for inspection @@ -505,9 +534,13 @@ class PendingOrderWorker: pos_summary_parts.append(f"{_sym} {_side_key} size={_qty} entry={_ep}") if pos_summary_parts: - logger.info(f"[PositionSync] Strategy {sid} ({safe_cfg.get('exchange_id', 'unknown')}) positions: {'; '.join(pos_summary_parts)}") + logger.info( + f"[PositionSync] Strategy {sid} ({safe_cfg.get('exchange_id', 'unknown')}) positions: {'; '.join(pos_summary_parts)}" + ) else: - logger.info(f"[PositionSync] Strategy {sid} ({safe_cfg.get('exchange_id', 'unknown')}) has NO positions on exchange.") + logger.info( + f"[PositionSync] Strategy {sid} ({safe_cfg.get('exchange_id', 'unknown')}) has NO positions on exchange." + ) # 3) Apply reconciliation to local rows. to_delete_ids: List[int] = [] @@ -536,7 +569,9 @@ class PendingOrderWorker: local_price = float(r.get("entry_price") or 0.0) except Exception: local_price = 0.0 - logger.debug(f"[PositionSync] Check ID={rid} {sym} {side}: local_sz={local_size} px={local_price}, exch_sz={exch_qty} px={exch_price}") + logger.debug( + f"[PositionSync] Check ID={rid} {sym} {side}: local_sz={local_size} px={local_price}, exch_sz={exch_qty} px={exch_price}" + ) if exch_qty <= eps: # Exchange is flat -> delete local position (self-heal). @@ -550,39 +585,45 @@ class PendingOrderWorker: else: price_diff_ratio = 1.0 if exch_price > 0 else 0.0 - if (local_size <= 0 or abs(exch_qty - local_size) / max(1.0, local_size) > 0.01) or (price_diff_ratio > 0.005): - logger.info(f"[PositionSync] -> Flagged for UPDATE: {sym} (local_sz={local_size}->{exch_qty}, px={local_price}->{exch_price})") + if (local_size <= 0 or abs(exch_qty - local_size) / max(1.0, local_size) > 0.01) or ( + price_diff_ratio > 0.005 + ): + logger.info( + f"[PositionSync] -> Flagged for UPDATE: {sym} (local_sz={local_size}->{exch_qty}, px={local_price}->{exch_price})" + ) to_update.append({"id": rid, "size": exch_qty, "entry_price": exch_price}) # [New Feature] Detect positions that exist on exchange but not in local DB, and insert them. # IMPORTANT: Only insert positions for symbols that this strategy actually trades # This prevents syncing positions from quick trade or other sources to_insert: List[Dict[str, Any]] = [] - local_symbols_sides = {(str(r.get("symbol") or "").strip(), str(r.get("side") or "").strip().lower()) for r in plist} - + local_symbols_sides = { + (str(r.get("symbol") or "").strip(), str(r.get("side") or "").strip().lower()) for r in plist + } + for _sym, _sides_map in exch_size.items(): # Filter: only sync positions for symbols that this strategy trades # If strategy has no symbol configured, skip auto-insert to prevent syncing quick trade positions _sym_upper = _sym.strip().upper() if allowed_symbols and _sym_upper not in allowed_symbols: - logger.debug(f"[PositionSync] Skipping {_sym}: not in strategy's symbol list (strategy trades: {allowed_symbols})") + logger.debug( + f"[PositionSync] Skipping {_sym}: not in strategy's symbol list (strategy trades: {allowed_symbols})" + ) continue elif not allowed_symbols: # Strategy has no symbol configured - skip to prevent syncing unrelated positions - logger.debug(f"[PositionSync] Skipping {_sym}: strategy has no symbol configured (preventing quick trade position sync)") + logger.debug( + f"[PositionSync] Skipping {_sym}: strategy has no symbol configured (preventing quick trade position sync)" + ) continue - + for _side, _qty in _sides_map.items(): if _qty > 1e-12 and (_sym, _side) not in local_symbols_sides: # Exchange has this position but local DB does not _ep = exch_entry_price.get(_sym, {}).get(_side, 0.0) - to_insert.append({ - "strategy_id": sid, - "symbol": _sym, - "side": _side, - "size": _qty, - "entry_price": _ep - }) + to_insert.append( + {"strategy_id": sid, "symbol": _sym, "side": _side, "size": _qty, "entry_price": _ep} + ) logger.info(f"[PositionSync] -> Flagged for INSERT: {_sym} {_side} size={_qty} entry={_ep}") if not to_delete_ids and not to_update and not to_insert: @@ -594,14 +635,16 @@ class PendingOrderWorker: cur.execute("DELETE FROM qd_strategy_positions WHERE id = %s", (int(rid),)) for u in to_update: cur.execute( - "UPDATE qd_strategy_positions SET size = %s, entry_price = %s, updated_at = NOW() WHERE id = %s", - (float(u["size"]), float(u["entry_price"]), int(u["id"])) + "UPDATE qd_strategy_positions SET size = %s, entry_price = %s, updated_at = NOW() WHERE id = %s", + (float(u["size"]), float(u["entry_price"]), int(u["id"])), ) for ins in to_insert: # Get user_id from strategy ins_user_id = 1 try: - cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (int(ins["strategy_id"]),)) + cur.execute( + "SELECT user_id FROM qd_strategies_trading WHERE id = %s", (int(ins["strategy_id"]),) + ) strategy_row = cur.fetchone() if strategy_row and strategy_row.get("user_id"): ins_user_id = int(strategy_row["user_id"]) @@ -610,7 +653,14 @@ class PendingOrderWorker: cur.execute( """INSERT INTO qd_strategy_positions (user_id, strategy_id, symbol, side, size, entry_price, updated_at) VALUES (%s, %s, %s, %s, %s, %s, NOW())""", - (ins_user_id, int(ins["strategy_id"]), str(ins["symbol"]), str(ins["side"]), float(ins["size"]), float(ins["entry_price"])) + ( + ins_user_id, + int(ins["strategy_id"]), + str(ins["symbol"]), + str(ins["side"]), + float(ins["size"]), + float(ins["entry_price"]), + ), ) db.commit() cur.close() @@ -896,7 +946,9 @@ class PendingOrderWorker: amount = float(payload.get("amount") or order_row.get("amount") or 0.0) if not symbol or not signal_type: self._mark_failed(order_id=order_id, error="missing_symbol_or_signal_type") - _console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} missing symbol/signal_type") + _console_print( + f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} missing symbol/signal_type" + ) _notify_live_best_effort(status="failed", error="missing_symbol_or_signal_type") return @@ -911,7 +963,9 @@ class PendingOrderWorker: # Futures does not support live trading if market_category in ("Futures",): self._mark_failed(order_id=order_id, error=f"live_trading_not_supported_for_{market_category.lower()}") - _console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} {market_category} does not support live trading") + _console_print( + f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} {market_category} does not support live trading" + ) _notify_live_best_effort(status="failed", error=f"live_trading_not_supported_for_{market_category.lower()}") return @@ -919,28 +973,58 @@ class PendingOrderWorker: if exchange_id == "ibkr": if market_category not in ("USStock",): self._mark_failed(order_id=order_id, error=f"ibkr_only_supports_usstock_got_{market_category.lower()}") - _console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} IBKR only supports USStock, got {market_category}") - _notify_live_best_effort(status="failed", error=f"ibkr_only_supports_usstock_got_{market_category.lower()}") + _console_print( + f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} IBKR only supports USStock, got {market_category}" + ) + _notify_live_best_effort( + status="failed", error=f"ibkr_only_supports_usstock_got_{market_category.lower()}" + ) return # Validate MT5 only for Forex if exchange_id == "mt5": if market_category != "Forex": self._mark_failed(order_id=order_id, error=f"mt5_only_supports_forex_got_{market_category.lower()}") - _console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} MT5 only supports Forex, got {market_category}") - _notify_live_best_effort(status="failed", error=f"mt5_only_supports_forex_got_{market_category.lower()}") + _console_print( + f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} MT5 only supports Forex, got {market_category}" + ) + _notify_live_best_effort( + status="failed", error=f"mt5_only_supports_forex_got_{market_category.lower()}" + ) return # Validate crypto exchanges only for Crypto market - crypto_exchanges = ["binance", "okx", "bitget", "bybit", "coinbaseexchange", "kraken", "kucoin", "gate", "bitfinex"] + crypto_exchanges = [ + "binance", + "okx", + "bitget", + "bybit", + "coinbaseexchange", + "kraken", + "kucoin", + "gate", + "bitfinex", + ] if exchange_id in crypto_exchanges: if market_category != "Crypto": - self._mark_failed(order_id=order_id, error=f"crypto_exchange_only_supports_crypto_got_{market_category.lower()}") - _console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} {exchange_id} only supports Crypto, got {market_category}") - _notify_live_best_effort(status="failed", error=f"crypto_exchange_only_supports_crypto_got_{market_category.lower()}") + self._mark_failed( + order_id=order_id, error=f"crypto_exchange_only_supports_crypto_got_{market_category.lower()}" + ) + _console_print( + f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} {exchange_id} only supports Crypto, got {market_category}" + ) + _notify_live_best_effort( + status="failed", error=f"crypto_exchange_only_supports_crypto_got_{market_category.lower()}" + ) return - market_type = (payload.get("market_type") or order_row.get("market_type") or cfg.get("market_type") or exchange_config.get("market_type") or "swap") + market_type = ( + payload.get("market_type") + or order_row.get("market_type") + or cfg.get("market_type") + or exchange_config.get("market_type") + or "swap" + ) market_type = str(market_type or "swap").strip().lower() if market_type in ("futures", "future", "perp", "perpetual"): market_type = "swap" @@ -959,6 +1043,7 @@ class PendingOrderWorker: if IBKRClient is None: try: from app.services.ibkr_trading import IBKRClient as _IBKRClient + IBKRClient = _IBKRClient except ImportError: pass @@ -982,6 +1067,7 @@ class PendingOrderWorker: if MT5Client is None: try: from app.services.mt5_trading import MT5Client as _MT5Client + MT5Client = _MT5Client except ImportError: pass @@ -1020,12 +1106,13 @@ class PendingOrderWorker: # Other exchanges are more permissive. return f"qd_{int(strategy_id)}_{int(order_id)}{('_' + ph) if ph else ''}" - client_oid = _make_client_oid("") sig = str(signal_type or "").strip().lower() # Spot does not support short signals in this system. if market_type == "spot" and "short" in sig: self._mark_failed(order_id=order_id, error="spot_market_does_not_support_short_signals") - _console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} spot short not supported") + _console_print( + f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} spot short not supported" + ) _notify_live_best_effort(status="failed", error="spot_market_does_not_support_short_signals") return @@ -1037,7 +1124,9 @@ class PendingOrderWorker: order_mode = str(payload.get("order_mode") or payload.get("orderMode") or _default_order_mode).strip().lower() maker_wait_sec = float(payload.get("maker_wait_sec") or payload.get("makerWaitSec") or _default_maker_wait_sec) - maker_offset_bps = float(payload.get("maker_offset_bps") or payload.get("makerOffsetBps") or _default_maker_offset_bps) + maker_offset_bps = float( + payload.get("maker_offset_bps") or payload.get("makerOffsetBps") or _default_maker_offset_bps + ) if maker_wait_sec <= 0: maker_wait_sec = _default_maker_wait_sec if _default_maker_wait_sec > 0 else 10.0 if maker_offset_bps < 0: @@ -1087,8 +1176,8 @@ class PendingOrderWorker: try: with get_db_connection() as db: cur = db.cursor() - # We need to find the specific position. - # Symbol stored in DB is normalized (e.g. BTC/USDT). + # We need to find the specific position. + # Symbol stored in DB is normalized (e.g. BTC/USDT). # The payload 'symbol' might be 'BTCUSDT' or 'BTC/USDT'. # We try to match what stored in DB. # Best effort: try exact match, then normalized. @@ -1096,27 +1185,30 @@ class PendingOrderWorker: # Mapping logic similar to _sync: if qry_sym.endswith("USDT") and "/" not in qry_sym: qry_sym = f"{qry_sym[:-4]}/USDT" - + cur.execute( "SELECT size FROM qd_strategy_positions WHERE strategy_id = %s AND symbol = %s AND side = %s", - (strategy_id, qry_sym, pos_side) + (strategy_id, qry_sym, pos_side), ) row = cur.fetchone() cur.close() - + if row: held_size = float(row["size"] or 0.0) if amount > held_size: - logger.warning(f"[RiskControl] Adjusting Close amount from {amount} to {held_size} (Held) for {symbol}") + logger.warning( + f"[RiskControl] Adjusting Close amount from {amount} to {held_size} (Held) for {symbol}" + ) amount = held_size else: # No position found in DB? # If reduce_only, and no position, maybe it's 0. - logger.warning(f"[RiskControl] Close signal for {symbol} but NO position found in DB. Setting amount=0.") + logger.warning( + f"[RiskControl] Close signal for {symbol} but NO position found in DB. Setting amount=0." + ) amount = 0.0 except Exception as e: - logger.error(f"[RiskControl] Failed to check DB position logic: {e}") - + logger.error(f"[RiskControl] Failed to check DB position logic: {e}") # Collect raw exchange interactions / intermediate states for debugging & persistence. phases: Dict[str, Any] = {} @@ -1135,11 +1227,17 @@ class PendingOrderWorker: if isinstance(client, BinanceFuturesClient) and market_type == "swap": try: client.set_leverage(symbol=str(symbol), leverage=float(leverage or 1.0)) - phases["set_leverage"] = {"exchange": "binance", "symbol": str(symbol), "leverage": float(leverage or 1.0)} + phases["set_leverage"] = { + "exchange": "binance", + "symbol": str(symbol), + "leverage": float(leverage or 1.0), + } except Exception as e: # Safer default: do NOT place orders with an unintended leverage. err = f"binance_set_leverage_failed:{e}" - logger.warning(f"live leverage set failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}") + logger.warning( + f"live leverage set failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}" + ) self._mark_failed(order_id=order_id, error=err) _console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} {err}") _notify_live_best_effort(status="failed", error=err, amount_hint=amount, price_hint=ref_price) @@ -1250,7 +1348,9 @@ class PendingOrderWorker: 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") + 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: @@ -1265,7 +1365,7 @@ class PendingOrderWorker: 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( @@ -1346,9 +1446,17 @@ class PendingOrderWorker: client_order_id=limit_client_oid, ) elif isinstance(client, BitgetMixClient): - product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") + product_type = str( + exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES" + ) margin_coin = str(exchange_config.get("margin_coin") or exchange_config.get("marginCoin") or "USDT") - margin_mode = str(payload.get("margin_mode") or payload.get("marginMode") or exchange_config.get("margin_mode") or exchange_config.get("marginMode") or "cross") + margin_mode = str( + payload.get("margin_mode") + or payload.get("marginMode") + or exchange_config.get("margin_mode") + or exchange_config.get("marginMode") + or "cross" + ) # Best-effort set leverage for Bitget mix before placing orders (otherwise exchange defaults apply). try: if market_type == "swap": @@ -1514,40 +1622,76 @@ class PendingOrderWorker: # Wait for fills if isinstance(client, BinanceFuturesClient): - q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + q = client.wait_for_fill( + symbol=str(symbol), + order_id=limit_order_id, + client_order_id=limit_client_oid, + max_wait_sec=maker_wait_sec, + ) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) fee_v, fee_c = _fetch_fee_best_effort(order_id0=limit_order_id, client_order_id0=limit_client_oid) _apply_fee(float(fee_v or 0.0), str(fee_c or "")) elif isinstance(client, BinanceSpotClient): - q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + q = client.wait_for_fill( + symbol=str(symbol), + order_id=limit_order_id, + client_order_id=limit_client_oid, + max_wait_sec=maker_wait_sec, + ) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) fee_v, fee_c = _fetch_fee_best_effort(order_id0=limit_order_id, client_order_id0=limit_client_oid) _apply_fee(float(fee_v or 0.0), str(fee_c or "")) elif isinstance(client, OkxClient): - q = client.wait_for_fill(symbol=str(symbol), ord_id=limit_order_id, cl_ord_id=limit_client_oid, market_type=market_type, max_wait_sec=maker_wait_sec) + q = client.wait_for_fill( + symbol=str(symbol), + ord_id=limit_order_id, + cl_ord_id=limit_client_oid, + market_type=market_type, + max_wait_sec=maker_wait_sec, + ) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) elif isinstance(client, BitgetMixClient): - product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") - q = client.wait_for_fill(symbol=str(symbol), product_type=product_type, order_id=limit_order_id, client_oid=limit_client_oid, max_wait_sec=maker_wait_sec) + product_type = str( + exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES" + ) + q = client.wait_for_fill( + symbol=str(symbol), + product_type=product_type, + order_id=limit_order_id, + client_oid=limit_client_oid, + max_wait_sec=maker_wait_sec, + ) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) elif isinstance(client, BitgetSpotClient): - q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + q = client.wait_for_fill( + symbol=str(symbol), + order_id=limit_order_id, + client_order_id=limit_client_oid, + max_wait_sec=maker_wait_sec, + ) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) elif isinstance(client, BybitClient): - q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + q = client.wait_for_fill( + symbol=str(symbol), + order_id=limit_order_id, + client_order_id=limit_client_oid, + max_wait_sec=maker_wait_sec, + ) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) elif isinstance(client, CoinbaseExchangeClient): - q = client.wait_for_fill(order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + q = client.wait_for_fill( + order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec + ) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) @@ -1557,7 +1701,9 @@ class PendingOrderWorker: _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) elif isinstance(client, KrakenFuturesClient): - q = client.wait_for_fill(order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + q = client.wait_for_fill( + order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec + ) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) @@ -1577,7 +1723,11 @@ class PendingOrderWorker: _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) elif isinstance(client, GateUsdtFuturesClient): - q = client.wait_for_fill(order_id=limit_order_id, contract=to_gate_currency_pair(str(symbol)), max_wait_sec=maker_wait_sec) + q = client.wait_for_fill( + order_id=limit_order_id, + contract=to_gate_currency_pair(str(symbol)), + max_wait_sec=maker_wait_sec, + ) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) @@ -1592,12 +1742,22 @@ class PendingOrderWorker: _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) elif isinstance(client, DeepcoinClient): - q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + q = client.wait_for_fill( + symbol=str(symbol), + order_id=limit_order_id, + client_order_id=limit_client_oid, + max_wait_sec=maker_wait_sec, + ) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) elif isinstance(client, HtxClient): - q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + q = client.wait_for_fill( + symbol=str(symbol), + order_id=limit_order_id, + client_order_id=limit_client_oid, + max_wait_sec=maker_wait_sec, + ) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) @@ -1632,50 +1792,95 @@ class PendingOrderWorker: if remaining > max(0.0, float(amount or 0.0) * 0.001): try: if isinstance(client, BinanceFuturesClient): - phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid + ) elif isinstance(client, BinanceSpotClient): - phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid + ) elif isinstance(client, OkxClient): - phases["limit_cancel"] = client.cancel_order(market_type=market_type, symbol=str(symbol), ord_id=limit_order_id, cl_ord_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + market_type=market_type, + symbol=str(symbol), + ord_id=limit_order_id, + cl_ord_id=limit_client_oid, + ) elif isinstance(client, BitgetMixClient): - product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") - margin_coin = str(exchange_config.get("margin_coin") or exchange_config.get("marginCoin") or "USDT") - phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), product_type=product_type, margin_coin=margin_coin, order_id=limit_order_id, client_oid=limit_client_oid) + product_type = str( + exchange_config.get("product_type") + or exchange_config.get("productType") + or "USDT-FUTURES" + ) + margin_coin = str( + exchange_config.get("margin_coin") or exchange_config.get("marginCoin") or "USDT" + ) + phases["limit_cancel"] = client.cancel_order( + symbol=str(symbol), + product_type=product_type, + margin_coin=margin_coin, + order_id=limit_order_id, + client_oid=limit_client_oid, + ) elif isinstance(client, BitgetSpotClient): - phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + symbol=str(symbol), client_order_id=limit_client_oid + ) elif isinstance(client, BybitClient): - phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid + ) elif isinstance(client, CoinbaseExchangeClient): - phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + order_id=limit_order_id, client_order_id=limit_client_oid + ) elif isinstance(client, KrakenClient): phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id) elif isinstance(client, KrakenFuturesClient): - phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + order_id=limit_order_id, client_order_id=limit_client_oid + ) elif isinstance(client, KucoinSpotClient): - phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + order_id=limit_order_id, client_order_id=limit_client_oid + ) elif isinstance(client, KucoinFuturesClient): - phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + order_id=limit_order_id, client_order_id=limit_client_oid + ) elif isinstance(client, GateSpotClient): phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id) elif isinstance(client, GateUsdtFuturesClient): phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id) elif isinstance(client, BitfinexClient): - phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + order_id=limit_order_id, client_order_id=limit_client_oid + ) elif isinstance(client, BitfinexDerivativesClient): - phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + order_id=limit_order_id, client_order_id=limit_client_oid + ) elif isinstance(client, DeepcoinClient): - phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid + ) elif isinstance(client, HtxClient): - phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid) + phases["limit_cancel"] = client.cancel_order( + symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid + ) except Exception: pass except LiveTradingError as e: - logger.warning(f"live limit phase failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}") + logger.warning( + f"live limit phase failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}" + ) # Fall back to market for full amount remaining = float(amount or 0.0) phases["limit_error"] = str(e) except Exception as e: - logger.warning(f"live limit phase unexpected error: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}") + logger.warning( + f"live limit phase unexpected error: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}" + ) remaining = float(amount or 0.0) phases["limit_error"] = str(e) @@ -1719,9 +1924,17 @@ class PendingOrderWorker: client_order_id=market_client_oid, ) elif isinstance(client, BitgetMixClient): - product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") + product_type = str( + exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES" + ) margin_coin = str(exchange_config.get("margin_coin") or exchange_config.get("marginCoin") or "USDT") - margin_mode = str(payload.get("margin_mode") or payload.get("marginMode") or exchange_config.get("margin_mode") or exchange_config.get("marginMode") or "cross") + margin_mode = str( + payload.get("margin_mode") + or payload.get("marginMode") + or exchange_config.get("margin_mode") + or exchange_config.get("marginMode") + or "cross" + ) try: if market_type == "swap": client.set_leverage( @@ -1847,7 +2060,9 @@ class PendingOrderWorker: client_order_id=market_client_oid, ) elif isinstance(client, BitfinexDerivativesClient): - res2 = client.place_market_order(symbol=str(symbol), side=side, size=remaining, client_order_id=market_client_oid) + res2 = client.place_market_order( + symbol=str(symbol), side=side, size=remaining, client_order_id=market_client_oid + ) elif isinstance(client, DeepcoinClient): if market_type == "swap": try: @@ -1884,41 +2099,77 @@ class PendingOrderWorker: # Query fills (short wait) if isinstance(client, BinanceFuturesClient): - q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + q2 = client.wait_for_fill( + symbol=str(symbol), + order_id=market_order_id, + client_order_id=market_client_oid, + max_wait_sec=3.0, + ) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) fee_v, fee_c = _fetch_fee_best_effort(order_id0=market_order_id, client_order_id0=market_client_oid) _apply_fee(float(fee_v or 0.0), str(fee_c or "")) elif isinstance(client, BinanceSpotClient): - q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + q2 = client.wait_for_fill( + symbol=str(symbol), + order_id=market_order_id, + client_order_id=market_client_oid, + max_wait_sec=3.0, + ) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) fee_v, fee_c = _fetch_fee_best_effort(order_id0=market_order_id, client_order_id0=market_client_oid) _apply_fee(float(fee_v or 0.0), str(fee_c or "")) elif isinstance(client, OkxClient): # OKX fills endpoint may lag shortly after execution; wait a bit longer to capture fee. - q2 = client.wait_for_fill(symbol=str(symbol), ord_id=market_order_id, cl_ord_id=market_client_oid, market_type=market_type, max_wait_sec=12.0) + q2 = client.wait_for_fill( + symbol=str(symbol), + ord_id=market_order_id, + cl_ord_id=market_client_oid, + market_type=market_type, + max_wait_sec=12.0, + ) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) elif isinstance(client, BitgetMixClient): - product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES") - q2 = client.wait_for_fill(symbol=str(symbol), product_type=product_type, order_id=market_order_id, client_oid=market_client_oid, max_wait_sec=3.0) + product_type = str( + exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES" + ) + q2 = client.wait_for_fill( + symbol=str(symbol), + product_type=product_type, + order_id=market_order_id, + client_oid=market_client_oid, + max_wait_sec=3.0, + ) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) elif isinstance(client, BitgetSpotClient): - q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + q2 = client.wait_for_fill( + symbol=str(symbol), + order_id=market_order_id, + client_order_id=market_client_oid, + max_wait_sec=3.0, + ) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) elif isinstance(client, BybitClient): - q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + q2 = client.wait_for_fill( + symbol=str(symbol), + order_id=market_order_id, + client_order_id=market_client_oid, + max_wait_sec=3.0, + ) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) elif isinstance(client, CoinbaseExchangeClient): - q2 = client.wait_for_fill(order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + q2 = client.wait_for_fill( + order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0 + ) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) @@ -1928,7 +2179,9 @@ class PendingOrderWorker: _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) elif isinstance(client, KrakenFuturesClient): - q2 = client.wait_for_fill(order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + q2 = client.wait_for_fill( + order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0 + ) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) @@ -1948,7 +2201,9 @@ class PendingOrderWorker: _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) elif isinstance(client, GateUsdtFuturesClient): - q2 = client.wait_for_fill(order_id=market_order_id, contract=to_gate_currency_pair(str(symbol)), max_wait_sec=3.0) + q2 = client.wait_for_fill( + order_id=market_order_id, contract=to_gate_currency_pair(str(symbol)), max_wait_sec=3.0 + ) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) @@ -1963,17 +2218,29 @@ class PendingOrderWorker: _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) elif isinstance(client, DeepcoinClient): - q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + q2 = client.wait_for_fill( + symbol=str(symbol), + order_id=market_order_id, + client_order_id=market_client_oid, + max_wait_sec=3.0, + ) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) elif isinstance(client, HtxClient): - q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + q2 = client.wait_for_fill( + symbol=str(symbol), + order_id=market_order_id, + client_order_id=market_client_oid, + max_wait_sec=3.0, + ) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) except LiveTradingError as e: - logger.warning(f"live market phase failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}") + logger.warning( + f"live market phase failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}" + ) phases["market_error"] = str(e) # If we already got any fills in the limit phase, treat as partial success instead of failing the whole order. if float(total_base or 0.0) > 0: @@ -1987,9 +2254,13 @@ class PendingOrderWorker: _notify_live_best_effort(status="failed", error=str(e), amount_hint=amount, price_hint=ref_price) return except Exception as e: - logger.warning(f"live market phase unexpected error: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}") + logger.warning( + f"live market phase unexpected error: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}" + ) self._mark_failed(order_id=order_id, error=str(e)) - _console_print(f"[worker] order unexpected error: strategy_id={strategy_id} pending_id={order_id} err={e}") + _console_print( + f"[worker] order unexpected error: strategy_id={strategy_id} pending_id={order_id} err={e}" + ) _notify_live_best_effort(status="failed", error=str(e), amount_hint=amount, price_hint=ref_price) return @@ -2000,7 +2271,17 @@ class PendingOrderWorker: filled_final = float(amount or 0.0) avg_final = float(ref_price or 0.0) - res = type("Tmp", (), {"exchange_id": str(exchange_config.get("exchange_id") or ""), "exchange_order_id": str(market_order_id or limit_order_id), "raw": phases, "filled": filled_final, "avg_price": avg_final})() + res = type( + "Tmp", + (), + { + "exchange_id": str(exchange_config.get("exchange_id") or ""), + "exchange_order_id": str(market_order_id or limit_order_id), + "raw": phases, + "filled": filled_final, + "avg_price": avg_final, + }, + )() executed_at = int(time.time()) filled = filled_final @@ -2019,7 +2300,9 @@ class PendingOrderWorker: avg_price=avg_price, executed_at=executed_at, ) - _console_print(f"[worker] order sent: strategy_id={strategy_id} pending_id={order_id} exchange={res.exchange_id} order_id={res.exchange_order_id} filled={filled} avg={avg_price}") + _console_print( + f"[worker] order sent: strategy_id={strategy_id} pending_id={order_id} exchange={res.exchange_id} order_id={res.exchange_order_id} filled={filled} avg={avg_price}" + ) except Exception as e: logger.warning(f"mark_sent failed: pending_id={order_id}, err={e}") @@ -2052,7 +2335,9 @@ class PendingOrderWorker: commission_ccy=str(fee_ccy or "").strip().upper(), profit=profit, ) - logger.info(f"live record done: pending_id={order_id} strategy_id={strategy_id} symbol={symbol} signal={signal_type}") + logger.info( + f"live record done: pending_id={order_id} strategy_id={strategy_id} symbol={symbol} signal={signal_type}" + ) except Exception as e: logger.warning(f"record_trade/update_position failed: pending_id={order_id}, err={e}") @@ -2095,7 +2380,9 @@ class PendingOrderWorker: # Stocks: no short selling in basic implementation if "short" in sig: self._mark_failed(order_id=order_id, error="ibkr_stock_short_not_supported") - _console_print(f"[worker] IBKR order rejected: strategy_id={strategy_id} pending_id={order_id} short not supported") + _console_print( + f"[worker] IBKR order rejected: strategy_id={strategy_id} pending_id={order_id} short not supported" + ) _notify_live_best_effort(status="failed", error="ibkr_stock_short_not_supported") return @@ -2106,17 +2393,19 @@ class PendingOrderWorker: action = "sell" else: self._mark_failed(order_id=order_id, error=f"ibkr_unsupported_signal:{signal_type}") - _console_print(f"[worker] IBKR order rejected: strategy_id={strategy_id} pending_id={order_id} unsupported signal {signal_type}") + _console_print( + f"[worker] IBKR order rejected: strategy_id={strategy_id} pending_id={order_id} unsupported signal {signal_type}" + ) _notify_live_best_effort(status="failed", error=f"ibkr_unsupported_signal:{signal_type}") return # Get market type (USStock) market_type = str( - payload.get("market_type") or - payload.get("market_category") or - exchange_config.get("market_type") or - exchange_config.get("market_category") or - "USStock" + payload.get("market_type") + or payload.get("market_category") + or exchange_config.get("market_type") + or exchange_config.get("market_category") + or "USStock" ).strip() try: @@ -2130,7 +2419,9 @@ class PendingOrderWorker: if not result.success: self._mark_failed(order_id=order_id, error=f"ibkr_order_failed:{result.message}") - _console_print(f"[worker] IBKR order failed: strategy_id={strategy_id} pending_id={order_id} err={result.message}") + _console_print( + f"[worker] IBKR order failed: strategy_id={strategy_id} pending_id={order_id} err={result.message}" + ) _notify_live_best_effort(status="failed", error=f"ibkr_order_failed:{result.message}") return @@ -2157,7 +2448,9 @@ class PendingOrderWorker: avg_price=avg_price, executed_at=executed_at, ) - _console_print(f"[worker] IBKR order sent: strategy_id={strategy_id} pending_id={order_id} order_id={exchange_order_id} filled={filled} avg={avg_price}") + _console_print( + f"[worker] IBKR order sent: strategy_id={strategy_id} pending_id={order_id} order_id={exchange_order_id} filled={filled} avg={avg_price}" + ) # Record trade and update position try: @@ -2240,24 +2533,29 @@ class PendingOrderWorker: action = "buy" else: self._mark_failed(order_id=order_id, error=f"mt5_unsupported_signal:{signal_type}") - _console_print(f"[worker] MT5 order rejected: strategy_id={strategy_id} pending_id={order_id} unsupported signal {signal_type}") + _console_print( + f"[worker] MT5 order rejected: strategy_id={strategy_id} pending_id={order_id} unsupported signal {signal_type}" + ) _notify_live_best_effort(status="failed", error=f"mt5_unsupported_signal:{signal_type}") return try: # Ensure client is connected before placing order if not client.connected: - logger.warning(f"MT5 client not connected, attempting reconnect: strategy_id={strategy_id}, pending_id={order_id}") + logger.warning( + f"MT5 client not connected, attempting reconnect: strategy_id={strategy_id}, pending_id={order_id}" + ) if not client.connect(): self._mark_failed(order_id=order_id, error="mt5_connection_failed") _console_print(f"[worker] MT5 connection failed: strategy_id={strategy_id} pending_id={order_id}") _notify_live_best_effort(status="failed", error="mt5_connection_failed") return - + # Normalize symbol before placing order (MT5 requires specific format) from app.services.mt5_trading.symbols import normalize_symbol + normalized_symbol = normalize_symbol(symbol) - + # Place market order via MT5 result = client.place_market_order( symbol=normalized_symbol, @@ -2268,7 +2566,9 @@ class PendingOrderWorker: if not result.success: self._mark_failed(order_id=order_id, error=f"mt5_order_failed:{result.message}") - _console_print(f"[worker] MT5 order failed: strategy_id={strategy_id} pending_id={order_id} err={result.message}") + _console_print( + f"[worker] MT5 order failed: strategy_id={strategy_id} pending_id={order_id} err={result.message}" + ) _notify_live_best_effort(status="failed", error=f"mt5_order_failed:{result.message}") return @@ -2295,7 +2595,9 @@ class PendingOrderWorker: avg_price=avg_price, executed_at=executed_at, ) - _console_print(f"[worker] MT5 order sent: strategy_id={strategy_id} pending_id={order_id} order_id={exchange_order_id} filled={filled} avg={avg_price}") + _console_print( + f"[worker] MT5 order sent: strategy_id={strategy_id} pending_id={order_id} order_id={exchange_order_id} filled={filled} avg={avg_price}" + ) # Record trade and update position try: @@ -2416,5 +2718,3 @@ class PendingOrderWorker: ) db.commit() cur.close() - - diff --git a/backend_api_python/app/services/polymarket_analyzer.py b/backend_api_python/app/services/polymarket_analyzer.py index 04c237a..09c29fc 100644 --- a/backend_api_python/app/services/polymarket_analyzer.py +++ b/backend_api_python/app/services/polymarket_analyzer.py @@ -2,38 +2,41 @@ Polymarket Prediction Market Analyzer Analyze prediction markets and generate AI predictions and trading opportunity recommendations """ + import json import re -from typing import Dict, List, Any, Optional from datetime import datetime +from typing import Dict, List, Optional -from app.utils.logger import get_logger -from app.utils.db import get_db_connection +from app.data_sources.polymarket import PolymarketDataSource from app.services.llm import LLMService from app.services.market_data_collector import get_market_data_collector -from app.data_sources.polymarket import PolymarketDataSource +from app.utils.db import get_db_connection +from app.utils.logger import get_logger logger = get_logger(__name__) class PolymarketAnalyzer: """Prediction Market AI Analyzer""" - + def __init__(self): self.llm_service = LLMService() self.data_collector = get_market_data_collector() self.polymarket_source = PolymarketDataSource() - - def analyze_market(self, market_id: str, user_id: int = None, use_cache: bool = True, language: str = 'zh-CN', model: str = None) -> Dict: + + def analyze_market( + self, market_id: str, user_id: int = None, use_cache: bool = True, language: str = "zh-CN", model: str = None + ) -> Dict: """ Analyze a single prediction market - + Args: market_id: Market ID user_id: User ID (optional, for user-specific analysis) use_cache: whether to use cached analysis results (default True) language: language setting ('zh-CN' or 'en-US'), used to generate AI analysis results in the corresponding language - + Returns: Analysis results dictionary """ @@ -41,11 +44,8 @@ class PolymarketAnalyzer: # 1. Get market data market = self.polymarket_source.get_market_details(market_id) if not market: - return { - "error": "Market not found", - "market_id": market_id - } - + return {"error": "Market not found", "market_id": market_id} + # 2. If cache is used, check whether there are cached analysis results (valid for 30 minutes) if use_cache: cached_analysis = self._get_cached_analysis(market_id, user_id) @@ -54,178 +54,177 @@ class PolymarketAnalyzer: if self._is_analysis_fresh(cached_analysis, max_age_minutes=cache_minutes): logger.debug(f"Using cached analysis for market {market_id}") return cached_analysis - + # 3. Collect relevant data - related_news = self._get_related_news(market['question']) - related_assets = self._identify_related_assets(market['question']) + related_news = self._get_related_news(market["question"]) + related_assets = self._identify_related_assets(market["question"]) asset_data = self._get_asset_data(related_assets) - + # 4. AI analysis ai_result = self._ai_predict_probability( - question=market['question'], - current_market_prob=market['current_probability'], + question=market["question"], + current_market_prob=market["current_probability"], related_news=related_news, asset_data=asset_data, - language=language + language=language, ) - + # 5. Calculate opportunity scores opportunity_score = self._calculate_opportunity_score( - ai_prob=ai_result['predicted_probability'], - market_prob=market['current_probability'], - confidence=ai_result['confidence'] + ai_prob=ai_result["predicted_probability"], + market_prob=market["current_probability"], + confidence=ai_result["confidence"], ) - + # 6. Generate recommendations recommendation = self._generate_recommendation( - divergence=ai_result['predicted_probability'] - market['current_probability'], - confidence=ai_result['confidence'] + divergence=ai_result["predicted_probability"] - market["current_probability"], + confidence=ai_result["confidence"], ) - + # 7. Construct analysis results analysis_result = { "market_id": market_id, - "ai_predicted_probability": ai_result['predicted_probability'], - "market_probability": market['current_probability'], - "divergence": ai_result['predicted_probability'] - market['current_probability'], + "ai_predicted_probability": ai_result["predicted_probability"], + "market_probability": market["current_probability"], + "divergence": ai_result["predicted_probability"] - market["current_probability"], "recommendation": recommendation, - "confidence_score": ai_result['confidence'], - "reasoning": ai_result['reasoning'], - "key_factors": ai_result.get('key_factors', []), - "risk_factors": ai_result.get('risk_factors', []), + "confidence_score": ai_result["confidence"], + "reasoning": ai_result["reasoning"], + "key_factors": ai_result.get("key_factors", []), + "risk_factors": ai_result.get("risk_factors", []), "related_assets": related_assets, "risk_level": self._assess_risk(market, ai_result), - "opportunity_score": opportunity_score + "opportunity_score": opportunity_score, } - + # 8. Save to database self._save_analysis_to_db(analysis_result, user_id) - + return analysis_result - + except Exception as e: logger.error(f"Failed to analyze market {market_id}: {e}", exc_info=True) - return { - "error": str(e), - "market_id": market_id - } - + return {"error": str(e), "market_id": market_id} + def generate_asset_trading_opportunities(self, market_id: str) -> List[Dict]: """ Generate trading opportunities for related assets based on prediction markets - + Args: market_id: prediction market ID - + Returns: List of asset trading opportunities """ try: # 1. Analyze prediction markets market_analysis = self.analyze_market(market_id) - if market_analysis.get('error'): + if market_analysis.get("error"): return [] - + # 2. Identify relevant assets - related_assets = market_analysis.get('related_assets', []) + related_assets = market_analysis.get("related_assets", []) if not related_assets: return [] - + # 3. Perform technical analysis on each asset opportunities = [] for asset in related_assets: try: # Infer market type market_type = self._infer_market(asset) - + # Get asset data asset_data = self.data_collector.collect_all( market=market_type, symbol=asset, timeframe="1D", - include_polymarket=False # avoid loops + include_polymarket=False, # avoid loops ) - + # technical analysis technical_analysis = self._analyze_technical(asset_data) - + # Incorporate Predictive Market Signals - if market_analysis['recommendation'] == "YES": + if market_analysis["recommendation"] == "YES": # Predicted event probability is high → related assets may rise - signal = "BUY" if technical_analysis.get('trend') == "bullish" else "HOLD" - elif market_analysis['recommendation'] == "NO": + signal = "BUY" if technical_analysis.get("trend") == "bullish" else "HOLD" + elif market_analysis["recommendation"] == "NO": # The probability of the predicted event is low → the related assets may fall - signal = "SELL" if technical_analysis.get('trend') == "bearish" else "HOLD" + signal = "SELL" if technical_analysis.get("trend") == "bearish" else "HOLD" else: signal = "HOLD" - + # Calculate overall confidence confidence = ( - market_analysis['confidence_score'] * 0.6 + - technical_analysis.get('confidence', 50) * 0.4 + market_analysis["confidence_score"] * 0.6 + technical_analysis.get("confidence", 50) * 0.4 ) - + if signal != "HOLD" and confidence > 60: - opportunities.append({ - "asset": asset, - "market": market_type, - "signal": signal, - "confidence": round(confidence, 2), - "reasoning": f"预测市场分析:{market_analysis['reasoning'][:200]}。技术面:{technical_analysis.get('summary', '')[:200]}", - "related_prediction": { - "market_id": market_id, - "question": market_analysis.get('question', ''), - "ai_probability": market_analysis['ai_predicted_probability'], - "market_probability": market_analysis['market_probability'] - }, - "entry_suggestion": technical_analysis.get('entry_suggestion', {}) - }) + opportunities.append( + { + "asset": asset, + "market": market_type, + "signal": signal, + "confidence": round(confidence, 2), + "reasoning": f"预测市场分析:{market_analysis['reasoning'][:200]}。技术面:{technical_analysis.get('summary', '')[:200]}", + "related_prediction": { + "market_id": market_id, + "question": market_analysis.get("question", ""), + "ai_probability": market_analysis["ai_predicted_probability"], + "market_probability": market_analysis["market_probability"], + }, + "entry_suggestion": technical_analysis.get("entry_suggestion", {}), + } + ) except Exception as e: logger.debug(f"Failed to analyze asset {asset} for market {market_id}: {e}") continue - + # Save opportunity to database if opportunities: self._save_opportunities_to_db(market_id, opportunities) - + return opportunities - + except Exception as e: logger.error(f"Failed to generate asset opportunities for {market_id}: {e}") return [] - - def _ai_predict_probability(self, question: str, current_market_prob: float, - related_news: List, asset_data: Dict, language: str = 'zh-CN') -> Dict: + + def _ai_predict_probability( + self, question: str, current_market_prob: float, related_news: List, asset_data: Dict, language: str = "zh-CN" + ) -> Dict: """Using AI to predict event probabilities""" try: # Build prompt based on language settings - is_english = language.lower() in ['en', 'en-us', 'en_us'] - + is_english = language.lower() in ["en", "en-us", "en_us"] + # build prompt news_text = "\n".join([f"- {n.get('title', '')[:100]}" for n in related_news[:5]]) - + asset_text = "" if asset_data: - price_data = asset_data.get('price', {}) - indicators = asset_data.get('indicators', {}) + price_data = asset_data.get("price", {}) + indicators = asset_data.get("indicators", {}) if price_data: if is_english: asset_text = f""" Related Asset Data: -- Current Price: {price_data.get('current_price', 'N/A')} -- 24h Change: {price_data.get('change_24h', 0):.2f}% -- RSI: {indicators.get('rsi', {}).get('value', 'N/A')} -- MACD: {indicators.get('macd', {}).get('signal', 'N/A')} +- Current Price: {price_data.get("current_price", "N/A")} +- 24h Change: {price_data.get("change_24h", 0):.2f}% +- RSI: {indicators.get("rsi", {}).get("value", "N/A")} +- MACD: {indicators.get("macd", {}).get("signal", "N/A")} """ else: asset_text = f""" 相关资产数据: -- 当前价格: {price_data.get('current_price', 'N/A')} -- 24h涨跌幅: {price_data.get('change_24h', 0):.2f}% -- RSI: {indicators.get('rsi', {}).get('value', 'N/A')} -- MACD: {indicators.get('macd', {}).get('signal', 'N/A')} +- 当前价格: {price_data.get("current_price", "N/A")} +- 24h涨跌幅: {price_data.get("change_24h", 0):.2f}% +- RSI: {indicators.get("rsi", {}).get("value", "N/A")} +- MACD: {indicators.get("macd", {}).get("signal", "N/A")} """ - + if is_english: prompt = f"""Analyze the following prediction market event and assess its probability of occurrence: @@ -254,7 +253,7 @@ Output JSON format: }} IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors) must be in English.""" - + system_prompt = "You are a professional market analyst specializing in prediction market analysis. Please objectively assess the probability of events occurring based on the provided data. Respond in English." else: prompt = f"""分析以下预测市场事件,评估其发生的概率: @@ -284,62 +283,48 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors) }} 重要提示:JSON响应中的所有文本(reasoning、key_factors、risk_factors)必须使用中文。""" - + system_prompt = "你是一个专业的市场分析师,擅长分析预测市场事件。请基于提供的数据,客观评估事件发生的概率。请使用中文回答。" - + # Call LLM - messages = [ - { - "role": "system", - "content": system_prompt - }, - { - "role": "user", - "content": prompt - } - ] - - result = self.llm_service.call_llm_api( - messages=messages, - use_json_mode=True, - temperature=0.3 - ) - + messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}] + + result = self.llm_service.call_llm_api(messages=messages, use_json_mode=True, temperature=0.3) + # Parse results if isinstance(result, str): result = json.loads(result) - + # Validation and normalization - predicted_prob = float(result.get('predicted_probability', current_market_prob)) + predicted_prob = float(result.get("predicted_probability", current_market_prob)) predicted_prob = max(0, min(100, predicted_prob)) # Limit to 0-100 - - confidence = float(result.get('confidence', 70)) + + confidence = float(result.get("confidence", 70)) confidence = max(0, min(100, confidence)) - + return { - 'predicted_probability': round(predicted_prob, 2), - 'confidence': round(confidence, 2), - 'reasoning': result.get('reasoning', ''), - 'key_factors': result.get('key_factors', []), - 'risk_factors': result.get('risk_factors', []) + "predicted_probability": round(predicted_prob, 2), + "confidence": round(confidence, 2), + "reasoning": result.get("reasoning", ""), + "key_factors": result.get("key_factors", []), + "risk_factors": result.get("risk_factors", []), } - + except Exception as e: logger.error(f"AI prediction failed: {e}", exc_info=True) # Return to default value return { - 'predicted_probability': current_market_prob, - 'confidence': 50.0, - 'reasoning': f'分析失败: {str(e)}', - 'key_factors': [], - 'risk_factors': [] + "predicted_probability": current_market_prob, + "confidence": 50.0, + "reasoning": f"分析失败: {str(e)}", + "key_factors": [], + "risk_factors": [], } - - def _calculate_opportunity_score(self, ai_prob: float, market_prob: float, - confidence: float) -> float: + + def _calculate_opportunity_score(self, ai_prob: float, market_prob: float, confidence: float) -> float: """ Calculate opportunity rating (0-100) - + logic: - The greater the difference between AI and the market, the better the opportunity - The higher the confidence, the better the chance @@ -349,13 +334,13 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors) divergence_score = min(divergence * 2, 40) # The higher the confidence level, the better the chance (maximum 60 points) confidence_score = confidence * 0.6 - + return round(divergence_score + confidence_score, 2) - + def _generate_recommendation(self, divergence: float, confidence: float) -> str: """ Generate recommendations: YES/NO/HOLD - + logic: - AI Probability > Market Probability + 5% and Confidence > 60 → YES - AI probability < market probability - 5% and confidence level > 60 → NO @@ -367,118 +352,107 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors) return "NO" else: return "HOLD" - + def _assess_risk(self, market: Dict, ai_result: Dict) -> str: """Assess risk level""" - confidence = ai_result.get('confidence', 50) - divergence = abs(ai_result.get('predicted_probability', 50) - market.get('current_probability', 50)) - + confidence = ai_result.get("confidence", 50) + divergence = abs(ai_result.get("predicted_probability", 50) - market.get("current_probability", 50)) + if confidence < 50 or divergence > 30: return "high" elif confidence < 70 or divergence > 15: return "medium" else: return "low" - + def _get_related_news(self, question: str) -> List[Dict]: """Get news on relevant issues""" # Extract keywords - keywords = self._extract_keywords(question) - + # Here you can call the news API and temporarily return an empty list # In actual implementation, existing news services can be called return [] - + def _identify_related_assets(self, question: str) -> List[str]: """Identify related assets mentioned in the question""" assets = [] - + # Cryptocurrency Keyword Mapping crypto_keywords = { - 'BTC': ['BTC', 'Bitcoin', 'bitcoin', 'btc'], - 'ETH': ['ETH', 'Ethereum', 'ethereum', 'eth'], - 'SOL': ['SOL', 'Solana', 'solana', 'sol'], - 'BNB': ['BNB', 'Binance', 'binance', 'bnb'], - 'XRP': ['XRP', 'Ripple', 'ripple', 'xrp'], - 'ADA': ['ADA', 'Cardano', 'cardano', 'ada'], - 'DOGE': ['DOGE', 'Dogecoin', 'dogecoin', 'doge'], - 'AVAX': ['AVAX', 'Avalanche', 'avalanche', 'avax'], - 'DOT': ['DOT', 'Polkadot', 'polkadot', 'dot'], - 'MATIC': ['MATIC', 'Polygon', 'polygon', 'matic'] + "BTC": ["BTC", "Bitcoin", "bitcoin", "btc"], + "ETH": ["ETH", "Ethereum", "ethereum", "eth"], + "SOL": ["SOL", "Solana", "solana", "sol"], + "BNB": ["BNB", "Binance", "binance", "bnb"], + "XRP": ["XRP", "Ripple", "ripple", "xrp"], + "ADA": ["ADA", "Cardano", "cardano", "ada"], + "DOGE": ["DOGE", "Dogecoin", "dogecoin", "doge"], + "AVAX": ["AVAX", "Avalanche", "avalanche", "avax"], + "DOT": ["DOT", "Polkadot", "polkadot", "dot"], + "MATIC": ["MATIC", "Polygon", "polygon", "matic"], } - + question_upper = question.upper() for symbol, keywords in crypto_keywords.items(): if any(kw in question_upper for kw in keywords): assets.append(f"{symbol}/USDT") - + # Remove duplicates return list(set(assets)) - + def _get_asset_data(self, assets: List[str]) -> Optional[Dict]: """Get asset data (get the first asset)""" if not assets: return None - + try: asset = assets[0] market_type = self._infer_market(asset) - return self.data_collector.collect_all( - market=market_type, - symbol=asset, - timeframe="1D" - ) + return self.data_collector.collect_all(market=market_type, symbol=asset, timeframe="1D") except Exception as e: logger.debug(f"Failed to get asset data for {assets}: {e}") return None - + def _analyze_technical(self, asset_data: Dict) -> Dict: """simple technical analysis""" if not asset_data: - return { - 'trend': 'neutral', - 'confidence': 50, - 'summary': '数据不足', - 'entry_suggestion': {} - } - - indicators = asset_data.get('indicators', {}) - price_data = asset_data.get('price', {}) - + return {"trend": "neutral", "confidence": 50, "summary": "数据不足", "entry_suggestion": {}} + + indicators = asset_data.get("indicators", {}) + # Simple trend judgment - rsi = indicators.get('rsi', {}).get('value', 50) - macd_signal = indicators.get('macd', {}).get('signal', 'neutral') - - trend = 'neutral' - if rsi > 60 and macd_signal == 'bullish': - trend = 'bullish' - elif rsi < 40 and macd_signal == 'bearish': - trend = 'bearish' - + rsi = indicators.get("rsi", {}).get("value", 50) + macd_signal = indicators.get("macd", {}).get("signal", "neutral") + + trend = "neutral" + if rsi > 60 and macd_signal == "bullish": + trend = "bullish" + elif rsi < 40 and macd_signal == "bearish": + trend = "bearish" + confidence = 60 if abs(rsi - 50) > 15 else 50 - + return { - 'trend': trend, - 'confidence': confidence, - 'summary': f'RSI: {rsi:.1f}, MACD: {macd_signal}', - 'entry_suggestion': {} + "trend": trend, + "confidence": confidence, + "summary": f"RSI: {rsi:.1f}, MACD: {macd_signal}", + "entry_suggestion": {}, } - + def _infer_market(self, symbol: str) -> str: """Infer market type""" - if '/' in symbol: + if "/" in symbol: return "Crypto" elif len(symbol) <= 5 and symbol.isupper(): return "USStock" else: return "Crypto" # default - + def _extract_keywords(self, text: str) -> List[str]: """Extract keywords""" # Simple keyword extraction - words = re.findall(r'\b[A-Z][a-z]+\b|\b[A-Z]{2,}\b', text) + words = re.findall(r"\b[A-Z][a-z]+\b|\b[A-Z]{2,}\b", text) return [w.lower() for w in words if len(w) > 2] - + def _get_cached_analysis(self, market_id: str, user_id: int = None) -> Optional[Dict]: """Get cached analysis results""" try: @@ -492,22 +466,22 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors) WHERE market_id = %s """ params = [market_id] - + if user_id: query += " AND user_id = %s" params.append(user_id) else: query += " AND user_id IS NULL" - + query += " ORDER BY created_at DESC LIMIT 1" - + cur.execute(query, params) row = cur.fetchone() cur.close() - + if row: - #RealDictCursor returns the dictionary, accessed using keys - key_factors_raw = row.get('key_factors') + # RealDictCursor returns the dictionary, accessed using keys + key_factors_raw = row.get("key_factors") key_factors = [] if key_factors_raw: try: @@ -515,44 +489,44 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors) key_factors = json.loads(key_factors_raw) else: key_factors = key_factors_raw if isinstance(key_factors_raw, list) else [] - except: + except Exception as e: + logger.debug(f"Failed to parse key_factors: {e}") key_factors = [] - + return { "market_id": market_id, - "ai_predicted_probability": float(row.get('ai_predicted_probability') or 0), - "market_probability": float(row.get('market_probability') or 0), - "divergence": float(row.get('divergence') or 0), - "recommendation": row.get('recommendation') or 'HOLD', - "confidence_score": float(row.get('confidence_score') or 0), - "opportunity_score": float(row.get('opportunity_score') or 0), - "reasoning": row.get('reasoning') or '', + "ai_predicted_probability": float(row.get("ai_predicted_probability") or 0), + "market_probability": float(row.get("market_probability") or 0), + "divergence": float(row.get("divergence") or 0), + "recommendation": row.get("recommendation") or "HOLD", + "confidence_score": float(row.get("confidence_score") or 0), + "opportunity_score": float(row.get("opportunity_score") or 0), + "reasoning": row.get("reasoning") or "", "key_factors": key_factors, - "related_assets": row.get('related_assets') if row.get('related_assets') else [], - "created_at": row.get('created_at') + "related_assets": row.get("related_assets") if row.get("related_assets") else [], + "created_at": row.get("created_at"), } except Exception as e: logger.debug(f"Failed to get cached analysis: {e}") - + return None - - + def _is_analysis_fresh(self, analysis: Dict, max_age_minutes: int = 30) -> bool: """检查分析结果是否新鲜""" - created_at = analysis.get('created_at') + created_at = analysis.get("created_at") if not created_at: return False - + if isinstance(created_at, str): - created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00')) - + created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + age = (datetime.now() - created_at.replace(tzinfo=None)).total_seconds() / 60 return age < max_age_minutes - - def _save_analysis_to_db(self, analysis: Dict, user_id: int = None, language: str = 'en-US', model: str = None): + + def _save_analysis_to_db(self, analysis: Dict, user_id: int = None, language: str = "en-US", model: str = None): """ Save analysis results to database - + Args: analysis: dictionary of analysis results user_id: user ID @@ -562,85 +536,103 @@ IMPORTANT: All text in the JSON response (reasoning, key_factors, risk_factors) try: with get_db_connection() as db: cur = db.cursor() - + # 1. Save to qd_polymarket_ai_analysis table (Polymarket special table) - cur.execute(""" + cur.execute( + """ INSERT INTO qd_polymarket_ai_analysis (market_id, user_id, ai_predicted_probability, market_probability, divergence, recommendation, confidence_score, opportunity_score, reasoning, key_factors, related_assets, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) - """, ( - analysis['market_id'], - user_id, - analysis['ai_predicted_probability'], - analysis['market_probability'], - analysis['divergence'], - analysis['recommendation'], - analysis['confidence_score'], - analysis['opportunity_score'], - analysis['reasoning'], - json.dumps(analysis.get('key_factors', [])), - analysis.get('related_assets', []) - )) - + """, + ( + analysis["market_id"], + user_id, + analysis["ai_predicted_probability"], + analysis["market_probability"], + analysis["divergence"], + analysis["recommendation"], + analysis["confidence_score"], + analysis["opportunity_score"], + analysis["reasoning"], + json.dumps(analysis.get("key_factors", [])), + analysis.get("related_assets", []), + ), + ) + # 2. Save to the qd_analysis_tasks table at the same time (for administrator statistics and unified historical record viewing) - market_info = analysis.get('market', {}) - market_title = market_info.get('question', '') or market_info.get('title', '') or f"Polymarket Market {analysis['market_id']}" - result_json = json.dumps({ - 'market_id': analysis['market_id'], - 'market_title': market_title, - 'analysis': analysis, - 'market': market_info, - 'type': 'polymarket' # Mark as Polymarket analysis - }, ensure_ascii=False) - - cur.execute(""" + market_info = analysis.get("market", {}) + market_title = ( + market_info.get("question", "") + or market_info.get("title", "") + or f"Polymarket Market {analysis['market_id']}" + ) + result_json = json.dumps( + { + "market_id": analysis["market_id"], + "market_title": market_title, + "analysis": analysis, + "market": market_info, + "type": "polymarket", # Mark as Polymarket analysis + }, + ensure_ascii=False, + ) + + cur.execute( + """ INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at, completed_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) RETURNING id - """, ( - int(user_id) if user_id else 1, - 'Polymarket', # market field - str(analysis['market_id']), # symbol field stores market_id - str(model) if model else '', - str(language), - 'completed', - result_json, - '' - )) + """, + ( + int(user_id) if user_id else 1, + "Polymarket", # market field + str(analysis["market_id"]), # symbol field stores market_id + str(model) if model else "", + str(language), + "completed", + result_json, + "", + ), + ) task_row = cur.fetchone() - task_id = task_row['id'] if task_row else None - + task_id = task_row["id"] if task_row else None + db.commit() cur.close() - + if task_id: - logger.debug(f"Saved Polymarket analysis to both tables: task_id={task_id}, market_id={analysis['market_id']}") + logger.debug( + f"Saved Polymarket analysis to both tables: task_id={task_id}, market_id={analysis['market_id']}" + ) except Exception as e: logger.error(f"Failed to save analysis to DB: {e}") - + def _save_opportunities_to_db(self, market_id: str, opportunities: List[Dict]): """保存交易机会到数据库""" try: with get_db_connection() as db: cur = db.cursor() for opp in opportunities: - cur.execute(""" + cur.execute( + """ INSERT INTO qd_polymarket_asset_opportunities (market_id, asset_symbol, asset_market, signal, confidence, reasoning, entry_suggestion, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s, NOW()) - """, ( - market_id, - opp['asset'], - opp['market'], - opp['signal'], - opp['confidence'], - opp['reasoning'], - json.dumps(opp.get('entry_suggestion', {})) - )) + """, + ( + market_id, + opp["asset"], + opp["market"], + opp["signal"], + opp["confidence"], + opp["reasoning"], + json.dumps(opp.get("entry_suggestion", {})), + ), + ) db.commit() cur.close() except Exception as e: diff --git a/backend_api_python/app/services/polymarket_batch_analyzer.py b/backend_api_python/app/services/polymarket_batch_analyzer.py index 1379f06..1ab53a5 100644 --- a/backend_api_python/app/services/polymarket_batch_analyzer.py +++ b/backend_api_python/app/services/polymarket_batch_analyzer.py @@ -2,41 +2,43 @@ Polymarket Batch Analyzer Analyze multiple markets at once and use AI to screen out markets with trading opportunities. """ + import json -from typing import List, Dict, Optional -from app.utils.logger import get_logger -from app.utils.db import get_db_connection -from app.services.llm import LLMService +from typing import Dict, List + from app.data_sources.polymarket import PolymarketDataSource +from app.services.llm import LLMService +from app.utils.db import get_db_connection +from app.utils.logger import get_logger logger = get_logger(__name__) class PolymarketBatchAnalyzer: """Batch analysis predicts the market, and AI screens trading opportunities""" - + def __init__(self): self.llm_service = LLMService() self.polymarket_source = PolymarketDataSource() - + def batch_analyze_markets(self, markets: List[Dict], max_opportunities: int = 20) -> List[Dict]: """ Analyze the market in batches and use AI to screen out markets with trading opportunities. - + Args: markets: list of markets max_opportunities: The maximum number of trading opportunities returned - + Returns: Filtered market list (including AI analysis results) """ if not markets: return [] - + try: # 1. Build prompts for batch analysis markets_summary = self._build_markets_summary(markets) - + prompt = f"""你是一个专业的预测市场分析师。请分析以下预测市场列表,筛选出最有交易机会的市场。 市场列表: @@ -68,76 +70,66 @@ class PolymarketBatchAnalyzer: - 机会评分 >= 60 才考虑 - 优先选择:高交易量 + 明显概率偏差 + 高置信度 - 简要说明原因,不要冗长""" - + # 2. Call LLM for batch analysis messages = [ { "role": "system", - "content": "你是一个专业的预测市场分析师,擅长从大量市场中快速识别有价值的交易机会。请客观、理性地分析,只推荐真正有优势的机会。" + "content": "你是一个专业的预测市场分析师,擅长从大量市场中快速识别有价值的交易机会。请客观、理性地分析,只推荐真正有优势的机会。", }, - { - "role": "user", - "content": prompt - } + {"role": "user", "content": prompt}, ] - + logger.info(f"Batch analyzing {len(markets)} markets, requesting {max_opportunities} opportunities") - result = self.llm_service.call_llm_api( - messages=messages, - use_json_mode=True, - temperature=0.3 - ) - + result = self.llm_service.call_llm_api(messages=messages, use_json_mode=True, temperature=0.3) + # 3. Parse the results if isinstance(result, str): try: result = json.loads(result) - except: + except Exception: logger.error(f"Failed to parse LLM result as JSON: {result[:200]}") return self._fallback_analysis(markets, max_opportunities) - - opportunities = result.get('opportunities', []) + + opportunities = result.get("opportunities", []) if not opportunities: logger.warning("LLM returned no opportunities, using fallback") return self._fallback_analysis(markets, max_opportunities) - + # 4. Incorporate AI analysis results into market data - opportunities_map = {opp.get('market_id'): opp for opp in opportunities} + opportunities_map = {opp.get("market_id"): opp for opp in opportunities} analyzed_markets = [] - + for market in markets: - market_id = market.get('market_id') + market_id = market.get("market_id") if not market_id: continue - + opp = opportunities_map.get(market_id) if opp: # Get the probability predicted by AI - predicted_prob = float(opp.get('predicted_probability', market.get('current_probability', 50.0))) - market_prob = market.get('current_probability', 50.0) + predicted_prob = float(opp.get("predicted_probability", market.get("current_probability", 50.0))) + market_prob = market.get("current_probability", 50.0) divergence = predicted_prob - market_prob - + # Merge AI analysis results - market['ai_analysis'] = { - 'predicted_probability': predicted_prob, # Probability predicted using AI - 'recommendation': opp.get('recommendation', 'HOLD'), - 'confidence_score': float(opp.get('confidence', 0)), - 'opportunity_score': float(opp.get('opportunity_score', 0)), - 'divergence': divergence, # AI Predicted Probability - Market Probability - 'reasoning': opp.get('reason', ''), - 'key_factors': opp.get('key_factors', []) + market["ai_analysis"] = { + "predicted_probability": predicted_prob, # Probability predicted using AI + "recommendation": opp.get("recommendation", "HOLD"), + "confidence_score": float(opp.get("confidence", 0)), + "opportunity_score": float(opp.get("opportunity_score", 0)), + "divergence": divergence, # AI Predicted Probability - Market Probability + "reasoning": opp.get("reason", ""), + "key_factors": opp.get("key_factors", []), } analyzed_markets.append(market) - + # 5. Sort by opportunity score - analyzed_markets.sort( - key=lambda x: x.get('ai_analysis', {}).get('opportunity_score', 0), - reverse=True - ) - + analyzed_markets.sort(key=lambda x: x.get("ai_analysis", {}).get("opportunity_score", 0), reverse=True) + logger.info(f"Batch analysis completed: {len(analyzed_markets)} opportunities identified") return analyzed_markets - + except Exception as e: error_msg = str(e) # Provide more helpful error messages for common API errors @@ -156,18 +148,18 @@ class PolymarketBatchAnalyzer: else: logger.error(f"Batch analysis failed: {error_msg}", exc_info=True) return self._fallback_analysis(markets, max_opportunities) - + def _build_markets_summary(self, markets: List[Dict]) -> str: """Build market summaries for batch analysis""" summary_lines = [] - + for i, market in enumerate(markets[:50], 1): # Limit to 50 to avoid prompts that are too long - market_id = market.get('market_id', '') - question = market.get('question', '')[:100] # Limit length - prob = market.get('current_probability', 50.0) - volume = market.get('volume_24h', 0) - category = market.get('category', 'other') - + market_id = market.get("market_id", "") + question = market.get("question", "")[:100] # Limit length + prob = market.get("current_probability", 50.0) + volume = market.get("volume_24h", 0) + category = market.get("category", "other") + summary_lines.append( f"{i}. ID: {market_id}\n" f" 问题: {question}\n" @@ -175,87 +167,92 @@ class PolymarketBatchAnalyzer: f" 24h交易量: ${volume:,.0f}\n" f" 分类: {category}" ) - + return "\n\n".join(summary_lines) - + def _fallback_analysis(self, markets: List[Dict], max_opportunities: int) -> List[Dict]: """Fallback analysis: filtering based on simple rules""" opportunities = [] - + for market in markets: - prob = market.get('current_probability', 50.0) - volume = market.get('volume_24h', 0) - + prob = market.get("current_probability", 50.0) + volume = market.get("volume_24h", 0) + # Simple rule: large trading volume + 50% probability of deviation if volume > 10000 and abs(prob - 50.0) > 10: opportunity_score = min(60 + abs(prob - 50.0) * 0.5, 90) - - market['ai_analysis'] = { - 'predicted_probability': prob, - 'recommendation': 'YES' if prob > 50 else 'NO', - 'confidence_score': 60.0, - 'opportunity_score': opportunity_score, - 'divergence': 0, - 'reasoning': f'高交易量({volume:,.0f}) + 明显概率偏差({prob:.1f}%)', - 'key_factors': ['高交易量', '概率偏差'] + + market["ai_analysis"] = { + "predicted_probability": prob, + "recommendation": "YES" if prob > 50 else "NO", + "confidence_score": 60.0, + "opportunity_score": opportunity_score, + "divergence": 0, + "reasoning": f"高交易量({volume:,.0f}) + 明显概率偏差({prob:.1f}%)", + "key_factors": ["高交易量", "概率偏差"], } opportunities.append(market) - + # Sort by opportunity score - opportunities.sort( - key=lambda x: x.get('ai_analysis', {}).get('opportunity_score', 0), - reverse=True - ) - + opportunities.sort(key=lambda x: x.get("ai_analysis", {}).get("opportunity_score", 0), reverse=True) + return opportunities[:max_opportunities] - + def save_batch_analysis(self, markets: List[Dict]): """Save batch analysis results to database""" try: with get_db_connection() as db: cur = db.cursor() - + for market in markets: - market_id = market.get('market_id') - ai_analysis = market.get('ai_analysis') - + market_id = market.get("market_id") + ai_analysis = market.get("ai_analysis") + if not market_id or not ai_analysis: continue - + try: # First delete the old analysis records of this market (general analysis with user_id as NULL) - cur.execute(""" + cur.execute( + """ DELETE FROM qd_polymarket_ai_analysis WHERE market_id = %s AND user_id IS NULL - """, (market_id,)) - - #Insert new analysis record - cur.execute(""" + """, + (market_id,), + ) + + # Insert new analysis record + cur.execute( + """ INSERT INTO qd_polymarket_ai_analysis (market_id, user_id, ai_predicted_probability, market_probability, divergence, recommendation, confidence_score, opportunity_score, reasoning, key_factors, related_assets, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) - """, ( - market_id, - None, # General analysis - float(ai_analysis.get('predicted_probability', market.get('current_probability', 50.0))), - market.get('current_probability', 50.0), - float(ai_analysis.get('divergence', 0)), - ai_analysis.get('recommendation', 'HOLD'), - ai_analysis.get('confidence_score', 0), - ai_analysis.get('opportunity_score', 0), - ai_analysis.get('reasoning', ''), - json.dumps(ai_analysis.get('key_factors', [])), - [] - )) + """, + ( + market_id, + None, # General analysis + float( + ai_analysis.get("predicted_probability", market.get("current_probability", 50.0)) + ), + market.get("current_probability", 50.0), + float(ai_analysis.get("divergence", 0)), + ai_analysis.get("recommendation", "HOLD"), + ai_analysis.get("confidence_score", 0), + ai_analysis.get("opportunity_score", 0), + ai_analysis.get("reasoning", ""), + json.dumps(ai_analysis.get("key_factors", [])), + [], + ), + ) except Exception as e: logger.warning(f"Failed to save analysis for market {market_id}: {e}") continue - + db.commit() cur.close() logger.info(f"Saved batch analysis for {len(markets)} markets") - + except Exception as e: logger.error(f"Failed to save batch analysis: {e}", exc_info=True) diff --git a/backend_api_python/app/services/polymarket_worker.py b/backend_api_python/app/services/polymarket_worker.py index 0abbe95..f197540 100644 --- a/backend_api_python/app/services/polymarket_worker.py +++ b/backend_api_python/app/services/polymarket_worker.py @@ -2,25 +2,26 @@ Polymarket background tasks Update market data every 30 minutes and analyze market opportunities in batches """ + +import os import threading import time -from datetime import datetime, timedelta -from typing import List, Dict, Optional -from app.utils.logger import get_logger -from app.utils.db import get_db_connection +from typing import Dict, Optional + from app.data_sources.polymarket import PolymarketDataSource from app.services.polymarket_batch_analyzer import PolymarketBatchAnalyzer +from app.utils.logger import get_logger logger = get_logger(__name__) class PolymarketWorker: """Polymarket data update and analysis background tasks""" - + def __init__(self, update_interval_minutes: int = 30, analysis_cache_minutes: int = 1440): # 24 hours cache """ Initialize background tasks - + Args: update_interval_minutes: market data update interval (minutes) analysis_cache_minutes: AI analysis result cache time (minutes) @@ -33,7 +34,7 @@ class PolymarketWorker: self.polymarket_source = PolymarketDataSource() self.batch_analyzer = PolymarketBatchAnalyzer() self._last_update_ts = 0.0 - + def start(self) -> bool: """Start background task""" with self._lock: @@ -42,9 +43,11 @@ class PolymarketWorker: self._stop_event.clear() self._thread = threading.Thread(target=self._run_loop, name="PolymarketWorker", daemon=True) self._thread.start() - logger.info(f"PolymarketWorker started (update_interval={self.update_interval_minutes}min, cache={self.analysis_cache_minutes}min)") + logger.info( + f"PolymarketWorker started (update_interval={self.update_interval_minutes}min, cache={self.analysis_cache_minutes}min)" + ) return True - + def stop(self, timeout_sec: float = 5.0) -> None: """Stop background tasks""" with self._lock: @@ -56,103 +59,105 @@ class PolymarketWorker: logger.warning("PolymarketWorker thread did not stop within timeout") else: logger.info("PolymarketWorker stopped") - + def _run_loop(self) -> None: """main loop""" logger.info("PolymarketWorker loop started") - + # Execute once immediately on startup self._update_markets_and_analyze() - + while not self._stop_event.is_set(): try: # Wait for specified time interval wait_seconds = self.update_interval_minutes * 60 if self._stop_event.wait(wait_seconds): break # If a stop signal is received, exit the loop - + # Perform updates and analysis self._update_markets_and_analyze() - + except Exception as e: logger.error(f"PolymarketWorker loop error: {e}", exc_info=True) # After an error, wait 1 minute and try again self._stop_event.wait(60) - + logger.info("PolymarketWorker loop stopped") - + def _update_markets_and_analyze(self) -> None: """Update market data and analyze""" try: logger.info("Starting Polymarket data update and analysis...") start_time = time.time() - + # Gamma API /events has no category param — fetch ALL once, categorize locally. all_markets = self.polymarket_source.get_trending_markets(category="all", limit=500) logger.info(f"Fetched {len(all_markets)} markets from Gamma API (single request)") - + unique_markets = {} cat_counts: Dict[str, int] = {} for market in all_markets: - market_id = market.get('market_id') + market_id = market.get("market_id") if market_id: unique_markets[market_id] = market - cat = market.get('category', 'other') + cat = market.get("category", "other") cat_counts[cat] = cat_counts.get(cat, 0) + 1 - + logger.info(f"Total unique markets: {len(unique_markets)}, by category: {cat_counts}") - + # 2. Analyze the market in batches (analyze all markets at once, and use AI to screen opportunities) markets_list = list(unique_markets.values()) logger.info(f"Starting batch analysis for {len(markets_list)} markets...") - + # Optimization strategy: first use rules to filter and only call LLM on high-value opportunities # This can greatly reduce the number of LLM calls and save tokens. - + # 1. First use rules to filter out the most valuable opportunities (without calling LLM) rule_based_opportunities = [] for market in markets_list: - prob = market.get('current_probability', 50.0) - volume = market.get('volume_24h', 0) + prob = market.get("current_probability", 50.0) + volume = market.get("volume_24h", 0) divergence = abs(prob - 50.0) - + # Rule screening: high trading volume + obvious probability deviation if volume > 5000 and divergence > 8: rule_based_opportunities.append(market) - + # 2. Only call LLM for opportunities filtered out by rules (up to 30, saving tokens) if rule_based_opportunities: - logger.info(f"Rule-based filtering: {len(rule_based_opportunities)} opportunities, analyzing top 30 with LLM") + logger.info( + f"Rule-based filtering: {len(rule_based_opportunities)} opportunities, analyzing top 30 with LLM" + ) # Sort by transaction volume and probability deviation, take the top 30 rule_based_opportunities.sort( - key=lambda x: (x.get('volume_24h', 0) * abs(x.get('current_probability', 50) - 50)), - reverse=True + key=lambda x: x.get("volume_24h", 0) * abs(x.get("current_probability", 50) - 50), reverse=True ) top_opportunities = rule_based_opportunities[:30] - + analyzed_markets = self.batch_analyzer.batch_analyze_markets( top_opportunities, - max_opportunities=30 # Analyze only the 30 most valuable opportunities + max_opportunities=30, # Analyze only the 30 most valuable opportunities ) else: logger.info("No rule-based opportunities found, skipping LLM analysis") analyzed_markets = [] - + # 3. Save the analysis results to the database if analyzed_markets: self.batch_analyzer.save_batch_analysis(analyzed_markets) analyzed_count = len(analyzed_markets) else: analyzed_count = 0 - + elapsed = time.time() - start_time - logger.info(f"Polymarket update completed: {len(unique_markets)} markets updated, {analyzed_count} opportunities identified in {elapsed:.1f}s") + logger.info( + f"Polymarket update completed: {len(unique_markets)} markets updated, {analyzed_count} opportunities identified in {elapsed:.1f}s" + ) self._last_update_ts = time.time() - + except Exception as e: logger.error(f"Failed to update markets and analyze: {e}", exc_info=True) - - + def force_update(self) -> None: """Force immediate update (for manual triggering)""" logger.info("Force update triggered") @@ -172,11 +177,6 @@ def get_polymarket_worker() -> PolymarketWorker: update_interval = int(os.getenv("POLYMARKET_UPDATE_INTERVAL_MIN", "30")) cache_minutes = int(os.getenv("POLYMARKET_ANALYSIS_CACHE_MIN", "30")) _polymarket_worker = PolymarketWorker( - update_interval_minutes=update_interval, - analysis_cache_minutes=cache_minutes + update_interval_minutes=update_interval, analysis_cache_minutes=cache_minutes ) return _polymarket_worker - - -# Need to import os -import os diff --git a/backend_api_python/app/services/portfolio_monitor.py b/backend_api_python/app/services/portfolio_monitor.py index 59ca0b9..8117992 100644 --- a/backend_api_python/app/services/portfolio_monitor.py +++ b/backend_api_python/app/services/portfolio_monitor.py @@ -2,6 +2,7 @@ Portfolio Monitor Service. Runs scheduled AI analysis on manual positions and sends notifications. """ + from __future__ import annotations import hashlib @@ -12,12 +13,12 @@ import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any, Dict, List, Optional +from app.services.billing_service import get_billing_service +from app.services.fast_analysis import get_fast_analysis_service +from app.services.kline import KlineService +from app.services.signal_notifier import SignalNotifier from app.utils.db import get_db_connection from app.utils.logger import get_logger -from app.services.fast_analysis import get_fast_analysis_service -from app.services.signal_notifier import SignalNotifier -from app.services.kline import KlineService -from app.services.billing_service import get_billing_service logger = get_logger(__name__) @@ -28,37 +29,37 @@ _stop_event = threading.Event() # Multilingual message templates 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': '价格/盈亏预警' + "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", }, - '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: +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, '') + 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 '' + return "" -def _get_alert_title(language: str = 'en-US') -> str: +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') + 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: @@ -72,16 +73,16 @@ def _resolve_notification_delivery(user_id: int, notification_config: Optional[D If the current channels cannot be delivered (no email/Chat ID, etc.), add a browser to ensure on-site notification. """ cfg: Dict[str, Any] = dict(notification_config) if isinstance(notification_config, dict) else {} - raw_ch = cfg.get('channels') + raw_ch = cfg.get("channels") if isinstance(raw_ch, str): raw_ch = [raw_ch] elif not isinstance(raw_ch, list): raw_ch = [] channels = [str(c).strip().lower() for c in raw_ch if c is not None and str(c).strip()] if not channels: - channels = ['browser'] + channels = ["browser"] - targets: Dict[str, Any] = dict(cfg.get('targets') or {}) + targets: Dict[str, Any] = dict(cfg.get("targets") or {}) try: with get_db_connection() as db: @@ -150,18 +151,18 @@ def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = No try: kline_service = KlineService() effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID - + with get_db_connection() as db: cur = db.cursor() if position_ids: - placeholders = ','.join(['?' for _ in position_ids]) + placeholders = ",".join(["?" for _ in position_ids]) cur.execute( f""" SELECT id, market, symbol, name, side, quantity, entry_price, group_name FROM qd_manual_positions WHERE user_id = ? AND id IN ({placeholders}) """, - [effective_user_id] + list(position_ids) + [effective_user_id] + list(position_ids), ) else: cur.execute( @@ -170,50 +171,52 @@ def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = No FROM qd_manual_positions WHERE user_id = ? """, - (effective_user_id,) + (effective_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' - group_name = row.get('group_name') - + 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" + group_name = row.get("group_name") + # Get current price (use realtime price API) current_price = 0 try: price_data = kline_service.get_realtime_price(market, symbol) - current_price = float(price_data.get('price') or 0) + current_price = float(price_data.get("price") or 0) except Exception: pass - + # Calculate PnL - if side == 'long': + 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, - 'group_name': group_name - }) - + + 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, + "group_name": group_name, + } + ) + return positions except Exception as e: logger.error(f"_get_positions_for_monitor failed: {e}") @@ -225,57 +228,65 @@ MAX_PARALLEL_ANALYSIS = 5 def _analyze_single_position(pos: Dict[str, Any], language: str, user_id: int = None) -> Dict[str, Any]: """Analyze a single position (designed to run inside a thread pool).""" - market = pos.get('market') - symbol = pos.get('symbol') - name = pos.get('name') or symbol - group_name = pos.get('group_name') + market = pos.get("market") + symbol = pos.get("symbol") + name = pos.get("name") or symbol + group_name = pos.get("group_name") if not market or not symbol: - return {'market': market, 'symbol': symbol, 'name': name, 'error': 'missing market/symbol'} + return {"market": market, "symbol": symbol, "name": name, "error": "missing market/symbol"} try: logger.info(f"Running fast AI analysis for {market}:{symbol} (user={user_id})") service = get_fast_analysis_service() analysis_result = service.analyze( - market=market, symbol=symbol, language=language, timeframe='1D', + market=market, + symbol=symbol, + language=language, + timeframe="1D", user_id=user_id, ) - detailed = analysis_result.get('detailed_analysis', {}) - trading_plan = analysis_result.get('trading_plan', {}) - scores = analysis_result.get('scores', {}) - risks = analysis_result.get('risks', []) - risk_report = '\n'.join([f"• {r}" for r in risks]) if risks else '' + detailed = analysis_result.get("detailed_analysis", {}) + trading_plan = analysis_result.get("trading_plan", {}) + scores = analysis_result.get("scores", {}) + risks = analysis_result.get("risks", []) + risk_report = "\n".join([f"• {r}" for r in risks]) if risks else "" result = { - 'market': market, 'symbol': symbol, 'name': name, 'group_name': group_name, - 'entry_price': pos.get('entry_price'), - 'current_price': pos.get('current_price') or analysis_result.get('market_data', {}).get('current_price'), - 'pnl': pos.get('pnl'), 'pnl_percent': pos.get('pnl_percent'), - 'quantity': pos.get('quantity'), 'side': pos.get('side'), - 'final_decision': analysis_result.get('decision', 'HOLD'), - 'confidence': analysis_result.get('confidence', 50), - 'reasoning': analysis_result.get('summary', ''), - 'trader_decision': analysis_result.get('decision', 'HOLD'), - 'trader_reasoning': analysis_result.get('summary', ''), - 'overview_report': detailed.get('technical', ''), - 'fundamental_report': detailed.get('fundamental', ''), - 'sentiment_report': detailed.get('sentiment', ''), - 'risk_report': risk_report, - 'suggested_entry': trading_plan.get('entry_price'), - 'suggested_stop_loss': trading_plan.get('stop_loss'), - 'suggested_take_profit': trading_plan.get('take_profit'), - 'technical_score': scores.get('technical', 50), - 'fundamental_score': scores.get('fundamental', 50), - 'sentiment_score': scores.get('sentiment', 50), - 'key_reasons': analysis_result.get('reasons', []), - 'error': analysis_result.get('error') + "market": market, + "symbol": symbol, + "name": name, + "group_name": group_name, + "entry_price": pos.get("entry_price"), + "current_price": pos.get("current_price") or analysis_result.get("market_data", {}).get("current_price"), + "pnl": pos.get("pnl"), + "pnl_percent": pos.get("pnl_percent"), + "quantity": pos.get("quantity"), + "side": pos.get("side"), + "final_decision": analysis_result.get("decision", "HOLD"), + "confidence": analysis_result.get("confidence", 50), + "reasoning": analysis_result.get("summary", ""), + "trader_decision": analysis_result.get("decision", "HOLD"), + "trader_reasoning": analysis_result.get("summary", ""), + "overview_report": detailed.get("technical", ""), + "fundamental_report": detailed.get("fundamental", ""), + "sentiment_report": detailed.get("sentiment", ""), + "risk_report": risk_report, + "suggested_entry": trading_plan.get("entry_price"), + "suggested_stop_loss": trading_plan.get("stop_loss"), + "suggested_take_profit": trading_plan.get("take_profit"), + "technical_score": scores.get("technical", 50), + "fundamental_score": scores.get("fundamental", 50), + "sentiment_score": scores.get("sentiment", 50), + "key_reasons": analysis_result.get("reasons", []), + "error": analysis_result.get("error"), } logger.info(f"Fast analysis completed for {market}:{symbol}: {analysis_result.get('decision', 'N/A')}") return result except Exception as e: logger.error(f"Failed to analyze {market}:{symbol}: {e}") - return {'market': market, 'symbol': symbol, 'name': name, 'error': str(e)} + return {"market": market, "symbol": symbol, "name": name, "error": str(e)} def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any], user_id: int = None) -> Dict[str, Any]: @@ -285,13 +296,13 @@ def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any], us duplicate positions so we don't waste LLM calls or show redundant entries. """ try: - language = config.get('language', 'en-US') - custom_prompt = config.get('prompt', '') + language = config.get("language", "en-US") + custom_prompt = config.get("prompt", "") # ── Deduplicate by (market, symbol) ── - unique_map: Dict[str, int] = {} # "market|symbol" -> index in unique_positions + unique_map: Dict[str, int] = {} # "market|symbol" -> index in unique_positions unique_positions: List[Dict[str, Any]] = [] - pos_to_unique: List[int] = [] # positions[i] -> unique_positions index + pos_to_unique: List[int] = [] # positions[i] -> unique_positions index for pos in positions: key = f"{pos.get('market')}|{pos.get('symbol')}" if key not in unique_map: @@ -314,8 +325,10 @@ def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any], us except Exception as e: pos = unique_positions[idx] unique_analyses[idx] = { - 'market': pos.get('market'), 'symbol': pos.get('symbol'), - 'name': pos.get('name') or pos.get('symbol'), 'error': str(e) + "market": pos.get("market"), + "symbol": pos.get("symbol"), + "name": pos.get("name") or pos.get("symbol"), + "error": str(e), } # ── Map back: each position gets its own copy with position-specific P&L ── @@ -327,30 +340,29 @@ def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any], us continue seen_keys.add(key) base = dict(unique_analyses[pos_to_unique[i]]) - base['entry_price'] = pos.get('entry_price') - base['current_price'] = base.get('current_price') or pos.get('current_price') + base["entry_price"] = pos.get("entry_price") + base["current_price"] = base.get("current_price") or pos.get("current_price") combined_qty = sum( - float(p.get('quantity') or 0) + float(p.get("quantity") or 0) for j, p in enumerate(positions) if f"{p.get('market')}|{p.get('symbol')}" == key ) combined_cost = sum( - float(p.get('entry_price') or 0) * float(p.get('quantity') or 0) + float(p.get("entry_price") or 0) * float(p.get("quantity") or 0) for j, p in enumerate(positions) if f"{p.get('market')}|{p.get('symbol')}" == key ) - cur_price = float(base.get('current_price') or 0) combined_pnl = sum( - float(p.get('pnl') or 0) + float(p.get("pnl") or 0) for j, p in enumerate(positions) if f"{p.get('market')}|{p.get('symbol')}" == key ) avg_entry = round(combined_cost / combined_qty, 4) if combined_qty else 0 pnl_pct = round(combined_pnl / combined_cost * 100, 2) if combined_cost else 0 - base['quantity'] = combined_qty - base['entry_price'] = avg_entry - base['pnl'] = round(combined_pnl, 2) - base['pnl_percent'] = pnl_pct + base["quantity"] = combined_qty + base["entry_price"] = avg_entry + base["pnl"] = round(combined_pnl, 2) + base["pnl_percent"] = pnl_pct position_analyses.append(base) # Also provide deduplicated positions list for report building @@ -362,35 +374,32 @@ def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any], us continue seen_keys2.add(key) merged = dict(pos) - merged['quantity'] = position_analyses[len(deduped_positions)].get('quantity', pos.get('quantity')) - merged['entry_price'] = position_analyses[len(deduped_positions)].get('entry_price', pos.get('entry_price')) - merged['pnl'] = position_analyses[len(deduped_positions)].get('pnl', pos.get('pnl')) - merged['pnl_percent'] = position_analyses[len(deduped_positions)].get('pnl_percent', pos.get('pnl_percent')) + merged["quantity"] = position_analyses[len(deduped_positions)].get("quantity", pos.get("quantity")) + merged["entry_price"] = position_analyses[len(deduped_positions)].get("entry_price", pos.get("entry_price")) + merged["pnl"] = position_analyses[len(deduped_positions)].get("pnl", pos.get("pnl")) + merged["pnl_percent"] = position_analyses[len(deduped_positions)].get("pnl_percent", pos.get("pnl_percent")) deduped_positions.append(merged) analysis_report = _build_comprehensive_report(deduped_positions, position_analyses, language, custom_prompt) return { - 'success': True, - 'analysis': analysis_report, - 'position_analyses': position_analyses, - 'positions': deduped_positions, - 'position_count': len(deduped_positions), - 'analyzed_count': len([p for p in position_analyses if not p.get('error')]), - 'timestamp': _now_ts() + "success": True, + "analysis": analysis_report, + "position_analyses": position_analyses, + "positions": deduped_positions, + "position_count": len(deduped_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()} + 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 = '' + 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 @@ -398,62 +407,61 @@ def _build_comprehensive_report( def _build_html_report( - positions: List[Dict[str, Any]], - position_analyses: List[Dict[str, Any]], - language: str, - custom_prompt: str = '' + 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_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) - + 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') - + 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 AI 快速分析引擎生成' if is_zh else 'Generated by QuantDinger Fast AI Analysis Engine', - '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' + "title": "投资组合AI分析报告" if is_zh else "Portfolio AI Analysis Report", + "subtitle": "由 QuantDinger AI 快速分析引擎生成" + if is_zh + else "Generated by QuantDinger Fast AI Analysis Engine", + "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 = ''' + css = """ - ''' - + """ + # Build HTML - pnl_class = 'positive' if total_pnl >= 0 else 'negative' - pnl_sign = '+' if total_pnl >= 0 else '' - - html = f''' + pnl_class = "positive" if total_pnl >= 0 else "negative" + pnl_sign = "+" if total_pnl >= 0 else "" + + html = f""" {css}
-

{texts['title']}

-
{texts['subtitle']}
+

{texts["title"]}

+
{texts["subtitle"]}
-

{texts['overview']}

+

{texts["overview"]}

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

{texts['ai_recommendations']}

+

{texts["ai_recommendations"]}

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

{texts['position_analysis']}

- ''' - +

{texts["position_analysis"]}

+ """ + for pa in position_analyses: - symbol = pa.get('symbol', '') - name = pa.get('name', symbol) - market = pa.get('market', '') - group_name = pa.get('group_name', '') - - if pa.get('error'): - html += f''' + symbol = pa.get("symbol", "") + name = pa.get("name", symbol) + market = pa.get("market", "") + group_name = pa.get("group_name", "") + + if pa.get("error"): + html += f"""
@@ -610,33 +618,33 @@ def _build_html_report(
-
{texts['analysis_failed']}: {pa.get('error')}
+
{texts["analysis_failed"]}: {pa.get("error")}
- ''' + """ continue - - decision = pa.get('final_decision', 'HOLD') + + 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''' + 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"""
@@ -648,41 +656,41 @@ def _build_html_report(
{decision_text}
-
{texts['confidence']}: {confidence}%
+
{texts["confidence"]}: {confidence}%
-
{texts['current_price']}
+
{texts["current_price"]}
${current_price:.4f}
-
{texts['entry_price']}
+
{texts["entry_price"]}
${entry_price:.4f}
-
{texts['pnl']}
+
{texts["pnl"]}
{pnl_sign}${pnl:.2f} ({pnl_sign}{pnl_pct:.1f}%)
-
{texts['quantity']} / {texts['side']}
+
{texts["quantity"]} / {texts["side"]}
{quantity} / {side_text}
- ''' - + """ + # Reasoning summary if reasoning: - html += f''' + html += f"""
-
{texts['reasoning']}
-
{reasoning[:500]}{'...' if len(reasoning) > 500 else ''}
+
{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}_{group_name}".encode()).hexdigest()[:8] - + # Collapsible: Trader Analysis if trader_reasoning: trader_id = f"trader_{section_id_base}" @@ -690,13 +698,13 @@ def _build_html_report(
-
{trader_reasoning.replace(chr(10), '
')}
+
{trader_reasoning.replace(chr(10), "
")}
''' - + # Collapsible: Market Overview if overview_report: overview_id = f"overview_{section_id_base}" @@ -704,13 +712,13 @@ def _build_html_report(
-
{overview_report.replace(chr(10), '
')}
+
{overview_report.replace(chr(10), "
")}
''' - + # Collapsible: Risk Assessment if risk_report: risk_id = f"risk_{section_id_base}" @@ -718,45 +726,42 @@ def _build_html_report(
-
{risk_report.replace(chr(10), '
')}
+
{risk_report.replace(chr(10), "
")}
''' - - html += ''' + + html += """
- ''' - + """ + # User focus section if custom_prompt: - html += f''' + html += f"""
-

{texts['user_focus']}

+

{texts["user_focus"]}

{custom_prompt}
- ''' - + """ + # Footer - html += f''' + html += f"""
- ''' - + """ + return html def _build_telegram_report( - positions: List[Dict[str, Any]], - position_analyses: List[Dict[str, Any]], - language: str, - custom_prompt: str = '' + 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). @@ -765,22 +770,22 @@ def _build_telegram_report( """ def _has_holding(pa: Dict[str, Any]) -> bool: - return float(pa.get('quantity') or 0) > 0 and float(pa.get('entry_price') or 0) > 0 + return float(pa.get("quantity") or 0) > 0 and float(pa.get("entry_price") or 0) > 0 - held = [p for p in position_analyses if _has_holding(p) and not p.get('error')] - watched = [p for p in position_analyses if not _has_holding(p) and not p.get('error')] - errored = [p for p in position_analyses if p.get('error')] + held = [p for p in position_analyses if _has_holding(p) and not p.get("error")] + watched = [p for p in position_analyses if not _has_holding(p) and not p.get("error")] + errored = [p for p in position_analyses if p.get("error")] - total_cost = sum(float(p.get('entry_price', 0)) * float(p.get('quantity', 0)) for p in held) - total_pnl = sum(float(p.get('pnl', 0)) for p in held) + total_cost = sum(float(p.get("entry_price", 0)) * float(p.get("quantity", 0)) for p in held) + total_pnl = sum(float(p.get("pnl", 0)) for p in held) total_pnl_pct = round(total_pnl / total_cost * 100, 2) if total_cost > 0 else 0 - pnl_sign = '+' if total_pnl >= 0 else '' + pnl_sign = "+" if total_pnl >= 0 else "" - 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']) + 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') + is_zh = language.startswith("zh") # ── Header / Overview ── if is_zh: @@ -793,11 +798,13 @@ def _build_telegram_report( if watched: overview.append(f"• Observations: {len(watched)}") lines.extend(overview) - lines.extend([ - "", - "🤖 Summary of AI suggestions", - f"🟢 Buy: {buy_count} | 🔴 Sell: {sell_count} | 🟡 Hold: {hold_count}", - ]) + lines.extend( + [ + "", + "🤖 Summary of AI suggestions", + f"🟢 Buy: {buy_count} | 🔴 Sell: {sell_count} | 🟡 Hold: {hold_count}", + ] + ) else: lines = ["📊 AI Asset Analysis Report", ""] overview = ["📈 Overview"] @@ -808,24 +815,26 @@ def _build_telegram_report( if watched: overview.append(f"• Watchlist: {len(watched)}") lines.extend(overview) - lines.extend([ - "", - "🤖 AI Recommendations", - f"🟢 Buy: {buy_count} | 🔴 Sell: {sell_count} | 🟡 Hold: {hold_count}", - ]) + lines.extend( + [ + "", + "🤖 AI Recommendations", + f"🟢 Buy: {buy_count} | 🔴 Sell: {sell_count} | 🟡 Hold: {hold_count}", + ] + ) # ── Helper: render one analysis entry ── def _render_pa(pa: Dict[str, Any], show_pnl: bool) -> None: - decision = pa.get('final_decision', 'HOLD') - emoji = {'BUY': '🟢', 'SELL': '🔴', 'HOLD': '🟡'}.get(decision, '⚪') + decision = pa.get("final_decision", "HOLD") + emoji = {"BUY": "🟢", "SELL": "🔴", "HOLD": "🟡"}.get(decision, "⚪") d_text = decision if is_zh: - d_text = {'BUY': 'Buy', 'SELL': 'Sell', 'HOLD': 'Hold'}.get(decision, 'Hold') + d_text = {"BUY": "Buy", "SELL": "Sell", "HOLD": "Hold"}.get(decision, "Hold") lines.append(f"\n{emoji} {pa.get('name', pa.get('symbol'))} ({pa.get('market')}/{pa.get('symbol')})") if show_pnl: - pnl = pa.get('pnl', 0) - pnl_pct = pa.get('pnl_percent', 0) - ps = '+' if pnl >= 0 else '' + pnl = pa.get("pnl", 0) + pnl_pct = pa.get("pnl_percent", 0) + ps = "+" if pnl >= 0 else "" lines.append( f" 💰 ${pa.get('current_price', 0):,.2f} | " f"{'profit and loss' if is_zh else 'P&L'}: {ps}${pnl:,.2f} ({ps}{pnl_pct:.1f}%)" @@ -836,7 +845,7 @@ def _build_telegram_report( f" 🎯 {'建议' if is_zh else 'Rec'}: {d_text} " f"({'confidence' if is_zh else 'Conf'}: {pa.get('confidence', 50)}%)" ) - reasoning = pa.get('reasoning', '') + reasoning = pa.get("reasoning", "") if reasoning: lines.append(f" 📝 {reasoning[:150]}{'...' if len(reasoning) > 150 else ''}") @@ -854,20 +863,22 @@ def _build_telegram_report( # ── Errors ── for pa in errored: - label = pa.get('name') or pa.get('symbol') or '?' + label = pa.get("name") or pa.get("symbol") or "?" lines.append(f"\n⚠️ {label}: {'Analysis failed' if is_zh else 'Analysis failed'}") if custom_prompt: lines.extend(["", f"👤 {'关注点' if is_zh else 'Focus'}: {custom_prompt}"]) - lines.extend([ - "", - "─────────────────────", - f"⏰ {time.strftime('%Y-%m-%d %H:%M')}", - f"{'Generated by QuantDinger Multi-Agent System' if is_zh else 'Generated by QuantDinger Multi-Agent System'}", - ]) + lines.extend( + [ + "", + "─────────────────────", + f"⏰ {time.strftime('%Y-%m-%d %H:%M')}", + f"{'Generated by QuantDinger Multi-Agent System' if is_zh else 'Generated by QuantDinger Multi-Agent System'}", + ] + ) - return '\n'.join(lines) + return "\n".join(lines) def _build_batch_telegram_report( @@ -875,37 +886,37 @@ def _build_batch_telegram_report( language: str, ) -> str: """Build a single Telegram report that combines multiple monitor results.""" - is_zh = language.startswith('zh') + is_zh = language.startswith("zh") def _has_holding(pa: Dict[str, Any]) -> bool: - return float(pa.get('quantity') or 0) > 0 and float(pa.get('entry_price') or 0) > 0 + return float(pa.get("quantity") or 0) > 0 and float(pa.get("entry_price") or 0) > 0 all_analyses: List[Dict[str, Any]] = [] monitor_sections: List[str] = [] for res in monitor_results: - meta = res.get('_meta', {}) - m_name = meta.get('monitor_name', '?') - m_analyses = meta.get('position_analyses', []) + meta = res.get("_meta", {}) + m_name = meta.get("monitor_name", "?") + m_analyses = meta.get("position_analyses", []) all_analyses.extend(m_analyses) section_lines: List[str] = [f"\n📋 {m_name}"] for pa in m_analyses: - if pa.get('error'): - label = pa.get('name') or pa.get('symbol') or '?' + if pa.get("error"): + label = pa.get("name") or pa.get("symbol") or "?" section_lines.append(f" ⚠️ {label}: {'Analysis failed' if is_zh else 'Failed'}") continue - decision = pa.get('final_decision', 'HOLD') - emoji = {'BUY': '🟢', 'SELL': '🔴', 'HOLD': '🟡'}.get(decision, '⚪') - d_text = ({'BUY': 'Buy', 'SELL': 'Sell', 'HOLD': 'Hold'}.get(decision, 'Hold')) if is_zh else decision - cur_price = pa.get('current_price', 0) + decision = pa.get("final_decision", "HOLD") + emoji = {"BUY": "🟢", "SELL": "🔴", "HOLD": "🟡"}.get(decision, "⚪") + d_text = ({"BUY": "Buy", "SELL": "Sell", "HOLD": "Hold"}.get(decision, "Hold")) if is_zh else decision + cur_price = pa.get("current_price", 0) section_lines.append( f"{emoji} {pa.get('name', pa.get('symbol'))} ({pa.get('market')}/{pa.get('symbol')})" ) if _has_holding(pa): - pnl = pa.get('pnl', 0) - pnl_s = '+' if pnl >= 0 else '' - pnl_pct = pa.get('pnl_percent', 0) + pnl = pa.get("pnl", 0) + pnl_s = "+" if pnl >= 0 else "" + pnl_pct = pa.get("pnl_percent", 0) section_lines.append( f" 💰 ${cur_price:,.2f} | {'盈亏' if is_zh else 'P&L'}: {pnl_s}${pnl:,.2f} ({pnl_s}{pnl_pct:.1f}%)" ) @@ -915,20 +926,20 @@ def _build_batch_telegram_report( f" 🎯 {'建议' if is_zh else 'Rec'}: {d_text} " f"({'confidence' if is_zh else 'Conf'}: {pa.get('confidence', 50)}%)" ) - reasoning = pa.get('reasoning', '') + reasoning = pa.get("reasoning", "") if reasoning: section_lines.append(f" 📝 {reasoning[:120]}{'...' if len(reasoning) > 120 else ''}") - monitor_sections.append('\n'.join(section_lines)) + monitor_sections.append("\n".join(section_lines)) - held = [a for a in all_analyses if _has_holding(a) and not a.get('error')] - watched = [a for a in all_analyses if not _has_holding(a) and not a.get('error')] - total_cost = sum(float(a.get('entry_price', 0)) * float(a.get('quantity', 0)) for a in held) - total_pnl = sum(float(a.get('pnl', 0)) for a in held) + held = [a for a in all_analyses if _has_holding(a) and not a.get("error")] + watched = [a for a in all_analyses if not _has_holding(a) and not a.get("error")] + total_cost = sum(float(a.get("entry_price", 0)) * float(a.get("quantity", 0)) for a in held) + total_pnl = sum(float(a.get("pnl", 0)) for a in held) total_pnl_pct = round(total_pnl / total_cost * 100, 2) if total_cost else 0 - pnl_sign = '+' if total_pnl >= 0 else '' - buy_c = len([a for a in all_analyses if a.get('final_decision') == 'BUY']) - sell_c = len([a for a in all_analyses if a.get('final_decision') == 'SELL']) - hold_c = len([a for a in all_analyses if a.get('final_decision') == 'HOLD']) + pnl_sign = "+" if total_pnl >= 0 else "" + buy_c = len([a for a in all_analyses if a.get("final_decision") == "BUY"]) + sell_c = len([a for a in all_analyses if a.get("final_decision") == "SELL"]) + hold_c = len([a for a in all_analyses if a.get("final_decision") == "HOLD"]) if is_zh: header = [ @@ -939,14 +950,18 @@ def _build_batch_telegram_report( f"• Number of targets: {len(all_analyses)}", ] if held: - header.append(f"• Positions: {len(held)} | Total cost: ${total_cost:,.2f} | Profit and loss: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)") + header.append( + f"• Positions: {len(held)} | Total cost: ${total_cost:,.2f} | Profit and loss: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)" + ) if watched: header.append(f"• Observations: {len(watched)}") - header.extend([ - "", - "🤖 Summary of AI suggestions", - f"🟢 Buy: {buy_c} | 🔴 Sell: {sell_c} | 🟡 Hold: {hold_c}", - ]) + header.extend( + [ + "", + "🤖 Summary of AI suggestions", + f"🟢 Buy: {buy_c} | 🔴 Sell: {sell_c} | 🟡 Hold: {hold_c}", + ] + ) else: header = [ "📊 Scheduled Portfolio Report", @@ -956,14 +971,18 @@ def _build_batch_telegram_report( f"• Symbols: {len(all_analyses)}", ] if held: - header.append(f"• Holdings: {len(held)} | Cost: ${total_cost:,.2f} | P&L: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)") + header.append( + f"• Holdings: {len(held)} | Cost: ${total_cost:,.2f} | P&L: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)" + ) if watched: header.append(f"• Watchlist: {len(watched)}") - header.extend([ - "", - "🤖 AI Recommendations", - f"🟢 Buy: {buy_c} | 🔴 Sell: {sell_c} | 🟡 Hold: {hold_c}", - ]) + header.extend( + [ + "", + "🤖 AI Recommendations", + f"🟢 Buy: {buy_c} | 🔴 Sell: {sell_c} | 🟡 Hold: {hold_c}", + ] + ) footer = [ "", @@ -972,7 +991,7 @@ def _build_batch_telegram_report( f"{'Generated by QuantDinger Multi-Agent System' if is_zh else 'Generated by QuantDinger Multi-Agent System'}", ] - return '\n'.join(header + monitor_sections + footer) + return "\n".join(header + monitor_sections + footer) def _build_batch_html_report( @@ -982,11 +1001,11 @@ def _build_batch_html_report( """Build a combined HTML report for browser / email channel.""" parts: List[str] = [] for res in monitor_results: - report = res.get('analysis', '') + report = res.get("analysis", "") if report: parts.append(report) if not parts: - return '' + return "" if len(parts) == 1: return parts[0] divider = '
' @@ -1001,31 +1020,31 @@ def _send_batch_notification( if not monitor_results: return - successful = [r for r in monitor_results if r.get('success')] + successful = [r for r in monitor_results if r.get("success")] if not successful: for r in monitor_results: - meta = r.get('_meta', {}) + meta = r.get("_meta", {}) _send_monitor_notification( - monitor_name=meta.get('monitor_name', '?'), + monitor_name=meta.get("monitor_name", "?"), result=r, - notification_config=meta.get('notification_config', {}), - positions=meta.get('positions', []), - position_analyses=meta.get('position_analyses', []), - language=meta.get('language', 'en-US'), - custom_prompt=meta.get('custom_prompt', ''), + notification_config=meta.get("notification_config", {}), + positions=meta.get("positions", []), + position_analyses=meta.get("position_analyses", []), + language=meta.get("language", "en-US"), + custom_prompt=meta.get("custom_prompt", ""), user_id=user_id, ) return - first_meta = successful[0].get('_meta', {}) - language = first_meta.get('language', 'en-US') + first_meta = successful[0].get("_meta", {}) + language = first_meta.get("language", "en-US") # Merge channels from all monitors (union) all_channels: set = set() for r in successful: - m = r.get('_meta', {}) - nc = m.get('notification_config', {}) - chs = nc.get('channels') + m = r.get("_meta", {}) + nc = m.get("notification_config", {}) + chs = nc.get("channels") if isinstance(chs, str): chs = [chs] elif not isinstance(chs, list): @@ -1034,18 +1053,18 @@ def _send_batch_notification( if c: all_channels.add(str(c).strip().lower()) if not all_channels: - all_channels = {'browser'} + all_channels = {"browser"} - merged_nc = {'channels': list(all_channels), 'targets': {}} + merged_nc = {"channels": list(all_channels), "targets": {}} resolved_nc = _resolve_notification_delivery(user_id, merged_nc) - channels = resolved_nc.get('channels') or ['browser'] - targets = resolved_nc.get('targets', {}) + channels = resolved_nc.get("channels") or ["browser"] + targets = resolved_nc.get("targets", {}) - is_zh = language.startswith('zh') - names = ', '.join(r.get('_meta', {}).get('monitor_name', '?') for r in successful) + is_zh = language.startswith("zh") + names = ", ".join(r.get("_meta", {}).get("monitor_name", "?") for r in successful) title = f"📊 Scheduled Asset Monitoring: {names}" if is_zh else f"📊 Scheduled Report: {names}" if len(title) > 255: - title = title[:252] + '...' + title = title[:252] + "..." html_report = _build_batch_html_report(successful, language) telegram_report = _build_batch_telegram_report(successful, language) @@ -1055,7 +1074,7 @@ def _send_batch_notification( for channel in channels: try: ch = str(channel).strip().lower() - if ch == 'browser': + if ch == "browser": with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -1064,34 +1083,48 @@ def _send_batch_notification( (user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW()) """, - (user_id, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report, - json.dumps({'batch': True, 'count': len(successful)}, ensure_ascii=False, default=str)), + ( + user_id, + "PORTFOLIO", + "ai_monitor", + "browser", + title, + html_report, + json.dumps({"batch": True, "count": len(successful)}, ensure_ascii=False, default=str), + ), ) db.commit() cur.close() - elif ch == 'telegram': - chat_id = targets.get('telegram', '') - token_override = targets.get('telegram_bot_token', '') + elif ch == "telegram": + chat_id = targets.get("telegram", "") + token_override = targets.get("telegram_bot_token", "") if chat_id: notifier._notify_telegram( - chat_id=chat_id, text=telegram_report, - token_override=token_override, parse_mode="HTML", + chat_id=chat_id, + text=telegram_report, + token_override=token_override, + parse_mode="HTML", ) - elif ch == 'email': - to_email = targets.get('email', '') + elif ch == "email": + to_email = targets.get("email", "") if to_email: notifier._notify_email( - to_email=to_email, subject=title, - body_text=html_report, body_html=html_report, + to_email=to_email, + subject=title, + body_text=html_report, + body_html=html_report, ) - elif ch == 'webhook': - url = targets.get('webhook', '') + elif ch == "webhook": + url = targets.get("webhook", "") if url: - notifier._notify_webhook(url=url, payload={ - 'type': 'portfolio_monitor_batch', - 'monitors': [r.get('_meta', {}).get('monitor_name') for r in successful], - 'html_report': html_report, - }) + notifier._notify_webhook( + url=url, + payload={ + "type": "portfolio_monitor_batch", + "monitors": [r.get("_meta", {}).get("monitor_name") for r in successful], + "html_report": html_report, + }, + ) except Exception as e: logger.warning(f"Batch notification channel {channel} failed: {e}") except Exception as e: @@ -1104,9 +1137,9 @@ def _send_monitor_notification( 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 = '', - user_id: int = None + language: str = "en-US", + custom_prompt: str = "", + user_id: int = None, ) -> None: """Send notification with analysis result using appropriate format for each channel.""" try: @@ -1114,23 +1147,35 @@ def _send_monitor_notification( effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID notification_config = _resolve_notification_delivery(effective_user_id, notification_config) - channels = notification_config.get('channels') or ['browser'] - targets = notification_config.get('targets', {}) + channels = notification_config.get("channels") or ["browser"] + targets = notification_config.get("targets", {}) - title = f"📊 Asset Monitor: {monitor_name}" if language.startswith('zh') else f"📊 Portfolio Monitor: {monitor_name}" + title = ( + f"📊 Asset Monitor: {monitor_name}" + if language.startswith("zh") + else f"📊 Portfolio Monitor: {monitor_name}" + ) if len(title) > 255: - title = title[:252] + '...' - - if not result.get('success'): - error_title = f"⚠️ Asset monitoring failed: {monitor_name}" if language.startswith('zh') else f"⚠️ Monitor Failed: {monitor_name}" + title = title[:252] + "..." + + if not result.get("success"): + error_title = ( + f"⚠️ Asset monitoring failed: {monitor_name}" + if language.startswith("zh") + else f"⚠️ Monitor Failed: {monitor_name}" + ) if len(error_title) > 255: - error_title = error_title[:252] + '...' - error_msg = f"分析失败: {result.get('error', 'Unknown error')}" if language.startswith('zh') else f"Analysis failed: {result.get('error', 'Unknown error')}" - + error_title = error_title[:252] + "..." + 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': + if ch == "browser": with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -1139,44 +1184,57 @@ def _send_monitor_notification( (user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW()) """, - (effective_user_id, 'PORTFOLIO', 'ai_monitor', 'browser', error_title, error_msg, - json.dumps(result, ensure_ascii=False, default=str)) + ( + effective_user_id, + "PORTFOLIO", + "ai_monitor", + "browser", + error_title, + error_msg, + json.dumps(result, ensure_ascii=False, default=str), + ), ) db.commit() cur.close() - elif ch == 'telegram': - chat_id = targets.get('telegram', '') - token_override = targets.get('telegram_bot_token', '') + elif ch == "telegram": + chat_id = targets.get("telegram", "") + token_override = targets.get("telegram_bot_token", "") if chat_id: - notifier._notify_telegram(chat_id=chat_id, text=f"{error_title}\n\n{error_msg}", token_override=token_override, parse_mode="HTML") - elif ch == 'email': - to_email = targets.get('email', '') + notifier._notify_telegram( + chat_id=chat_id, + text=f"{error_title}\n\n{error_msg}", + token_override=token_override, + 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 - + html_report = result.get("analysis", "") # This is already HTML from _build_html_report + # Generate Telegram-specific report if we have the data - telegram_report = '' + 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) + + telegram_report = re.sub(r"<[^>]+>", "", html_report) if len(telegram_report) > 4000: - telegram_report = 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': + + if ch == "browser": # Browser notification uses HTML report with get_db_connection() as db: cur = db.cursor() @@ -1186,51 +1244,55 @@ def _send_monitor_notification( (user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW()) """, - (effective_user_id, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report, - json.dumps(result, ensure_ascii=False, default=str)) + ( + effective_user_id, + "PORTFOLIO", + "ai_monitor", + "browser", + title, + html_report, + json.dumps(result, ensure_ascii=False, default=str), + ), ) db.commit() cur.close() - - elif ch == 'telegram': - chat_id = targets.get('telegram', '') - token_override = targets.get('telegram_bot_token', '') + + elif ch == "telegram": + chat_id = targets.get("telegram", "") + token_override = targets.get("telegram_bot_token", "") if chat_id: # Use Telegram-optimized format notifier._notify_telegram( - chat_id=chat_id, - text=telegram_report, - token_override=token_override, - parse_mode="HTML" + chat_id=chat_id, text=telegram_report, token_override=token_override, parse_mode="HTML" ) - - elif ch == 'email': - to_email = targets.get('email', '') + + 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 + body_html=html_report, # Send as HTML email ) - - elif ch == 'webhook': - url = targets.get('webhook', '') + + 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 - } + "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}") @@ -1260,35 +1322,31 @@ def run_single_monitor( FROM qd_position_monitors WHERE id = ? AND user_id = ? """, - (monitor_id, effective_user_id) + (monitor_id, effective_user_id), ) row = cur.fetchone() cur.close() if not row: - return {'success': False, 'error': 'Monitor not found'} + return {"success": False, "error": "Monitor not found"} - monitor_user_id = int(row.get('user_id') or effective_user_id) - 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'), {}) + monitor_user_id = int(row.get("user_id") or effective_user_id) + 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"), {}) if override_language: - config['language'] = override_language + config["language"] = override_language - interval_minutes = int( - config.get('run_interval_minutes') - or config.get('interval_minutes') - or 60 - ) + interval_minutes = int(config.get("run_interval_minutes") or config.get("interval_minutes") or 60) if position_ids: positions = _get_positions_for_monitor(position_ids, user_id=monitor_user_id) - elif config.get('symbol'): - target_sym = config['symbol'].strip().upper() - target_mkt = (config.get('market') or '').strip() + elif config.get("symbol"): + target_sym = config["symbol"].strip().upper() + target_mkt = (config.get("market") or "").strip() # Rule 4: symbol deleted from watchlist → skip still_in_watchlist = False @@ -1309,39 +1367,42 @@ def run_single_monitor( if not still_in_watchlist: logger.info(f"Monitor #{monitor_id} skipped: {target_mkt}:{target_sym} removed from watchlist") - return {'success': False, 'error': 'Symbol removed from watchlist'} + return {"success": False, "error": "Symbol removed from watchlist"} # Rules 1&2: match real position if exists, otherwise virtual observation matched = _get_positions_for_monitor(None, user_id=monitor_user_id) positions = [ - p for p in matched - if (p.get('symbol') or '').strip().upper() == target_sym - and (not target_mkt or (p.get('market') or '').strip() == target_mkt) + p + for p in matched + if (p.get("symbol") or "").strip().upper() == target_sym + and (not target_mkt or (p.get("market") or "").strip() == target_mkt) ] if not positions: - positions = [{ - 'market': target_mkt, - 'symbol': config['symbol'].strip(), - 'name': config.get('name', config['symbol']).strip(), - 'side': 'long', - 'quantity': 0, - 'entry_price': 0, - 'current_price': 0, - 'pnl': 0, - 'pnl_percent': 0, - }] + positions = [ + { + "market": target_mkt, + "symbol": config["symbol"].strip(), + "name": config.get("name", config["symbol"]).strip(), + "side": "long", + "quantity": 0, + "entry_price": 0, + "current_price": 0, + "pnl": 0, + "pnl_percent": 0, + } + ] else: # Rule 5: no position_ids, no config.symbol → nothing to analyze positions = [] if not positions: logger.info(f"Monitor #{monitor_id} skipped: no matching positions found") - return {'success': False, 'error': 'No matching positions found'} + return {"success": False, "error": "No matching positions found"} # ── Billing ── billing = get_billing_service() symbol_count = len(positions) - per_symbol_cost = billing.get_feature_cost('ai_analysis') + per_symbol_cost = billing.get_feature_cost("ai_analysis") total_cost = per_symbol_cost * symbol_count if total_cost > 0 and billing.is_billing_enabled(): @@ -1351,25 +1412,22 @@ def run_single_monitor( f"Monitor #{monitor_id} skipped: insufficient credits " f"({user_credits} < {total_cost} for {symbol_count} symbols)" ) - return { - 'success': False, - 'error': f'Insufficient credits: need {total_cost}, have {user_credits}' - } + return {"success": False, "error": f"Insufficient credits: need {total_cost}, have {user_credits}"} for i in range(symbol_count): pos = positions[i] ok, msg = billing.check_and_consume( user_id=monitor_user_id, - feature='ai_analysis', - reference_id=f"monitor_{monitor_id}_{pos.get('symbol', '')}" + feature="ai_analysis", + reference_id=f"monitor_{monitor_id}_{pos.get('symbol', '')}", ) if not ok: - logger.warning(f"Monitor #{monitor_id} billing failed at symbol #{i+1}: {msg}") + logger.warning(f"Monitor #{monitor_id} billing failed at symbol #{i + 1}: {msg}") break - if monitor_type == 'ai': + if monitor_type == "ai": result = _run_ai_analysis(positions, config, user_id=monitor_user_id) else: - result = {'success': False, 'error': f'Unsupported monitor type: {monitor_type}'} + result = {"success": False, "error": f"Unsupported monitor type: {monitor_type}"} with get_db_connection() as db: cur = db.cursor() @@ -1383,26 +1441,26 @@ def run_single_monitor( updated_at = NOW() WHERE id = ? """, - (interval_minutes, json.dumps(result, ensure_ascii=False, default=str), monitor_id) + (interval_minutes, json.dumps(result, ensure_ascii=False, default=str), monitor_id), ) db.commit() cur.close() - language = config.get('language', 'en-US') - custom_prompt = config.get('prompt', '') - position_analyses = result.get('position_analyses', []) - deduped_positions = result.get('positions', positions) + language = config.get("language", "en-US") + custom_prompt = config.get("prompt", "") + position_analyses = result.get("position_analyses", []) + deduped_positions = result.get("positions", positions) # Attach metadata used by batch notification / history - result['_meta'] = { - 'monitor_id': monitor_id, - 'monitor_name': name, - 'user_id': monitor_user_id, - 'language': language, - 'custom_prompt': custom_prompt, - 'notification_config': notification_config, - 'positions': deduped_positions, - 'position_analyses': position_analyses, + result["_meta"] = { + "monitor_id": monitor_id, + "monitor_name": name, + "user_id": monitor_user_id, + "language": language, + "custom_prompt": custom_prompt, + "notification_config": notification_config, + "positions": deduped_positions, + "position_analyses": position_analyses, } if not skip_notification: @@ -1421,17 +1479,18 @@ def run_single_monitor( except Exception as e: logger.error(f"run_single_monitor failed: {e}") logger.error(traceback.format_exc()) - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} def _check_position_alerts(): """Check all active alerts and trigger notifications if conditions are met.""" from datetime import datetime, timezone + try: kline_service = KlineService() notifier = SignalNotifier() now = datetime.now(timezone.utc) - + with get_db_connection() as db: cur = db.cursor() # Get active alerts for all users that haven't been triggered (or can repeat) @@ -1447,20 +1506,20 @@ def _check_position_alerts(): ) alerts = cur.fetchall() or [] cur.close() - + for alert in alerts: try: - alert_id = alert.get('id') - alert_user_id = int(alert.get('user_id') or 1) - 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') # datetime or None - repeat_interval = int(alert.get('repeat_interval') or 0) - notification_config = _safe_json_loads(alert.get('notification_config'), {}) - + alert_id = alert.get("id") + alert_user_id = int(alert.get("user_id") or 1) + 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") # datetime or None + 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 and last_triggered_at: @@ -1470,71 +1529,75 @@ def _check_position_alerts(): elapsed_seconds = (now - last_triggered_at).total_seconds() if elapsed_seconds >= repeat_interval: can_trigger = True - + if not can_trigger: continue - + # Get current price (use realtime price API) current_price = 0 try: price_data = kline_service.get_realtime_price(market, symbol) - current_price = float(price_data.get('price') or 0) + current_price = float(price_data.get("price") 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': + 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 + "price_above", + alert_language, + symbol=symbol, + current_price=current_price, + threshold=threshold, ) - - elif alert_type == 'price_below': + + 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 + "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' - + + 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': + 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: + + 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 + "pnl_above", alert_language, symbol=symbol, pnl_percent=pnl_percent, threshold=threshold ) - elif alert_type == 'pnl_below' and pnl_percent <= 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 + "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() @@ -1544,21 +1607,21 @@ def _check_position_alerts(): SET is_triggered = 1, last_triggered_at = NOW(), trigger_count = trigger_count + 1, updated_at = NOW() WHERE id = ? """, - (alert_id,) + (alert_id,), ) db.commit() cur.close() - + # Send notification (merge personal center notification configuration, consistent with asset monitoring tasks) resolved = _resolve_notification_delivery(alert_user_id, notification_config) - channels = resolved.get('channels') or ['browser'] - targets = resolved.get('targets', {}) + channels = resolved.get("channels") or ["browser"] + targets = resolved.get("targets", {}) alert_title = _get_alert_title(alert_language) - + for channel in channels: try: ch = str(channel).strip().lower() - if ch == 'browser': + if ch == "browser": with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -1567,40 +1630,58 @@ def _check_position_alerts(): (user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW()) """, - (alert_user_id, symbol, 'price_alert', 'browser', alert_title, alert_message, - json.dumps({'alert_id': alert_id, 'alert_type': alert_type}, ensure_ascii=False)) + ( + alert_user_id, + symbol, + "price_alert", + "browser", + alert_title, + alert_message, + json.dumps( + {"alert_id": alert_id, "alert_type": alert_type}, ensure_ascii=False + ), + ), ) db.commit() cur.close() - elif ch == 'telegram': - chat_id = targets.get('telegram', '') - token_override = targets.get('telegram_bot_token', '') + elif ch == "telegram": + chat_id = targets.get("telegram", "") + token_override = targets.get("telegram_bot_token", "") if chat_id: - notifier._notify_telegram(chat_id=chat_id, text=alert_message, token_override=token_override, parse_mode="HTML") - elif ch == 'email': - to_email = targets.get('email', '') + notifier._notify_telegram( + chat_id=chat_id, + text=alert_message, + token_override=token_override, + 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) + 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, user_id: int = None): +def notify_strategy_signal_for_positions( + market: str, symbol: str, signal_type: str, signal_detail: str, user_id: int = None +): """ - Called when a strategy signal is triggered. + 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() + symbol = (symbol or "").strip().upper() if not symbol: return - + with get_db_connection() as db: cur = db.cursor() # Query positions for all users or specific user @@ -1611,7 +1692,7 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: FROM qd_manual_positions WHERE user_id = ? AND symbol = ? """, - (user_id, symbol) + (user_id, symbol), ) else: cur.execute( @@ -1620,25 +1701,23 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: FROM qd_manual_positions WHERE symbol = ? """, - (symbol,) + (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_user_id = int(pos.get('user_id') or 1) - 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) - + pos_user_id = int(pos.get("user_id") or 1) + 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"🔗Strategy signal linkage: {pos_name}" message = f"""The strategy emits {signal_type} signal! @@ -1649,7 +1728,7 @@ Signal details: {signal_detail} Please check whether your position needs adjustment. """ - + # Save browser notification with get_db_connection() as db: cur = db.cursor() @@ -1659,14 +1738,21 @@ Please check whether your position needs adjustment. """ (user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW()) """, - (pos_user_id, symbol, 'strategy_linkage', 'browser', title, message, - json.dumps({'signal_type': signal_type}, ensure_ascii=False)) + ( + pos_user_id, + symbol, + "strategy_linkage", + "browser", + title, + message, + json.dumps({"signal_type": signal_type}, ensure_ascii=False), + ), ) 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}") @@ -1701,8 +1787,8 @@ def _monitor_loop(): for row in rows: if _stop_event.is_set(): break - monitor_id = row.get('id') - monitor_user_id = int(row.get('user_id') or 1) + monitor_id = row.get("id") + monitor_user_id = int(row.get("user_id") or 1) if not monitor_id: continue logger.info(f"Running due monitor #{monitor_id} for user #{monitor_user_id}") @@ -1720,15 +1806,15 @@ def _monitor_loop(): for uid, results in user_results.items(): try: if len(results) == 1: - meta = results[0].get('_meta', {}) + meta = results[0].get("_meta", {}) _send_monitor_notification( - monitor_name=meta.get('monitor_name', '?'), + monitor_name=meta.get("monitor_name", "?"), result=results[0], - notification_config=meta.get('notification_config', {}), - positions=meta.get('positions', []), - position_analyses=meta.get('position_analyses', []), - language=meta.get('language', 'en-US'), - custom_prompt=meta.get('custom_prompt', ''), + notification_config=meta.get("notification_config", {}), + positions=meta.get("positions", []), + position_analyses=meta.get("position_analyses", []), + language=meta.get("language", "en-US"), + custom_prompt=meta.get("custom_prompt", ""), user_id=uid, ) else: @@ -1747,11 +1833,11 @@ def _monitor_loop(): 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() @@ -1761,7 +1847,7 @@ def start_monitor_service(): def stop_monitor_service(): """Stop the background monitor service.""" global _monitor_thread - + _stop_event.set() if _monitor_thread: _monitor_thread.join(timeout=5) diff --git a/backend_api_python/app/services/reflection.py b/backend_api_python/app/services/reflection.py index d04be22..a837a46 100644 --- a/backend_api_python/app/services/reflection.py +++ b/backend_api_python/app/services/reflection.py @@ -5,13 +5,13 @@ Validates historical AI decisions against actual price outcomes, updates qd_analysis_memory with was_correct/actual_return_pct, and optionally triggers AI calibration. """ + import os import threading -import time -from typing import Dict, Any, Optional +from typing import Any, Dict, Optional -from app.utils.logger import get_logger from app.services.analysis_memory import get_analysis_memory +from app.utils.logger import get_logger logger = get_logger(__name__) @@ -53,6 +53,7 @@ class ReflectionService: return try: from app.services.ai_calibration import AICalibrationService + svc = AICalibrationService() markets = (os.getenv("AI_CALIBRATION_MARKETS", "Crypto") or "Crypto").strip().split(",") for market in markets: diff --git a/backend_api_python/app/services/search.py b/backend_api_python/app/services/search.py index 769dbc6..a35274d 100644 --- a/backend_api_python/app/services/search.py +++ b/backend_api_python/app/services/search.py @@ -11,19 +11,20 @@ Supported search engines (in order of priority): Reference: daily_stock_analysis-main/src/search_service.py """ -import requests -import json -import time + import re +import time from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime -from typing import List, Dict, Any, Optional from itertools import cycle +from typing import Any, Dict, List, Optional from urllib.parse import urlparse -from app.utils.logger import get_logger +import requests + from app.utils.config_loader import load_addon_config +from app.utils.logger import get_logger logger = get_logger(__name__) @@ -35,51 +36,53 @@ _google_quota_reset_time = 0 @dataclass class SearchResult: """Search result data class""" + title: str snippet: str # summary url: str source: str # Source website published_date: Optional[str] = None - sentiment: str = 'neutral' # Emotion tags - + sentiment: str = "neutral" # Emotion tags + def to_text(self) -> str: """Convert to text format""" date_str = f" ({self.published_date})" if self.published_date else "" return f"【{self.source}】{self.title}{date_str}\n{self.snippet}" - + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary""" return { - 'title': self.title, - 'link': self.url, - 'snippet': self.snippet, - 'source': self.source, - 'published': self.published_date or '', - 'sentiment': self.sentiment, + "title": self.title, + "link": self.url, + "snippet": self.snippet, + "source": self.source, + "published": self.published_date or "", + "sentiment": self.sentiment, } -@dataclass +@dataclass class SearchResponse: """search response""" + query: str results: List[SearchResult] provider: str # search engine used success: bool = True error_message: Optional[str] = None search_time: float = 0.0 # Search time (seconds) - + def to_context(self, max_results: int = 5) -> str: """Transform search results into context that can be used for AI analysis""" if not self.success or not self.results: return f"No relevant results were found for '{self.query}'." - + lines = [f"[Search results for {self.query}] (source: {self.provider})"] for i, result in enumerate(self.results[:max_results], 1): lines.append(f"\n{i}. {result.to_text()}") - + return "\n".join(lines) - + def to_list(self) -> List[Dict[str, Any]]: """Convert to list format (compatible with old interface)""" return [r.to_dict() for r in self.results] @@ -87,11 +90,11 @@ class SearchResponse: class BaseSearchProvider(ABC): """Search engine base class""" - + def __init__(self, api_keys: List[str], name: str): """ Initialize search engine - + Args: api_keys: API Key list (supports multiple key load balancing) name: search engine name @@ -101,63 +104,63 @@ class BaseSearchProvider(ABC): self._key_cycle = cycle(api_keys) if api_keys else None self._key_usage: Dict[str, int] = {key: 0 for key in api_keys} self._key_errors: Dict[str, int] = {key: 0 for key in api_keys} - + @property def name(self) -> str: return self._name - + @property def is_available(self) -> bool: """Check if there is an available API Key""" return bool(self._api_keys) - + def _get_next_key(self) -> Optional[str]: """ Get the next available API Key (load balancing) - + Strategy: polling + skip keys with too many errors """ if not self._key_cycle: return None - + # Try at most all keys for _ in range(len(self._api_keys)): key = next(self._key_cycle) # Skip keys with too many errors (more than 3 times) if self._key_errors.get(key, 0) < 3: return key - + # There is a problem with all keys, reset the error count and return the first one logger.warning(f"[{self._name}] all API keys have recorded errors, resetting error counters") self._key_errors = {key: 0 for key in self._api_keys} return self._api_keys[0] if self._api_keys else None - + def _record_success(self, key: str) -> None: """Record successful use""" self._key_usage[key] = self._key_usage.get(key, 0) + 1 # Decrement error count on success if key in self._key_errors and self._key_errors[key] > 0: self._key_errors[key] -= 1 - + def _record_error(self, key: str) -> None: """Log errors""" self._key_errors[key] = self._key_errors.get(key, 0) + 1 logger.warning(f"[{self._name}] API key {key[:8]}... error count: {self._key_errors[key]}") - + @abstractmethod def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse: """Perform a search (subclass implementation)""" pass - + def search(self, query: str, max_results: int = 5, days: int = 7) -> SearchResponse: """ Perform a search - + Args: query: search keyword max_results: Maximum number of results returned days: Search the time range of the last few days (default 7 days) - + Returns: SearchResponse object """ @@ -168,61 +171,59 @@ class BaseSearchProvider(ABC): results=[], provider=self._name, success=False, - error_message=f"{self._name} API key is not configured" + error_message=f"{self._name} API key is not configured", ) - + start_time = time.time() try: response = self._do_search(query, api_key, max_results, days=days) response.search_time = time.time() - start_time - + if response.success: self._record_success(api_key) - logger.info(f"[{self._name}] search '{query}' succeeded, returned {len(response.results)} results in {response.search_time:.2f}s") + logger.info( + f"[{self._name}] search '{query}' succeeded, returned {len(response.results)} results in {response.search_time:.2f}s" + ) else: self._record_error(api_key) - + return response - + except Exception as e: self._record_error(api_key) elapsed = time.time() - start_time logger.error(f"[{self._name}] search '{query}' failed: {e}") return SearchResponse( - query=query, - results=[], - provider=self._name, - success=False, - error_message=str(e), - search_time=elapsed + query=query, results=[], provider=self._name, success=False, error_message=str(e), search_time=elapsed ) - + @staticmethod def _extract_domain(url: str) -> str: """Extract domain name from URL as source""" try: parsed = urlparse(url) - domain = parsed.netloc.replace('www.', '') - return domain or 'Unknown source' - except: - return 'Unknown source' + domain = parsed.netloc.replace("www.", "") + return domain or "Unknown source" + except Exception as e: + logger.debug(f"Failed to extract domain from URL {url}: {e}") + return "Unknown source" class TavilySearchProvider(BaseSearchProvider): """ Tavily search engine - + Features: - Search API optimized for AI/LLM - Free version 1000 requests per month - Return structured search results - + Documentation: https://docs.tavily.com/ """ - + def __init__(self, api_keys: List[str]): super().__init__(api_keys, "Tavily") - + def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse: """Perform a Tavily search""" try: @@ -230,10 +231,10 @@ class TavilySearchProvider(BaseSearchProvider): except ImportError: # If tavily-python is not installed, use the REST API return self._do_search_rest(query, api_key, max_results, days) - + try: client = TavilyClient(api_key=api_key) - + # Perform a search response = client.search( query=query, @@ -243,114 +244,106 @@ class TavilySearchProvider(BaseSearchProvider): include_raw_content=False, days=days, ) - + # Parse results results = [] - for item in response.get('results', []): - results.append(SearchResult( - title=item.get('title', ''), - snippet=item.get('content', '')[:500], - url=item.get('url', ''), - source=self._extract_domain(item.get('url', '')), - published_date=item.get('published_date'), - )) - + for item in response.get("results", []): + results.append( + SearchResult( + title=item.get("title", ""), + snippet=item.get("content", "")[:500], + url=item.get("url", ""), + source=self._extract_domain(item.get("url", "")), + published_date=item.get("published_date"), + ) + ) + return SearchResponse( query=query, results=results, provider=self.name, success=True, ) - + except Exception as e: error_msg = str(e) - if 'rate limit' in error_msg.lower() or 'quota' in error_msg.lower(): + if "rate limit" in error_msg.lower() or "quota" in error_msg.lower(): error_msg = f"API quota exhausted: {error_msg}" - - return SearchResponse( - query=query, - results=[], - provider=self.name, - success=False, - error_message=error_msg - ) - + + return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=error_msg) + def _do_search_rest(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse: """Performing Tavily searches using the REST API (alternative)""" try: url = "https://api.tavily.com/search" headers = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", } payload = { - 'api_key': api_key, - 'query': query, - 'search_depth': 'advanced', - 'max_results': max_results, - 'include_answer': False, - 'include_raw_content': False, + "api_key": api_key, + "query": query, + "search_depth": "advanced", + "max_results": max_results, + "include_answer": False, + "include_raw_content": False, } - + response = requests.post(url, headers=headers, json=payload, timeout=15) - + if response.status_code != 200: return SearchResponse( query=query, results=[], provider=self.name, success=False, - error_message=f"HTTP {response.status_code}: {response.text}" + error_message=f"HTTP {response.status_code}: {response.text}", ) - + data = response.json() results = [] - for item in data.get('results', []): - results.append(SearchResult( - title=item.get('title', ''), - snippet=item.get('content', '')[:500], - url=item.get('url', ''), - source=self._extract_domain(item.get('url', '')), - published_date=item.get('published_date'), - )) - + for item in data.get("results", []): + results.append( + SearchResult( + title=item.get("title", ""), + snippet=item.get("content", "")[:500], + url=item.get("url", ""), + source=self._extract_domain(item.get("url", "")), + published_date=item.get("published_date"), + ) + ) + return SearchResponse( query=query, results=results, provider=self.name, success=True, ) - + except Exception as e: - return SearchResponse( - query=query, - results=[], - provider=self.name, - success=False, - error_message=str(e) - ) + return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e)) class SerpAPISearchProvider(BaseSearchProvider): """ SerpAPI search engine - + Features: -Support multiple search engines such as Google, Bing, Baidu, etc. - Free version 100 requests per month - + Documentation: https://serpapi.com/ """ - + def __init__(self, api_keys: List[str]): super().__init__(api_keys, "SerpAPI") - + def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse: """Perform a SerpAPI search""" try: from serpapi import GoogleSearch except ImportError: return self._do_search_rest(query, api_key, max_results, days) - + try: tbs = "qdr:w" if days <= 1: @@ -370,23 +363,25 @@ class SerpAPISearchProvider(BaseSearchProvider): "hl": "zh-cn", "gl": "cn", "tbs": tbs, - "num": max_results + "num": max_results, } - + search = GoogleSearch(params) response = search.get_dict() - + results = [] - organic_results = response.get('organic_results', []) + organic_results = response.get("organic_results", []) for item in organic_results[:max_results]: - results.append(SearchResult( - title=item.get('title', ''), - snippet=item.get('snippet', '')[:500], - url=item.get('link', ''), - source=item.get('source', self._extract_domain(item.get('link', ''))), - published_date=item.get('date'), - )) + results.append( + SearchResult( + title=item.get("title", ""), + snippet=item.get("snippet", "")[:500], + url=item.get("link", ""), + source=item.get("source", self._extract_domain(item.get("link", ""))), + published_date=item.get("date"), + ) + ) return SearchResponse( query=query, @@ -394,16 +389,10 @@ class SerpAPISearchProvider(BaseSearchProvider): provider=self.name, success=True, ) - + except Exception as e: - return SearchResponse( - query=query, - results=[], - provider=self.name, - success=False, - error_message=str(e) - ) - + return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e)) + def _do_search_rest(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse: """Performing SerpAPI searches using the REST API""" try: @@ -414,7 +403,7 @@ class SerpAPISearchProvider(BaseSearchProvider): tbs = "qdr:w" elif days <= 30: tbs = "qdr:m" - + url = "https://serpapi.com/search" params = { "engine": "google", @@ -423,291 +412,275 @@ class SerpAPISearchProvider(BaseSearchProvider): "hl": "zh-cn", "gl": "cn", "tbs": tbs, - "num": max_results + "num": max_results, } - + response = requests.get(url, params=params, timeout=15) - + if response.status_code != 200: return SearchResponse( query=query, results=[], provider=self.name, success=False, - error_message=f"HTTP {response.status_code}" + error_message=f"HTTP {response.status_code}", ) - + data = response.json() results = [] - - for item in data.get('organic_results', [])[:max_results]: - results.append(SearchResult( - title=item.get('title', ''), - snippet=item.get('snippet', '')[:500], - url=item.get('link', ''), - source=self._extract_domain(item.get('link', '')), - published_date=item.get('date'), - )) - + + for item in data.get("organic_results", [])[:max_results]: + results.append( + SearchResult( + title=item.get("title", ""), + snippet=item.get("snippet", "")[:500], + url=item.get("link", ""), + source=self._extract_domain(item.get("link", "")), + published_date=item.get("date"), + ) + ) + return SearchResponse( query=query, results=results, provider=self.name, success=True, ) - + except Exception as e: - return SearchResponse( - query=query, - results=[], - provider=self.name, - success=False, - error_message=str(e) - ) + return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e)) class GoogleSearchProvider(BaseSearchProvider): """Google Custom Search (CSE) search engine""" - + def __init__(self, api_key: str, cx: str): super().__init__([api_key] if api_key else [], "Google") self._cx = cx - + def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse: """Perform a Google search""" global _google_quota_exhausted, _google_quota_reset_time - + if not self._cx: return SearchResponse( query=query, results=[], provider=self.name, success=False, - error_message="Google Search CX is not configured" + error_message="Google Search CX is not configured", ) - + try: url = "https://www.googleapis.com/customsearch/v1" params = { - 'key': api_key, - 'cx': self._cx, - 'q': query, - 'num': min(max_results, 10), + "key": api_key, + "cx": self._cx, + "q": query, + "num": min(max_results, 10), } - + # Add time limit if days <= 1: - params['dateRestrict'] = 'd1' + params["dateRestrict"] = "d1" elif days <= 7: - params['dateRestrict'] = 'w1' + params["dateRestrict"] = "w1" elif days <= 30: - params['dateRestrict'] = 'm1' - + params["dateRestrict"] = "m1" + response = requests.get(url, params=params, timeout=10) - + if response.status_code == 429: _google_quota_exhausted = True import datetime - tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) + + tomorrow = datetime.datetime.utcnow().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + datetime.timedelta(days=1) _google_quota_reset_time = tomorrow.timestamp() return SearchResponse( query=query, results=[], provider=self.name, success=False, - error_message="Google API quota exhausted" + error_message="Google API quota exhausted", ) - + response.raise_for_status() data = response.json() - + results = [] - if 'items' in data: - for item in data['items']: - results.append(SearchResult( - title=item.get('title', ''), - snippet=item.get('snippet', ''), - url=item.get('link', ''), - source='Google', - published_date=item.get('pagemap', {}).get('metatags', [{}])[0].get('article:published_time', ''), - )) - + if "items" in data: + for item in data["items"]: + results.append( + SearchResult( + title=item.get("title", ""), + snippet=item.get("snippet", ""), + url=item.get("link", ""), + source="Google", + published_date=item.get("pagemap", {}) + .get("metatags", [{}])[0] + .get("article:published_time", ""), + ) + ) + return SearchResponse( query=query, results=results, provider=self.name, success=True, ) - + except Exception as e: - return SearchResponse( - query=query, - results=[], - provider=self.name, - success=False, - error_message=str(e) - ) + return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e)) class BingSearchProvider(BaseSearchProvider): """Bing Search API search engine""" - + def __init__(self, api_key: str): super().__init__([api_key] if api_key else [], "Bing") - + def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse: """Perform a Bing search""" try: url = "https://api.bing.microsoft.com/v7.0/search" headers = {"Ocp-Apim-Subscription-Key": api_key} - params = { - "q": query, - "count": max_results, - "textDecorations": True, - "textFormat": "HTML" - } - + params = {"q": query, "count": max_results, "textDecorations": True, "textFormat": "HTML"} + response = requests.get(url, headers=headers, params=params, timeout=10) response.raise_for_status() data = response.json() - + results = [] - if 'webPages' in data and 'value' in data['webPages']: - for item in data['webPages']['value']: - results.append(SearchResult( - title=item.get('name', ''), - snippet=item.get('snippet', ''), - url=item.get('url', ''), - source='Bing', - published_date=item.get('datePublished', ''), - )) - + if "webPages" in data and "value" in data["webPages"]: + for item in data["webPages"]["value"]: + results.append( + SearchResult( + title=item.get("name", ""), + snippet=item.get("snippet", ""), + url=item.get("url", ""), + source="Bing", + published_date=item.get("datePublished", ""), + ) + ) + return SearchResponse( query=query, results=results, provider=self.name, success=True, ) - + except Exception as e: - return SearchResponse( - query=query, - results=[], - provider=self.name, - success=False, - error_message=str(e) - ) + return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e)) class DuckDuckGoSearchProvider(BaseSearchProvider): """DuckDuckGo search engine (free, no API Key required)""" - + def __init__(self): - super().__init__(['free'], "DuckDuckGo") - + super().__init__(["free"], "DuckDuckGo") + def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse: """Perform a DuckDuckGo search""" try: # Using DuckDuckGo Instant Answer API url = "https://api.duckduckgo.com/" - params = { - 'q': query, - 'format': 'json', - 'no_html': 1, - 'skip_disambig': 1 - } - + params = {"q": query, "format": "json", "no_html": 1, "skip_disambig": 1} + response = requests.get(url, params=params, timeout=10) response.raise_for_status() data = response.json() - + results = [] - + # Get RelatedTopics - related_topics = data.get('RelatedTopics', []) + related_topics = data.get("RelatedTopics", []) for topic in related_topics[:max_results]: if isinstance(topic, dict): - if 'FirstURL' in topic: - results.append(SearchResult( - title=topic.get('Text', '')[:100], - snippet=topic.get('Text', ''), - url=topic.get('FirstURL', ''), - source='DuckDuckGo', - )) - elif 'Topics' in topic: - for sub_topic in topic['Topics']: + if "FirstURL" in topic: + results.append( + SearchResult( + title=topic.get("Text", "")[:100], + snippet=topic.get("Text", ""), + url=topic.get("FirstURL", ""), + source="DuckDuckGo", + ) + ) + elif "Topics" in topic: + for sub_topic in topic["Topics"]: if len(results) >= max_results: break - if 'FirstURL' in sub_topic: - results.append(SearchResult( - title=sub_topic.get('Text', '')[:100], - snippet=sub_topic.get('Text', ''), - url=sub_topic.get('FirstURL', ''), - source='DuckDuckGo', - )) - + if "FirstURL" in sub_topic: + results.append( + SearchResult( + title=sub_topic.get("Text", "")[:100], + snippet=sub_topic.get("Text", ""), + url=sub_topic.get("FirstURL", ""), + source="DuckDuckGo", + ) + ) + # Check AbstractURL - if data.get('AbstractURL') and len(results) < max_results: - results.insert(0, SearchResult( - title=data.get('Heading', query), - snippet=data.get('AbstractText', ''), - url=data.get('AbstractURL', ''), - source='DuckDuckGo', - )) - + if data.get("AbstractURL") and len(results) < max_results: + results.insert( + 0, + SearchResult( + title=data.get("Heading", query), + snippet=data.get("AbstractText", ""), + url=data.get("AbstractURL", ""), + source="DuckDuckGo", + ), + ) + # If no results, try the HTML version if not results: results = self._search_html(query, max_results) - + return SearchResponse( query=query, results=results[:max_results], provider=self.name, success=len(results) > 0, ) - + except Exception as e: - return SearchResponse( - query=query, - results=[], - provider=self.name, - success=False, - error_message=str(e) - ) - + return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e)) + def _search_html(self, query: str, max_results: int) -> List[SearchResult]: """DuckDuckGo HTML search alternatives""" try: url = "https://lite.duckduckgo.com/lite/" - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - } - data = {'q': query} - + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + data = {"q": query} + response = requests.post(url, headers=headers, data=data, timeout=10) response.raise_for_status() - + results = [] html = response.text - + link_pattern = r']*class="result-link"[^>]*href="([^"]*)"[^>]*>([^<]*)' snippet_pattern = r']*class="result-snippet"[^>]*>([^<]*)' - + links = re.findall(link_pattern, html) snippets = re.findall(snippet_pattern, html) - + for i, (link, title) in enumerate(links[:max_results]): - snippet = snippets[i] if i < len(snippets) else '' + snippet = snippets[i] if i < len(snippets) else "" if link and title: - results.append(SearchResult( - title=title.strip(), - snippet=snippet.strip(), - url=link, - source='DuckDuckGo', - )) - + results.append( + SearchResult( + title=title.strip(), + snippet=snippet.strip(), + url=link, + source="DuckDuckGo", + ) + ) + return results - + except Exception as e: logger.debug(f"DuckDuckGo HTML search failed: {e}") return [] @@ -716,103 +689,105 @@ class DuckDuckGoSearchProvider(BaseSearchProvider): class SearchService: """ Search service - + Function: 1. Manage multiple search engines 2. Automatic failover 3. Result aggregation and formatting """ - + def __init__(self): self._providers: List[BaseSearchProvider] = [] self._config = {} self._load_config() self._init_providers() - + def _load_config(self): """Load configuration""" config = load_addon_config() - self._config = config.get('search', {}) - self.provider = self._config.get('provider', 'google') - self.max_results = int(self._config.get('max_results', 10)) - + self._config = config.get("search", {}) + self.provider = self._config.get("provider", "google") + self.max_results = int(self._config.get("max_results", 10)) + def _init_providers(self): """Initialize search engines (sorted by priority)""" from app.config import APIKeys - + # 1. Tavily (AI optimized search) tavily_keys = APIKeys.TAVILY_API_KEYS if tavily_keys: self._providers.append(TavilySearchProvider(tavily_keys)) logger.info(f"Configured Tavily search with {len(tavily_keys)} API keys") - + # 2. SerpAPI serpapi_keys = APIKeys.SERPAPI_KEYS if serpapi_keys: self._providers.append(SerpAPISearchProvider(serpapi_keys)) logger.info(f"Configured SerpAPI search with {len(serpapi_keys)} API keys") - + # 3. Google CSE - google_api_key = self._config.get('google', {}).get('api_key') - google_cx = self._config.get('google', {}).get('cx') + google_api_key = self._config.get("google", {}).get("api_key") + google_cx = self._config.get("google", {}).get("cx") if google_api_key and google_cx: self._providers.append(GoogleSearchProvider(google_api_key, google_cx)) logger.info("Configured Google CSE search") - + # 4. Bing - bing_api_key = self._config.get('bing', {}).get('api_key') + bing_api_key = self._config.get("bing", {}).get("api_key") if bing_api_key: self._providers.append(BingSearchProvider(bing_api_key)) logger.info("Configured Bing search") - + # 5. DuckDuckGo (Free Tips) self._providers.append(DuckDuckGoSearchProvider()) logger.info("Configured DuckDuckGo search as the free fallback") - + if len(self._providers) == 1: logger.warning("Only DuckDuckGo is available. Configure more search-engine API keys for better coverage.") - + @property def is_available(self) -> bool: """Check if a search engine is available""" return any(p.is_available for p in self._providers) - - def search(self, query: str, num_results: int = None, date_restrict: str = None, days: int = 7) -> List[Dict[str, Any]]: + + def search( + self, query: str, num_results: int = None, date_restrict: str = None, days: int = 7 + ) -> List[Dict[str, Any]]: """ Perform a search (compatible with old interface) - + Args: query: search keyword num_results: Maximum number of results returned date_restrict: time restriction (Google format, such as 'd7') days: Search the last few days (priority higher than date_restrict) - + Returns: Search result list """ limit = num_results if num_results else self.max_results - + # Parse date_restrict to days if date_restrict and not days: - if date_restrict.startswith('d'): + if date_restrict.startswith("d"): days = int(date_restrict[1:]) - elif date_restrict.startswith('w'): + elif date_restrict.startswith("w"): days = int(date_restrict[1:]) * 7 - elif date_restrict.startswith('m'): + elif date_restrict.startswith("m"): days = int(date_restrict[1:]) * 30 - + response = self.search_with_fallback(query, limit, days) return response.to_list() - + def search_with_fallback(self, query: str, max_results: int = 5, days: int = 7) -> SearchResponse: """ Perform a search (with automatic failover) - + Args: query: search keyword max_results: Maximum number of results returned days: Search the last few days - + Returns: SearchResponse object """ @@ -820,39 +795,35 @@ class SearchService: for provider in self._providers: if not provider.is_available: continue - + response = provider.search(query, max_results, days) - + if response.success and response.results: return response else: logger.warning(f"{provider.name} search failed: {response.error_message}. Trying the next engine.") - + # all engines fail return SearchResponse( query=query, results=[], provider="None", success=False, - error_message="All search engines are unavailable or failed" + error_message="All search engines are unavailable or failed", ) - + def search_stock_news( - self, - stock_code: str, - stock_name: str, - market: str = "USStock", - max_results: int = 5 + self, stock_code: str, stock_name: str, market: str = "USStock", max_results: int = 5 ) -> SearchResponse: """ Search stock related news - + Args: stock_code: stock code stock_name: stock name market: market type max_results: Maximum number of results returned - + Returns: SearchResponse object """ @@ -864,7 +835,7 @@ class SearchService: search_days = 2 else: search_days = 1 - + # Build search queries based on market type if market == "USStock": query = f"{stock_name} {stock_code} stock news latest" @@ -874,26 +845,23 @@ class SearchService: query = f"{stock_name} {stock_code} forex news analysis" else: query = f"{stock_name} {stock_code} latest news" - + logger.info(f"Searching stock news: {stock_name}({stock_code}), market={market}, days={search_days}") - + return self.search_with_fallback(query, max_results, search_days) - + def search_stock_events( - self, - stock_code: str, - stock_name: str, - event_types: Optional[List[str]] = None + self, stock_code: str, stock_name: str, event_types: Optional[List[str]] = None ) -> SearchResponse: """ Search for specific stock events (annual report preview, shareholding reduction, etc.) """ if event_types is None: event_types = ["年报预告", "减持公告", "业绩快报"] - + event_query = " OR ".join(event_types) query = f"{stock_name} ({event_query})" - + return self.search_with_fallback(query, max_results=5, days=30) diff --git a/backend_api_python/app/services/security_service.py b/backend_api_python/app/services/security_service.py index 8ac7e9f..f57bb7e 100644 --- a/backend_api_python/app/services/security_service.py +++ b/backend_api_python/app/services/security_service.py @@ -1,11 +1,14 @@ """ Security Service - Handles Turnstile verification, rate limiting, and brute-force protection. """ -import os + import json -import requests +import os from datetime import datetime, timedelta -from typing import Tuple, Optional, Dict, Any +from typing import Any, Dict, Tuple + +import requests + from app.utils.db import get_db_connection from app.utils.logger import get_logger @@ -25,94 +28,90 @@ def get_security_service(): class SecurityService: """Security service for authentication protection""" - + def __init__(self): self._load_config() - + def _load_config(self): """Load security configuration from environment variables""" # Turnstile config - self.turnstile_site_key = os.getenv('TURNSTILE_SITE_KEY', '') - self.turnstile_secret_key = os.getenv('TURNSTILE_SECRET_KEY', '') + self.turnstile_site_key = os.getenv("TURNSTILE_SITE_KEY", "") + self.turnstile_secret_key = os.getenv("TURNSTILE_SECRET_KEY", "") self.turnstile_enabled = bool(self.turnstile_site_key and self.turnstile_secret_key) - + # IP rate limit config - self.ip_max_attempts = int(os.getenv('SECURITY_IP_MAX_ATTEMPTS', '10')) - self.ip_window_minutes = int(os.getenv('SECURITY_IP_WINDOW_MINUTES', '5')) - self.ip_block_minutes = int(os.getenv('SECURITY_IP_BLOCK_MINUTES', '15')) - + self.ip_max_attempts = int(os.getenv("SECURITY_IP_MAX_ATTEMPTS", "10")) + self.ip_window_minutes = int(os.getenv("SECURITY_IP_WINDOW_MINUTES", "5")) + self.ip_block_minutes = int(os.getenv("SECURITY_IP_BLOCK_MINUTES", "15")) + # Account rate limit config - self.account_max_attempts = int(os.getenv('SECURITY_ACCOUNT_MAX_ATTEMPTS', '5')) - self.account_window_minutes = int(os.getenv('SECURITY_ACCOUNT_WINDOW_MINUTES', '60')) - self.account_block_minutes = int(os.getenv('SECURITY_ACCOUNT_BLOCK_MINUTES', '30')) - + self.account_max_attempts = int(os.getenv("SECURITY_ACCOUNT_MAX_ATTEMPTS", "5")) + self.account_window_minutes = int(os.getenv("SECURITY_ACCOUNT_WINDOW_MINUTES", "60")) + self.account_block_minutes = int(os.getenv("SECURITY_ACCOUNT_BLOCK_MINUTES", "30")) + # Verification code rate limit - self.code_rate_limit_seconds = int(os.getenv('VERIFICATION_CODE_RATE_LIMIT', '60')) - self.code_ip_hourly_limit = int(os.getenv('VERIFICATION_CODE_IP_HOURLY_LIMIT', '10')) - + self.code_rate_limit_seconds = int(os.getenv("VERIFICATION_CODE_RATE_LIMIT", "60")) + self.code_ip_hourly_limit = int(os.getenv("VERIFICATION_CODE_IP_HOURLY_LIMIT", "10")) + def get_security_config(self) -> Dict[str, Any]: """Get public security config for frontend""" return { - 'turnstile_enabled': self.turnstile_enabled, - 'turnstile_site_key': self.turnstile_site_key, - 'registration_enabled': os.getenv('ENABLE_REGISTRATION', 'true').lower() == 'true', - 'oauth_google_enabled': bool(os.getenv('GOOGLE_CLIENT_ID', '')), - 'oauth_github_enabled': bool(os.getenv('GITHUB_CLIENT_ID', '')), + "turnstile_enabled": self.turnstile_enabled, + "turnstile_site_key": self.turnstile_site_key, + "registration_enabled": os.getenv("ENABLE_REGISTRATION", "true").lower() == "true", + "oauth_google_enabled": bool(os.getenv("GOOGLE_CLIENT_ID", "")), + "oauth_github_enabled": bool(os.getenv("GITHUB_CLIENT_ID", "")), } - + # ========================================================================= # Turnstile Verification # ========================================================================= - + def verify_turnstile(self, token: str, ip_address: str = None) -> Tuple[bool, str]: """ Verify Cloudflare Turnstile token. - + Returns: (success, message) """ if not self.turnstile_enabled: # If Turnstile is not configured, skip verification - return True, 'turnstile_disabled' - + return True, "turnstile_disabled" + if not token: - return False, 'Missing Turnstile token' - + return False, "Missing Turnstile token" + try: response = requests.post( - 'https://challenges.cloudflare.com/turnstile/v0/siteverify', - data={ - 'secret': self.turnstile_secret_key, - 'response': token, - 'remoteip': ip_address - }, - timeout=10 + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + data={"secret": self.turnstile_secret_key, "response": token, "remoteip": ip_address}, + timeout=10, ) result = response.json() - - if result.get('success'): - return True, 'verified' + + if result.get("success"): + return True, "verified" else: - error_codes = result.get('error-codes', []) + error_codes = result.get("error-codes", []) logger.warning(f"Turnstile verification failed: {error_codes}") - return False, 'Turnstile verification failed' - + return False, "Turnstile verification failed" + except requests.RequestException as e: logger.error(f"Turnstile API error: {e}") # On API error, we might want to allow (fail-open) or deny (fail-closed) # For security, we'll deny - return False, 'Turnstile service unavailable' - + return False, "Turnstile service unavailable" + # ========================================================================= # Rate Limiting & Brute-Force Protection # ========================================================================= - - def record_login_attempt(self, identifier: str, identifier_type: str, - success: bool, ip_address: str = None, - user_agent: str = None) -> bool: + + def record_login_attempt( + self, identifier: str, identifier_type: str, success: bool, ip_address: str = None, user_agent: str = None + ) -> bool: """ Record a login attempt for rate limiting. - + Args: identifier: IP address or username identifier_type: 'ip' or 'account' @@ -125,11 +124,11 @@ class SecurityService: cur = db.cursor() cur.execute( """ - INSERT INTO qd_login_attempts + INSERT INTO qd_login_attempts (identifier, identifier_type, success, ip_address, user_agent) VALUES (?, ?, ?, ?, ?) """, - (identifier, identifier_type, success, ip_address, user_agent) + (identifier, identifier_type, success, ip_address, user_agent), ) db.commit() cur.close() @@ -137,16 +136,16 @@ class SecurityService: except Exception as e: logger.error(f"Failed to record login attempt: {e}") return False - + def is_blocked(self, identifier: str, identifier_type: str) -> Tuple[bool, int]: """ Check if an identifier (IP or account) is blocked due to too many failed attempts. - + Returns: (is_blocked, remaining_seconds) """ try: - if identifier_type == 'ip': + if identifier_type == "ip": max_attempts = self.ip_max_attempts window_minutes = self.ip_window_minutes block_minutes = self.ip_block_minutes @@ -154,30 +153,30 @@ class SecurityService: max_attempts = self.account_max_attempts window_minutes = self.account_window_minutes block_minutes = self.account_block_minutes - + with get_db_connection() as db: cur = db.cursor() - + # Count failed attempts in the time window window_start = datetime.now() - timedelta(minutes=window_minutes) cur.execute( """ SELECT COUNT(*) as count, MAX(attempt_time) as last_attempt FROM qd_login_attempts - WHERE identifier = ? AND identifier_type = ? + WHERE identifier = ? AND identifier_type = ? AND success = FALSE AND attempt_time > ? """, - (identifier, identifier_type, window_start) + (identifier, identifier_type, window_start), ) row = cur.fetchone() cur.close() - + if not row: return False, 0 - - failed_count = row['count'] or 0 - last_attempt = row['last_attempt'] - + + failed_count = row["count"] or 0 + last_attempt = row["last_attempt"] + if failed_count >= max_attempts: # Check if still in block period if last_attempt: @@ -185,34 +184,37 @@ class SecurityService: if datetime.now() < block_until: remaining = int((block_until - datetime.now()).total_seconds()) return True, remaining - + return False, 0 - + except Exception as e: logger.error(f"Failed to check block status: {e}") return False, 0 - + def check_login_allowed(self, username: str, ip_address: str) -> Tuple[bool, str]: """ Check if login is allowed for the given username and IP. - + Returns: (allowed, message) """ # Check IP block - ip_blocked, ip_remaining = self.is_blocked(ip_address, 'ip') + ip_blocked, ip_remaining = self.is_blocked(ip_address, "ip") if ip_blocked: minutes = ip_remaining // 60 - return False, f'Too many failed attempts from this IP. Try again in {minutes + 1} minutes.' - + return False, f"Too many failed attempts from this IP. Try again in {minutes + 1} minutes." + # Check account block - account_blocked, account_remaining = self.is_blocked(username, 'account') + account_blocked, account_remaining = self.is_blocked(username, "account") if account_blocked: minutes = account_remaining // 60 - return False, f'Account temporarily locked due to too many failed attempts. Try again in {minutes + 1} minutes.' - - return True, 'allowed' - + return ( + False, + f"Account temporarily locked due to too many failed attempts. Try again in {minutes + 1} minutes.", + ) + + return True, "allowed" + def clear_login_attempts(self, identifier: str, identifier_type: str) -> bool: """ Clear login attempts for an identifier (called after successful login). @@ -225,7 +227,7 @@ class SecurityService: DELETE FROM qd_login_attempts WHERE identifier = ? AND identifier_type = ? """, - (identifier, identifier_type) + (identifier, identifier_type), ) db.commit() cur.close() @@ -233,17 +235,17 @@ class SecurityService: except Exception as e: logger.error(f"Failed to clear login attempts: {e}") return False - + # ========================================================================= # Security Audit Logging # ========================================================================= - - def log_security_event(self, action: str, user_id: int = None, - ip_address: str = None, user_agent: str = None, - details: dict = None) -> bool: + + def log_security_event( + self, action: str, user_id: int = None, ip_address: str = None, user_agent: str = None, details: dict = None + ) -> bool: """ Log a security-related event. - + Args: action: Event type (login, logout, register, reset_password, etc.) user_id: User ID if applicable @@ -253,16 +255,16 @@ class SecurityService: """ try: details_json = json.dumps(details) if details else None - + with get_db_connection() as db: cur = db.cursor() cur.execute( """ - INSERT INTO qd_security_logs + INSERT INTO qd_security_logs (user_id, action, ip_address, user_agent, details) VALUES (?, ?, ?, ?, ?) """, - (user_id, action, ip_address, user_agent, details_json) + (user_id, action, ip_address, user_agent, details_json), ) db.commit() cur.close() @@ -270,22 +272,22 @@ class SecurityService: except Exception as e: logger.error(f"Failed to log security event: {e}") return False - + # ========================================================================= # Verification Code Rate Limiting # ========================================================================= - + def can_send_verification_code(self, email: str, ip_address: str) -> Tuple[bool, str]: """ Check if we can send a verification code to this email from this IP. - + Returns: (allowed, message) """ try: with get_db_connection() as db: cur = db.cursor() - + # Check email rate limit (one code per minute per email) rate_limit_time = datetime.now() - timedelta(seconds=self.code_rate_limit_seconds) cur.execute( @@ -293,12 +295,12 @@ class SecurityService: SELECT COUNT(*) as count FROM qd_verification_codes WHERE email = ? AND created_at > ? """, - (email, rate_limit_time) + (email, rate_limit_time), ) row = cur.fetchone() - if row and row['count'] > 0: - return False, f'Please wait {self.code_rate_limit_seconds} seconds before requesting another code' - + if row and row["count"] > 0: + return False, f"Please wait {self.code_rate_limit_seconds} seconds before requesting another code" + # Check IP hourly limit hour_ago = datetime.now() - timedelta(hours=1) cur.execute( @@ -306,88 +308,82 @@ class SecurityService: SELECT COUNT(*) as count FROM qd_verification_codes WHERE ip_address = ? AND created_at > ? """, - (ip_address, hour_ago) + (ip_address, hour_ago), ) row = cur.fetchone() - if row and row['count'] >= self.code_ip_hourly_limit: - return False, 'Too many verification code requests from this IP. Try again later.' - + if row and row["count"] >= self.code_ip_hourly_limit: + return False, "Too many verification code requests from this IP. Try again later." + cur.close() - return True, 'allowed' - + return True, "allowed" + except Exception as e: logger.error(f"Failed to check verification code rate limit: {e}") - return True, 'allowed' # Fail open on DB errors - + return True, "allowed" # Fail open on DB errors + # ========================================================================= # Password Strength Validation # ========================================================================= - + def validate_password_strength(self, password: str) -> Tuple[bool, str]: """ Validate password meets minimum security requirements. - + Requirements: - At least 8 characters - Contains at least one uppercase letter - Contains at least one lowercase letter - Contains at least one digit - + Returns: (valid, message) """ if len(password) < 8: - return False, 'Password must be at least 8 characters long' - + return False, "Password must be at least 8 characters long" + if not any(c.isupper() for c in password): - return False, 'Password must contain at least one uppercase letter' - + return False, "Password must contain at least one uppercase letter" + if not any(c.islower() for c in password): - return False, 'Password must contain at least one lowercase letter' - + return False, "Password must contain at least one lowercase letter" + if not any(c.isdigit() for c in password): - return False, 'Password must contain at least one digit' - - return True, 'valid' - + return False, "Password must contain at least one digit" + + return True, "valid" + # ========================================================================= # Cleanup # ========================================================================= - + def cleanup_old_records(self, days: int = 7) -> int: """ Clean up old login attempts and expired verification codes. - + Returns: Number of records deleted """ deleted = 0 cutoff = datetime.now() - timedelta(days=days) - + try: with get_db_connection() as db: cur = db.cursor() - + # Clean old login attempts - cur.execute( - "DELETE FROM qd_login_attempts WHERE attempt_time < ?", - (cutoff,) - ) + cur.execute("DELETE FROM qd_login_attempts WHERE attempt_time < ?", (cutoff,)) deleted += cur.rowcount or 0 - + # Clean expired verification codes - cur.execute( - "DELETE FROM qd_verification_codes WHERE expires_at < ?", - (cutoff,) - ) + cur.execute("DELETE FROM qd_verification_codes WHERE expires_at < ?", (cutoff,)) deleted += cur.rowcount or 0 - + db.commit() cur.close() - + logger.info(f"Security cleanup: deleted {deleted} old records") return deleted - + except Exception as e: logger.error(f"Security cleanup failed: {e}") return 0 diff --git a/backend_api_python/app/services/signal_notifier.py b/backend_api_python/app/services/signal_notifier.py index dc57407..3cebe1e 100644 --- a/backend_api_python/app/services/signal_notifier.py +++ b/backend_api_python/app/services/signal_notifier.py @@ -17,9 +17,9 @@ notification_config = { from __future__ import annotations -import html -import hmac import hashlib +import hmac +import html import json import os import smtplib @@ -27,7 +27,6 @@ import time from datetime import datetime, timezone from email.message import EmailMessage from typing import Any, Dict, List, Optional, Tuple - from zoneinfo import ZoneInfo import requests @@ -229,9 +228,7 @@ class SignalNotifier: headers_override=(targets.get("webhook_headers") or targets.get("webhookHeaders") or None), token_override=(targets.get("webhook_token") or targets.get("webhookToken") or None), signing_secret_override=( - targets.get("webhook_signing_secret") - or targets.get("webhookSigningSecret") - or None + targets.get("webhook_signing_secret") or targets.get("webhookSigningSecret") or None ), ) elif c == "discord": @@ -450,7 +447,7 @@ class SignalNotifier: "" f"{esc(k)}" "" - "" + '' f"{esc(v)}" "" "" @@ -494,7 +491,6 @@ class SignalNotifier: user_id: int = None, ) -> Tuple[bool, str]: try: - now = int(time.time()) # Get user_id from strategy if not provided if user_id is None: if strategy_id is not None: @@ -504,7 +500,7 @@ class SignalNotifier: cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (int(strategy_id),)) row = cur.fetchone() cur.close() - user_id = int((row or {}).get('user_id') or 1) + user_id = int((row or {}).get("user_id") or 1) except Exception: user_id = 1 else: @@ -590,7 +586,9 @@ class SignalNotifier: headers["Authorization"] = f"Bearer {tok}" # Optional signing secret (per-strategy override, else env) - signing_secret = str(signing_secret_override or "").strip() or (os.getenv("SIGNAL_WEBHOOK_SIGNING_SECRET") or "").strip() + signing_secret = ( + str(signing_secret_override or "").strip() or (os.getenv("SIGNAL_WEBHOOK_SIGNING_SECRET") or "").strip() + ) if signing_secret: try: ts = str(int(time.time())) @@ -599,12 +597,14 @@ class SignalNotifier: sig = hmac.new(signing_secret.encode("utf-8"), sig_base, hashlib.sha256).hexdigest() headers["X-QD-Timestamp"] = ts headers["X-QD-Signature"] = sig + # Send raw bytes so signature matches what we sign. def _post_once(timeout: float) -> requests.Response: return requests.post(url, data=body, headers=headers, timeout=timeout) except Exception as e: return False, f"webhook_signing_failed:{e}" else: + def _post_once(timeout: float) -> requests.Response: return requests.post(url, json=payload, headers=headers, timeout=timeout) @@ -650,11 +650,15 @@ class SignalNotifier: "title": "QuantDinger Signal", "color": int(color), "fields": [ - {"name": "Strategy", "value": f"{strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})", "inline": True}, + { + "name": "Strategy", + "value": f"{strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})", + "inline": True, + }, {"name": "Symbol", "value": str(instrument.get("symbol") or ""), "inline": True}, {"name": "Signal", "value": str(sig.get("type") or ""), "inline": False}, - {"name": "Price", "value": str(float(order.get('ref_price') or 0.0)), "inline": True}, - {"name": "Stake", "value": str(float(order.get('stake_amount') or 0.0)), "inline": True}, + {"name": "Price", "value": str(float(order.get("ref_price") or 0.0)), "inline": True}, + {"name": "Stake", "value": str(float(order.get("stake_amount") or 0.0)), "inline": True}, ], } if payload.get("timestamp_iso"): @@ -865,9 +869,7 @@ class SignalNotifier: elif c == "telegram": chat_id = str((targets or {}).get("telegram") or "").strip() token_override = str( - (targets or {}).get("telegram_bot_token") - or (targets or {}).get("telegram_token") - or "" + (targets or {}).get("telegram_bot_token") or (targets or {}).get("telegram_token") or "" ).strip() ok, err = self._notify_telegram( chat_id=chat_id, @@ -907,4 +909,3 @@ class SignalNotifier: results[c] = {"ok": bool(ok), "error": (err or "")} return results - diff --git a/backend_api_python/app/services/strategy.py b/backend_api_python/app/services/strategy.py index bbaabe4..e0da1b7 100644 --- a/backend_api_python/app/services/strategy.py +++ b/backend_api_python/app/services/strategy.py @@ -1,26 +1,24 @@ -import os -import time import json import threading import uuid -from typing import List, Dict, Any, Optional -from datetime import datetime +from typing import Any, Dict, List, Optional -from app.utils.logger import get_logger from app.utils.db import get_db_connection +from app.utils.logger import get_logger logger = get_logger(__name__) + class StrategyService: """Strategy service.""" - + # Class variable: limit connection test concurrency _connection_test_semaphore = threading.Semaphore(5) - + def __init__(self): # Local deployment: do not use encryption/decryption. pass - + def get_running_strategies(self) -> List[Dict[str, Any]]: """Get all running strategies (ID only)""" try: @@ -30,7 +28,7 @@ class StrategyService: cursor.execute(query) results = cursor.fetchall() cursor.close() - return [row['id'] for row in results] + return [row["id"] for row in results] except Exception as e: logger.error(f"Failed to fetch running strategies: {str(e)}") return [] @@ -47,25 +45,25 @@ class StrategyService: cursor.execute(query) results = cursor.fetchall() cursor.close() - - strategies = [{'id': row['id'], 'strategy_type': row.get('strategy_type', '')} for row in results] + + strategies = [{"id": row["id"], "strategy_type": row.get("strategy_type", "")} for row in results] logger.info(f"Found {len(strategies)} running strategies: {strategies}") return strategies - + except Exception as e: logger.error(f"Failed to fetch running strategies: {str(e)}") return [] - + def get_exchange_symbols(self, exchange_config: Dict[str, Any]) -> Dict[str, Any]: """ Get exchange trading pairs (no API Key required) """ try: - exchange_id = exchange_config.get('exchange_id', '') - proxies = exchange_config.get('proxies') - + exchange_id = exchange_config.get("exchange_id", "") + proxies = exchange_config.get("proxies") + if not exchange_id: - return {'success': False, 'message': 'Please select an exchange', 'symbols': []} + return {"success": False, "message": "Please select an exchange", "symbols": []} # For these exchanges, prefer direct REST (no ccxt), aligned with local live-trading design. ex = str(exchange_id or "").strip().lower() @@ -78,11 +76,17 @@ class StrategyService: return r.json() symbols: List[str] = [] - market_type = str(exchange_config.get("market_type") or exchange_config.get("defaultType") or "spot").strip().lower() + market_type = ( + str(exchange_config.get("market_type") or exchange_config.get("defaultType") or "spot") + .strip() + .lower() + ) if market_type in ("futures", "future", "perp", "perpetual"): market_type = "swap" if ex == "bybit": - base = str(exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.bybit.com").rstrip("/") + base = str( + exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.bybit.com" + ).rstrip("/") cat = "spot" if market_type == "spot" else "linear" j = _req_json(f"{base}/v5/market/instruments-info?category={cat}") lst = (((j.get("result") or {}).get("list")) if isinstance(j, dict) else None) or [] @@ -97,10 +101,14 @@ class StrategyService: if sym.endswith("USDT") and len(sym) > 4: symbols.append(f"{sym[:-4]}/USDT") symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} + return {"success": True, "message": f"Success, {len(symbols)} trading pairs", "symbols": symbols} if ex in ("coinbaseexchange", "coinbase_exchange"): - base = str(exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.exchange.coinbase.com").rstrip("/") + base = str( + exchange_config.get("base_url") + or exchange_config.get("baseUrl") + or "https://api.exchange.coinbase.com" + ).rstrip("/") j = _req_json(f"{base}/products") if isinstance(j, list): for it in j: @@ -113,7 +121,7 @@ class StrategyService: if quote_ccy == "USDT" and base_ccy: symbols.append(f"{base_ccy}/USDT") symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} + return {"success": True, "message": f"Success, {len(symbols)} trading pairs", "symbols": symbols} if ex == "kraken": if market_type == "spot": @@ -130,7 +138,11 @@ class StrategyService: if str(quote_ccy).upper() == "USDT": symbols.append(f"{str(base_ccy).upper()}/USDT") else: - base = str(exchange_config.get("futures_base_url") or exchange_config.get("futuresBaseUrl") or "https://futures.kraken.com").rstrip("/") + base = str( + exchange_config.get("futures_base_url") + or exchange_config.get("futuresBaseUrl") + or "https://futures.kraken.com" + ).rstrip("/") j = _req_json(f"{base}/derivatives/api/v3/instruments") instruments = j.get("instruments") if isinstance(j, dict) else None if isinstance(instruments, list): @@ -142,11 +154,15 @@ class StrategyService: if sym and ("perpetual" in typ or typ.startswith("pf") or sym.startswith("PF_")): symbols.append(sym) symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} + return {"success": True, "message": f"Success, {len(symbols)} trading pairs", "symbols": symbols} if ex == "kucoin": if market_type == "spot": - base = str(exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.kucoin.com").rstrip("/") + base = str( + exchange_config.get("base_url") + or exchange_config.get("baseUrl") + or "https://api.kucoin.com" + ).rstrip("/") j = _req_json(f"{base}/api/v1/symbols") data = (j.get("data") if isinstance(j, dict) else None) or [] if isinstance(data, list): @@ -161,7 +177,11 @@ class StrategyService: if b: symbols.append(f"{b}/USDT") else: - base = str(exchange_config.get("futures_base_url") or exchange_config.get("futuresBaseUrl") or "https://api-futures.kucoin.com").rstrip("/") + base = str( + exchange_config.get("futures_base_url") + or exchange_config.get("futuresBaseUrl") + or "https://api-futures.kucoin.com" + ).rstrip("/") j = _req_json(f"{base}/api/v1/contracts/active") data = (j.get("data") if isinstance(j, dict) else None) or [] if isinstance(data, list): @@ -177,10 +197,12 @@ class StrategyService: if base_ccy: symbols.append(f"{base_ccy}/USDT") symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} + return {"success": True, "message": f"Success, {len(symbols)} trading pairs", "symbols": symbols} if ex == "gate": - base = str(exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.gateio.ws").rstrip("/") + base = str( + exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.gateio.ws" + ).rstrip("/") if market_type == "spot": j = _req_json(f"{base}/api/v4/spot/currency_pairs") if isinstance(j, list): @@ -203,11 +225,13 @@ class StrategyService: if name and name.upper().endswith("_USDT"): symbols.append(name.replace("_", "/")) symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} + return {"success": True, "message": f"Success, {len(symbols)} trading pairs", "symbols": symbols} if ex == "bitfinex": - j = _req_json("https://api-pub.bitfinex.com/v2/conf/pub:list:pair:exchange") if market_type == "spot" else _req_json( - "https://api-pub.bitfinex.com/v2/conf/pub:list:pair:futures" + j = ( + _req_json("https://api-pub.bitfinex.com/v2/conf/pub:list:pair:exchange") + if market_type == "spot" + else _req_json("https://api-pub.bitfinex.com/v2/conf/pub:list:pair:futures") ) pairs = [] if isinstance(j, list) and j and isinstance(j[0], list): @@ -225,38 +249,38 @@ class StrategyService: elif s.endswith("USDT") and len(s) > 4: symbols.append(f"{s[:-4]}/USDT") symbols = sorted(list(set(symbols))) - return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} - return {'success': True, 'message': 'Success', 'symbols': symbols} - + return {"success": True, "message": f"Success, {len(symbols)} trading pairs", "symbols": symbols} + return {"success": True, "message": "Success", "symbols": symbols} + import ccxt - + # Create exchange instance (public only) exchange_class = getattr(ccxt, exchange_id, None) if not exchange_class: - return {'success': False, 'message': f'Unsupported exchange: {exchange_id}', 'symbols': []} - + return {"success": False, "message": f"Unsupported exchange: {exchange_id}", "symbols": []} + exchange_config_dict = { - 'enableRateLimit': True, - 'options': {'defaultType': 'swap'} # Default to swap + "enableRateLimit": True, + "options": {"defaultType": "swap"}, # Default to swap } if proxies: - exchange_config_dict['proxies'] = proxies - + exchange_config_dict["proxies"] = proxies + exchange = exchange_class(exchange_config_dict) markets = exchange.load_markets() - + symbols = [] for symbol, market in markets.items(): - if market.get('active', False) and market.get('quote') == 'USDT': + if market.get("active", False) and market.get("quote") == "USDT": symbols.append(symbol) - + symbols.sort() - return {'success': True, 'message': f'Success, {len(symbols)} trading pairs', 'symbols': symbols} - + return {"success": True, "message": f"Success, {len(symbols)} trading pairs", "symbols": symbols} + except Exception as e: logger.error(f"Failed to fetch symbols: {str(e)}") - return {'success': False, 'message': f'Failed to get trading pairs: {str(e)}', 'symbols': []} - + return {"success": False, "message": f"Failed to get trading pairs: {str(e)}", "symbols": []} + def test_exchange_connection(self, exchange_config: Dict[str, Any], user_id: int = 1) -> Dict[str, Any]: """ Test exchange connection via direct REST clients (no ccxt). @@ -269,43 +293,45 @@ class StrategyService: with StrategyService._connection_test_semaphore: try: from app.services.exchange_execution import resolve_exchange_config, safe_exchange_config_for_log - from app.services.live_trading.factory import create_client from app.services.live_trading.binance import BinanceFuturesClient from app.services.live_trading.binance_spot import BinanceSpotClient - from app.services.live_trading.okx import OkxClient + from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient from app.services.live_trading.bitget import BitgetMixClient from app.services.live_trading.bitget_spot import BitgetSpotClient from app.services.live_trading.bybit import BybitClient from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient + from app.services.live_trading.deepcoin import DeepcoinClient + from app.services.live_trading.factory import create_client + from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient + from app.services.live_trading.htx import HtxClient from app.services.live_trading.kraken import KrakenClient from app.services.live_trading.kraken_futures import KrakenFuturesClient - from app.services.live_trading.kucoin import KucoinSpotClient - from app.services.live_trading.kucoin import KucoinFuturesClient - from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient - from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient - from app.services.live_trading.deepcoin import DeepcoinClient - from app.services.live_trading.htx import HtxClient + from app.services.live_trading.kucoin import KucoinFuturesClient, KucoinSpotClient + from app.services.live_trading.okx import OkxClient resolved = resolve_exchange_config(exchange_config or {}, user_id=user_id) safe_cfg = safe_exchange_config_for_log(resolved) exchange_id = (resolved.get("exchange_id") or "").strip().lower() if not exchange_id: - return {'success': False, 'message': 'Missing exchange_id', 'data': None} + return {"success": False, "message": "Missing exchange_id", "data": None} # Handle MT5 (Forex) connection test - if exchange_id == 'mt5': + if exchange_id == "mt5": # Validate that MT5 is only used for Forex market - market_category = str(resolved.get("market_category") or exchange_config.get("market_category") or "").strip() + market_category = str( + resolved.get("market_category") or exchange_config.get("market_category") or "" + ).strip() if market_category and market_category != "Forex": return { - 'success': False, - 'message': f'MT5 can only be used for Forex trading, but market_category is {market_category}. Please use MT5 only with Forex market.', - 'data': {'exchange': safe_cfg} + "success": False, + "message": f"MT5 can only be used for Forex trading, but market_category is {market_category}. Please use MT5 only with Forex market.", + "data": {"exchange": safe_cfg}, } - + try: from app.services.live_trading.factory import create_mt5_client + mt5_client = create_mt5_client(resolved) if mt5_client and mt5_client.connected: # Get account info if available @@ -315,31 +341,29 @@ class StrategyService: except Exception: pass return { - 'success': True, - 'message': 'MT5 connection successful', - 'data': { - 'exchange': safe_cfg, - 'account': account_info - } + "success": True, + "message": "MT5 connection successful", + "data": {"exchange": safe_cfg, "account": account_info}, } else: return { - 'success': False, - 'message': 'Failed to connect to MT5. Please check credentials and ensure terminal is running.', - 'data': {'exchange': safe_cfg} + "success": False, + "message": "Failed to connect to MT5. Please check credentials and ensure terminal is running.", + "data": {"exchange": safe_cfg}, } except Exception as e: error_msg = str(e) return { - 'success': False, - 'message': f'MT5 connection failed: {error_msg}', - 'data': {'exchange': safe_cfg} + "success": False, + "message": f"MT5 connection failed: {error_msg}", + "data": {"exchange": safe_cfg}, } # Handle IBKR (US Stocks) connection test - if exchange_id == 'ibkr': + if exchange_id == "ibkr": try: from app.services.live_trading.factory import create_ibkr_client + ibkr_client = create_ibkr_client(resolved) # create_ibkr_client already connects, so if it returns, connection is successful if ibkr_client and ibkr_client.connected(): @@ -350,31 +374,29 @@ class StrategyService: except Exception: pass return { - 'success': True, - 'message': 'IBKR connection successful', - 'data': { - 'exchange': safe_cfg, - 'account': account_summary - } + "success": True, + "message": "IBKR connection successful", + "data": {"exchange": safe_cfg, "account": account_summary}, } else: return { - 'success': False, - 'message': 'Failed to connect to IBKR. Please check TWS/Gateway is running and credentials are correct.', - 'data': {'exchange': safe_cfg} + "success": False, + "message": "Failed to connect to IBKR. Please check TWS/Gateway is running and credentials are correct.", + "data": {"exchange": safe_cfg}, } except Exception as e: error_msg = str(e) return { - 'success': False, - 'message': f'IBKR connection failed: {error_msg}', - 'data': {'exchange': safe_cfg} + "success": False, + "message": f"IBKR connection failed: {error_msg}", + "data": {"exchange": safe_cfg}, } # Best-effort detect current egress IP (for Binance IP whitelist debugging). egress_ip = "" try: import requests as _rq + egress_ip = str(_rq.get("https://ifconfig.me/ip", timeout=5).text or "").strip() except Exception: egress_ip = "" @@ -387,7 +409,9 @@ class StrategyService: if isinstance(client, OkxClient): return client.get_balance() if isinstance(client, BitgetMixClient): - product_type = str(resolved.get("product_type") or resolved.get("productType") or "USDT-FUTURES") + product_type = str( + resolved.get("product_type") or resolved.get("productType") or "USDT-FUTURES" + ) return client.get_accounts(product_type=product_type) if isinstance(client, BitgetSpotClient): return client.get_assets() @@ -422,12 +446,12 @@ class StrategyService: client = create_client(resolved, market_type=market_type) except Exception as e: return { - 'success': False, - 'message': f'Create client failed: {str(e)}', - 'data': { - 'exchange': safe_cfg, - 'market_type': market_type, - 'egress_ip': egress_ip, + "success": False, + "message": f"Create client failed: {str(e)}", + "data": { + "exchange": safe_cfg, + "market_type": market_type, + "egress_ip": egress_ip, }, } client_kind = type(client).__name__ @@ -439,14 +463,14 @@ class StrategyService: ok_public = False if not ok_public: return { - 'success': False, - 'message': f'Public ping failed: {exchange_id}', - 'data': { - 'exchange': safe_cfg, - 'client': client_kind, - 'market_type': market_type, - 'egress_ip': egress_ip, - 'base_url': getattr(client, "base_url", "") or "", + "success": False, + "message": f"Public ping failed: {exchange_id}", + "data": { + "exchange": safe_cfg, + "client": client_kind, + "market_type": market_type, + "egress_ip": egress_ip, + "base_url": getattr(client, "base_url", "") or "", }, } @@ -454,7 +478,9 @@ class StrategyService: priv_data = _validate_private(client, market_type) except Exception as e: msg = str(e) - if exchange_id == "binance" and ("-2015" in msg or "Invalid API-key, IP, or permissions" in msg): + if exchange_id == "binance" and ( + "-2015" in msg or "Invalid API-key, IP, or permissions" in msg + ): alt_market_type = "spot" if market_type != "spot" else "swap" alt_client_kind = "" alt_base_url = "" @@ -470,7 +496,9 @@ class StrategyService: alt_ok = False base_url = getattr(client, "base_url", "") or "" - is_demo = str(resolved.get("enable_demo_trading") or resolved.get("enableDemoTrading") or "").strip().lower() in ("true", "1", "yes") + is_demo = str( + resolved.get("enable_demo_trading") or resolved.get("enableDemoTrading") or "" + ).strip().lower() in ("true", "1", "yes") hint = ( f"Binance auth failed (-2015). Verify: " f"(1) IP whitelist includes this server egress IP={egress_ip or 'unknown'}, " @@ -505,31 +533,31 @@ class StrategyService: hint_cn = "" fail_payload = { - 'exchange': safe_cfg, - 'client': client_kind, - 'market_type': market_type, - 'egress_ip': egress_ip, - 'base_url': getattr(client, "base_url", "") or "", + "exchange": safe_cfg, + "client": client_kind, + "market_type": market_type, + "egress_ip": egress_ip, + "base_url": getattr(client, "base_url", "") or "", } if hint_cn: - fail_payload['hint_cn'] = hint_cn + fail_payload["hint_cn"] = hint_cn return { - 'success': False, - 'message': f'Auth failed: {msg}', - 'data': fail_payload, + "success": False, + "message": f"Auth failed: {msg}", + "data": fail_payload, } return { - 'success': True, - 'message': 'Connection OK', - 'data': { - 'exchange': safe_cfg, - 'client': client_kind, - 'market_type': market_type, - 'egress_ip': egress_ip, - 'base_url': getattr(client, "base_url", "") or "", - 'private': priv_data, + "success": True, + "message": "Connection OK", + "data": { + "exchange": safe_cfg, + "client": client_kind, + "market_type": market_type, + "egress_ip": egress_ip, + "base_url": getattr(client, "base_url", "") or "", + "private": priv_data, }, } @@ -545,34 +573,31 @@ class StrategyService: last_failure = None for market_type in market_candidates: result = _probe_market_type(market_type) - if result.get('success'): + if result.get("success"): if not explicit_market_type and len(market_candidates) > 1: - result['message'] = f"Connection OK ({market_type})" + result["message"] = f"Connection OK ({market_type})" return result last_failure = result if last_failure and not explicit_market_type and len(market_candidates) > 1: tried = "/".join(market_candidates) - last_failure['message'] = f"{last_failure.get('message')}. Tried market_type={tried}" - return last_failure or {'success': False, 'message': 'Connection failed', 'data': None} + last_failure["message"] = f"{last_failure.get('message')}. Tried market_type={tried}" + return last_failure or {"success": False, "message": "Connection failed", "data": None} except Exception as e: logger.error(f"test_exchange_connection failed: {str(e)}") - return {'success': False, 'message': f'Connection failed: {str(e)}', 'data': None} + return {"success": False, "message": f"Connection failed: {str(e)}", "data": None} def get_strategy_type(self, strategy_id: int) -> str: """Get strategy type from DB.""" try: with get_db_connection() as db: cur = db.cursor() - cur.execute( - "SELECT strategy_type FROM qd_strategies_trading WHERE id = ?", - (strategy_id,) - ) + cur.execute("SELECT strategy_type FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) row = cur.fetchone() cur.close() - return (row or {}).get('strategy_type') or 'IndicatorStrategy' + return (row or {}).get("strategy_type") or "IndicatorStrategy" except Exception: - return 'IndicatorStrategy' + return "IndicatorStrategy" def update_strategy_status(self, strategy_id: int, status: str, user_id: int = None) -> bool: """Update strategy status. If user_id is provided, verify ownership.""" @@ -582,12 +607,12 @@ class StrategyService: if user_id is not None: cur.execute( "UPDATE qd_strategies_trading SET status = ?, updated_at = NOW() WHERE id = ? AND user_id = ?", - (status, strategy_id, user_id) + (status, strategy_id, user_id), ) else: cur.execute( "UPDATE qd_strategies_trading SET status = ?, updated_at = NOW() WHERE id = ?", - (status, strategy_id) + (status, strategy_id), ) db.commit() cur.close() @@ -614,7 +639,7 @@ class StrategyService: def _dump_json_or_encrypt(self, obj: Any, encrypt: bool = False) -> str: if obj is None: - return '' + return "" # Local deployment: always store plaintext JSON. return json.dumps(obj, ensure_ascii=False) @@ -630,26 +655,28 @@ class StrategyService: WHERE user_id = ? ORDER BY id DESC """, - (user_id,) + (user_id,), ) rows = cur.fetchall() or [] cur.close() out = [] for r in rows: - ex = self._safe_json_loads(r.get('exchange_config'), {}) - ind = self._safe_json_loads(r.get('indicator_config'), {}) - tr = self._safe_json_loads(r.get('trading_config'), {}) - ai = self._safe_json_loads(r.get('ai_model_config'), {}) - notify = self._safe_json_loads(r.get('notification_config'), {}) - out.append({ - **r, - 'exchange_config': ex, - 'indicator_config': ind, - 'trading_config': tr, - 'ai_model_config': ai, - 'notification_config': notify - }) + ex = self._safe_json_loads(r.get("exchange_config"), {}) + ind = self._safe_json_loads(r.get("indicator_config"), {}) + tr = self._safe_json_loads(r.get("trading_config"), {}) + ai = self._safe_json_loads(r.get("ai_model_config"), {}) + notify = self._safe_json_loads(r.get("notification_config"), {}) + out.append( + { + **r, + "exchange_config": ex, + "indicator_config": ind, + "trading_config": tr, + "ai_model_config": ai, + "notification_config": notify, + } + ) return out except Exception as e: logger.error(f"list_strategies failed: {e}") @@ -661,41 +688,45 @@ class StrategyService: with get_db_connection() as db: cur = db.cursor() if user_id is not None: - cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ? AND user_id = ?", (strategy_id, user_id)) + cur.execute( + "SELECT * FROM qd_strategies_trading WHERE id = ? AND user_id = ?", (strategy_id, user_id) + ) else: cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) r = cur.fetchone() cur.close() if not r: return None - r['exchange_config'] = self._safe_json_loads(r.get('exchange_config'), {}) - r['indicator_config'] = self._safe_json_loads(r.get('indicator_config'), {}) - r['trading_config'] = self._safe_json_loads(r.get('trading_config'), {}) - r['ai_model_config'] = self._safe_json_loads(r.get('ai_model_config'), {}) - r['notification_config'] = self._safe_json_loads(r.get('notification_config'), {}) + r["exchange_config"] = self._safe_json_loads(r.get("exchange_config"), {}) + r["indicator_config"] = self._safe_json_loads(r.get("indicator_config"), {}) + r["trading_config"] = self._safe_json_loads(r.get("trading_config"), {}) + r["ai_model_config"] = self._safe_json_loads(r.get("ai_model_config"), {}) + r["notification_config"] = self._safe_json_loads(r.get("notification_config"), {}) return r except Exception as e: logger.error(f"get_strategy failed: {e}") return None def create_strategy(self, payload: Dict[str, Any]) -> int: - name = (payload.get('strategy_name') or '').strip() + name = (payload.get("strategy_name") or "").strip() if not name: raise ValueError("strategy_name is required") - user_id = payload.get('user_id') or 1 - strategy_type = payload.get('strategy_type') or 'IndicatorStrategy' - market_category = payload.get('market_category') or 'Crypto' - execution_mode = payload.get('execution_mode') or 'signal' - notification_config = payload.get('notification_config') or {} + user_id = payload.get("user_id") or 1 + strategy_type = payload.get("strategy_type") or "IndicatorStrategy" + market_category = payload.get("market_category") or "Crypto" + execution_mode = payload.get("execution_mode") or "signal" + notification_config = payload.get("notification_config") or {} - indicator_config = payload.get('indicator_config') or {} - trading_config = payload.get('trading_config') or {} - exchange_config = payload.get('exchange_config') or {} + indicator_config = payload.get("indicator_config") or {} + trading_config = payload.get("trading_config") or {} + exchange_config = payload.get("exchange_config") or {} # Validate MT5 can only be used for Forex trading - exchange_id = (exchange_config.get('exchange_id') or '').strip().lower() if isinstance(exchange_config, dict) else '' - if exchange_id == 'mt5' and market_category != 'Forex': + exchange_id = ( + (exchange_config.get("exchange_id") or "").strip().lower() if isinstance(exchange_config, dict) else "" + ) + if exchange_id == "mt5" and market_category != "Forex": raise ValueError( f"MT5 can only be used for Forex trading, but market_category is '{market_category}'. " f"MT5 does not support Crypto or Stock trading. Please use MT5 only with Forex market." @@ -703,38 +734,38 @@ class StrategyService: # When credential_id is present, strip raw API keys to avoid # storing secrets in the strategy record — they live in qd_exchange_credentials. - if isinstance(exchange_config, dict) and exchange_config.get('credential_id'): - for _secret_key in ('api_key', 'secret_key', 'passphrase', 'apiKey', 'secret', 'password'): + if isinstance(exchange_config, dict) and exchange_config.get("credential_id"): + for _secret_key in ("api_key", "secret_key", "passphrase", "apiKey", "secret", "password"): exchange_config.pop(_secret_key, None) # Strategy group fields - strategy_group_id = payload.get('strategy_group_id') or '' - group_base_name = payload.get('group_base_name') or '' + strategy_group_id = payload.get("strategy_group_id") or "" + group_base_name = payload.get("group_base_name") or "" # Denormalized fields for quick list rendering - symbol = (trading_config or {}).get('symbol') - timeframe = (trading_config or {}).get('timeframe') - initial_capital = (trading_config or {}).get('initial_capital') or payload.get('initial_capital') or 1000 - leverage = (trading_config or {}).get('leverage') or 1 - market_type = (trading_config or {}).get('market_type') or 'swap' - - # Cross-sectional strategy fields (store in trading_config to avoid DB schema changes) - cs_strategy_type = payload.get('cs_strategy_type') or trading_config.get('cs_strategy_type') or 'single' - symbol_list = payload.get('symbol_list') or trading_config.get('symbol_list') or [] - portfolio_size = payload.get('portfolio_size') or trading_config.get('portfolio_size') or 10 - long_ratio = float(payload.get('long_ratio') or trading_config.get('long_ratio') or 0.5) - rebalance_frequency = payload.get('rebalance_frequency') or trading_config.get('rebalance_frequency') or 'daily' - - # Store cross-sectional config in trading_config - if cs_strategy_type == 'cross_sectional': - trading_config['cs_strategy_type'] = cs_strategy_type - trading_config['symbol_list'] = symbol_list - trading_config['portfolio_size'] = portfolio_size - trading_config['long_ratio'] = long_ratio - trading_config['rebalance_frequency'] = rebalance_frequency + symbol = (trading_config or {}).get("symbol") + timeframe = (trading_config or {}).get("timeframe") + initial_capital = (trading_config or {}).get("initial_capital") or payload.get("initial_capital") or 1000 + leverage = (trading_config or {}).get("leverage") or 1 + market_type = (trading_config or {}).get("market_type") or "swap" - strategy_mode = payload.get('strategy_mode') or 'signal' - strategy_code = payload.get('strategy_code') or '' + # Cross-sectional strategy fields (store in trading_config to avoid DB schema changes) + cs_strategy_type = payload.get("cs_strategy_type") or trading_config.get("cs_strategy_type") or "single" + symbol_list = payload.get("symbol_list") or trading_config.get("symbol_list") or [] + portfolio_size = payload.get("portfolio_size") or trading_config.get("portfolio_size") or 10 + long_ratio = float(payload.get("long_ratio") or trading_config.get("long_ratio") or 0.5) + rebalance_frequency = payload.get("rebalance_frequency") or trading_config.get("rebalance_frequency") or "daily" + + # Store cross-sectional config in trading_config + if cs_strategy_type == "cross_sectional": + trading_config["cs_strategy_type"] = cs_strategy_type + trading_config["symbol_list"] = symbol_list + trading_config["portfolio_size"] = portfolio_size + trading_config["long_ratio"] = long_ratio + trading_config["rebalance_frequency"] = rebalance_frequency + + strategy_mode = payload.get("strategy_mode") or "signal" + strategy_code = payload.get("strategy_code") or "" with get_db_connection() as db: cur = db.cursor() @@ -755,22 +786,22 @@ class StrategyService: market_category, execution_mode, self._dump_json_or_encrypt(notification_config, encrypt=False), - payload.get('status') or 'stopped', + payload.get("status") or "stopped", symbol, timeframe, float(initial_capital or 1000), int(leverage or 1), market_type, - self._dump_json_or_encrypt(exchange_config, encrypt=False) if exchange_config else '', + self._dump_json_or_encrypt(exchange_config, encrypt=False) if exchange_config else "", self._dump_json_or_encrypt(indicator_config, encrypt=False), self._dump_json_or_encrypt(trading_config, encrypt=False), - self._dump_json_or_encrypt(payload.get('ai_model_config') or {}, encrypt=False), - int(payload.get('decide_interval') or 300), + self._dump_json_or_encrypt(payload.get("ai_model_config") or {}, encrypt=False), + int(payload.get("decide_interval") or 300), strategy_group_id, group_base_name, strategy_mode, - strategy_code - ) + strategy_code, + ), ) new_id = cur.lastrowid db.commit() @@ -780,10 +811,10 @@ class StrategyService: def batch_create_strategies(self, payload: Dict[str, Any]) -> Dict[str, Any]: """ Batch create strategies (multi-symbol) - + Args: payload: Contains symbols (array) and other strategy config - + Returns: { 'success': True/False, @@ -792,128 +823,118 @@ class StrategyService: 'failed_symbols': [] } """ - symbols = payload.get('symbols') or [] + symbols = payload.get("symbols") or [] if not symbols or not isinstance(symbols, list): raise ValueError("symbols array is required") - - base_name = (payload.get('strategy_name') or '').strip() + + base_name = (payload.get("strategy_name") or "").strip() if not base_name: raise ValueError("strategy_name is required") - + # Validate MT5 can only be used for Forex trading - market_category = payload.get('market_category') or 'Crypto' - exchange_config = payload.get('exchange_config') or {} - exchange_id = (exchange_config.get('exchange_id') or '').strip().lower() if isinstance(exchange_config, dict) else '' - if exchange_id == 'mt5' and market_category != 'Forex': + market_category = payload.get("market_category") or "Crypto" + exchange_config = payload.get("exchange_config") or {} + exchange_id = ( + (exchange_config.get("exchange_id") or "").strip().lower() if isinstance(exchange_config, dict) else "" + ) + if exchange_id == "mt5" and market_category != "Forex": raise ValueError( f"MT5 can only be used for Forex trading, but market_category is '{market_category}'. " f"MT5 does not support Crypto or Stock trading. Please use MT5 only with Forex market." ) - + # Generate strategy group ID strategy_group_id = str(uuid.uuid4())[:8] - + created_ids = [] failed_symbols = [] - + for symbol in symbols: try: # Create individual strategy for each symbol single_payload = dict(payload) - + # Parse symbol (may be "Market:SYMBOL" format) - if isinstance(symbol, str) and ':' in symbol: - parts = symbol.split(':', 1) + if isinstance(symbol, str) and ":" in symbol: + parts = symbol.split(":", 1) market_category = parts[0] symbol_name = parts[1] else: - market_category = payload.get('market_category') or 'Crypto' + market_category = payload.get("market_category") or "Crypto" symbol_name = symbol - + # Strategy name with symbol suffix - single_payload['strategy_name'] = f"{base_name}-{symbol_name}" - single_payload['strategy_group_id'] = strategy_group_id - single_payload['group_base_name'] = base_name - single_payload['market_category'] = market_category - + single_payload["strategy_name"] = f"{base_name}-{symbol_name}" + single_payload["strategy_group_id"] = strategy_group_id + single_payload["group_base_name"] = base_name + single_payload["market_category"] = market_category + # Update symbol in trading_config - trading_config = dict(single_payload.get('trading_config') or {}) - trading_config['symbol'] = symbol_name - single_payload['trading_config'] = trading_config - + trading_config = dict(single_payload.get("trading_config") or {}) + trading_config["symbol"] = symbol_name + single_payload["trading_config"] = trading_config + new_id = self.create_strategy(single_payload) created_ids.append(new_id) - + except Exception as e: logger.error(f"Failed to create strategy for symbol {symbol}: {e}") - failed_symbols.append({'symbol': symbol, 'error': str(e)}) - + failed_symbols.append({"symbol": symbol, "error": str(e)}) + return { - 'success': len(created_ids) > 0, - 'strategy_group_id': strategy_group_id, - 'group_base_name': base_name, - 'created_ids': created_ids, - 'failed_symbols': failed_symbols, - 'total_created': len(created_ids), - 'total_failed': len(failed_symbols) + "success": len(created_ids) > 0, + "strategy_group_id": strategy_group_id, + "group_base_name": base_name, + "created_ids": created_ids, + "failed_symbols": failed_symbols, + "total_created": len(created_ids), + "total_failed": len(failed_symbols), } def batch_start_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]: """Batch start strategies. If user_id is provided, verify ownership.""" success_ids = [] failed_ids = [] - + for sid in strategy_ids: try: - self.update_strategy_status(sid, 'running', user_id=user_id) + self.update_strategy_status(sid, "running", user_id=user_id) success_ids.append(sid) except Exception as e: logger.error(f"Failed to start strategy {sid}: {e}") - failed_ids.append({'id': sid, 'error': str(e)}) - - return { - 'success': len(success_ids) > 0, - 'success_ids': success_ids, - 'failed_ids': failed_ids - } + failed_ids.append({"id": sid, "error": str(e)}) + + return {"success": len(success_ids) > 0, "success_ids": success_ids, "failed_ids": failed_ids} def batch_stop_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]: """Batch stop strategies. If user_id is provided, verify ownership.""" success_ids = [] failed_ids = [] - + for sid in strategy_ids: try: - self.update_strategy_status(sid, 'stopped', user_id=user_id) + self.update_strategy_status(sid, "stopped", user_id=user_id) success_ids.append(sid) except Exception as e: logger.error(f"Failed to stop strategy {sid}: {e}") - failed_ids.append({'id': sid, 'error': str(e)}) - - return { - 'success': len(success_ids) > 0, - 'success_ids': success_ids, - 'failed_ids': failed_ids - } + failed_ids.append({"id": sid, "error": str(e)}) + + return {"success": len(success_ids) > 0, "success_ids": success_ids, "failed_ids": failed_ids} def batch_delete_strategies(self, strategy_ids: List[int], user_id: int = None) -> Dict[str, Any]: """Batch delete strategies. If user_id is provided, verify ownership.""" success_ids = [] failed_ids = [] - + for sid in strategy_ids: try: self.delete_strategy(sid, user_id=user_id) success_ids.append(sid) except Exception as e: logger.error(f"Failed to delete strategy {sid}: {e}") - failed_ids.append({'id': sid, 'error': str(e)}) - - return { - 'success': len(success_ids) > 0, - 'success_ids': success_ids, - 'failed_ids': failed_ids - } + failed_ids.append({"id": sid, "error": str(e)}) + + return {"success": len(success_ids) > 0, "success_ids": success_ids, "failed_ids": failed_ids} def get_strategies_by_group(self, strategy_group_id: str, user_id: int = None) -> List[Dict[str, Any]]: """Get all strategies in a group. If user_id is provided, filter by user.""" @@ -923,16 +944,15 @@ class StrategyService: if user_id is not None: cur.execute( "SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ? AND user_id = ?", - (strategy_group_id, user_id) + (strategy_group_id, user_id), ) else: cur.execute( - "SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ?", - (strategy_group_id,) + "SELECT id FROM qd_strategies_trading WHERE strategy_group_id = ?", (strategy_group_id,) ) rows = cur.fetchall() or [] cur.close() - return [row['id'] for row in rows] + return [row["id"] for row in rows] except Exception as e: logger.error(f"get_strategies_by_group failed: {e}") return [] @@ -943,50 +963,59 @@ class StrategyService: return False # Merge: allow partial updates - name = (payload.get('strategy_name') or existing.get('strategy_name') or '').strip() - market_category = payload.get('market_category') or existing.get('market_category') or 'Crypto' - execution_mode = payload.get('execution_mode') or existing.get('execution_mode') or 'signal' - notification_config = payload.get('notification_config') if payload.get('notification_config') is not None else (existing.get('notification_config') or {}) + name = (payload.get("strategy_name") or existing.get("strategy_name") or "").strip() + market_category = payload.get("market_category") or existing.get("market_category") or "Crypto" + execution_mode = payload.get("execution_mode") or existing.get("execution_mode") or "signal" + notification_config = ( + payload.get("notification_config") + if payload.get("notification_config") is not None + else (existing.get("notification_config") or {}) + ) - indicator_config = payload.get('indicator_config') if payload.get('indicator_config') is not None else (existing.get('indicator_config') or {}) - trading_config = payload.get('trading_config') if payload.get('trading_config') is not None else (existing.get('trading_config') or {}) - exchange_config = payload.get('exchange_config') if payload.get('exchange_config') is not None else (existing.get('exchange_config') or {}) - ai_model_config = payload.get('ai_model_config') if payload.get('ai_model_config') is not None else (existing.get('ai_model_config') or {}) - strategy_mode = payload.get('strategy_mode') if payload.get('strategy_mode') is not None else (existing.get('strategy_mode') or 'signal') - strategy_code = payload.get('strategy_code') if payload.get('strategy_code') is not None else (existing.get('strategy_code') or '') + indicator_config = ( + payload.get("indicator_config") + if payload.get("indicator_config") is not None + else (existing.get("indicator_config") or {}) + ) + trading_config = ( + payload.get("trading_config") + if payload.get("trading_config") is not None + else (existing.get("trading_config") or {}) + ) + exchange_config = ( + payload.get("exchange_config") + if payload.get("exchange_config") is not None + else (existing.get("exchange_config") or {}) + ) + ai_model_config = ( + payload.get("ai_model_config") + if payload.get("ai_model_config") is not None + else (existing.get("ai_model_config") or {}) + ) # When credential_id is present, strip raw API keys to avoid # storing secrets in the strategy record — they live in qd_exchange_credentials. - if isinstance(exchange_config, dict) and exchange_config.get('credential_id'): - for _secret_key in ('api_key', 'secret_key', 'passphrase', 'apiKey', 'secret', 'password'): + if isinstance(exchange_config, dict) and exchange_config.get("credential_id"): + for _secret_key in ("api_key", "secret_key", "passphrase", "apiKey", "secret", "password"): exchange_config.pop(_secret_key, None) # Handle cross-sectional strategy config updates - if payload.get('cs_strategy_type') is not None: - trading_config['cs_strategy_type'] = payload.get('cs_strategy_type') - if payload.get('symbol_list') is not None: - trading_config['symbol_list'] = payload.get('symbol_list') - if payload.get('portfolio_size') is not None: - trading_config['portfolio_size'] = payload.get('portfolio_size') - if payload.get('long_ratio') is not None: - trading_config['long_ratio'] = payload.get('long_ratio') - if payload.get('rebalance_frequency') is not None: - trading_config['rebalance_frequency'] = payload.get('rebalance_frequency') + if payload.get("cs_strategy_type") is not None: + trading_config["cs_strategy_type"] = payload.get("cs_strategy_type") + if payload.get("symbol_list") is not None: + trading_config["symbol_list"] = payload.get("symbol_list") + if payload.get("portfolio_size") is not None: + trading_config["portfolio_size"] = payload.get("portfolio_size") + if payload.get("long_ratio") is not None: + trading_config["long_ratio"] = payload.get("long_ratio") + if payload.get("rebalance_frequency") is not None: + trading_config["rebalance_frequency"] = payload.get("rebalance_frequency") - symbol = (trading_config or {}).get('symbol') - timeframe = (trading_config or {}).get('timeframe') - initial_capital = (trading_config or {}).get('initial_capital') or existing.get('initial_capital') or 1000 - leverage = (trading_config or {}).get('leverage') or existing.get('leverage') or 1 - market_type = (trading_config or {}).get('market_type') or existing.get('market_type') or 'swap' - - strategy_type = (payload.get('strategy_type') if payload.get('strategy_type') is not None - else existing.get('strategy_type')) or 'IndicatorStrategy' - strategy_mode = (payload.get('strategy_mode') if payload.get('strategy_mode') is not None - else existing.get('strategy_mode')) or 'signal' - if 'strategy_code' in payload: - strategy_code = payload.get('strategy_code') or '' - else: - strategy_code = existing.get('strategy_code') or '' + symbol = (trading_config or {}).get("symbol") + timeframe = (trading_config or {}).get("timeframe") + initial_capital = (trading_config or {}).get("initial_capital") or existing.get("initial_capital") or 1000 + leverage = (trading_config or {}).get("leverage") or existing.get("leverage") or 1 + market_type = (trading_config or {}).get("market_type") or existing.get("market_type") or "swap" with get_db_connection() as db: cur = db.cursor() @@ -1022,12 +1051,12 @@ class StrategyService: float(initial_capital or 1000), int(leverage or 1), market_type, - self._dump_json_or_encrypt(exchange_config, encrypt=False) if exchange_config else '', + self._dump_json_or_encrypt(exchange_config, encrypt=False) if exchange_config else "", self._dump_json_or_encrypt(indicator_config, encrypt=False), self._dump_json_or_encrypt(trading_config, encrypt=False), self._dump_json_or_encrypt(ai_model_config, encrypt=False), - strategy_id - ) + strategy_id, + ), ) db.commit() cur.close() @@ -1039,7 +1068,9 @@ class StrategyService: with get_db_connection() as db: cur = db.cursor() if user_id is not None: - cur.execute("DELETE FROM qd_strategies_trading WHERE id = ? AND user_id = ?", (strategy_id, user_id)) + cur.execute( + "DELETE FROM qd_strategies_trading WHERE id = ? AND user_id = ?", (strategy_id, user_id) + ) else: cur.execute("DELETE FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) db.commit() diff --git a/backend_api_python/app/services/strategy_compiler.py b/backend_api_python/app/services/strategy_compiler.py index 61d6bd9..5fbe467 100644 --- a/backend_api_python/app/services/strategy_compiler.py +++ b/backend_api_python/app/services/strategy_compiler.py @@ -1,41 +1,41 @@ -import json -from typing import Dict, Any, List +from typing import Any, Dict + class StrategyCompiler: def compile(self, config: Dict[str, Any]) -> str: """ Compiles the strategy configuration JSON into executable Python code. """ - + # Extract configurations - name = config.get('name', 'Generated Strategy') - entry_rules = config.get('entry_rules', []) - position_config = config.get('position_config', {}) - pyramiding_rules = config.get('pyramiding_rules', {}) - risk_management = config.get('risk_management', {}) - + name = config.get("name", "Generated Strategy") + entry_rules = config.get("entry_rules", []) + position_config = config.get("position_config", {}) + pyramiding_rules = config.get("pyramiding_rules", {}) + risk_management = config.get("risk_management", {}) + # 1. Imports and Setup code = self._get_header(name) - + # 2. Parameters (Variables) code += self._get_parameters(position_config, pyramiding_rules, risk_management) - + # 3. Indicators Calculation code += self._get_indicators_calculation(entry_rules) - + # 4. Signal Logic (Entry Conditions) code += self._get_entry_logic(entry_rules) - + # 5. Core Loop (Position Management) - Based on code2.py code += self._get_core_loop(position_config, pyramiding_rules, risk_management) - + # 6. Output Formatting code += self._get_output_section(name, entry_rules) - + return code def _get_header(self, name): - return f''' + return f""" # Generated Strategy: {name} import pandas as pd import numpy as np @@ -44,28 +44,27 @@ import numpy as np def get_val(arr, i, default=0): if i < 0 or i >= len(arr): return default return arr[i] -''' +""" def _get_parameters(self, pos_config, pyr_rules, risk_mgmt): # Default values - initial_size = pos_config.get('initial_size_pct', 10) / 100.0 - leverage = pos_config.get('leverage', 1) - max_pyramiding = pos_config.get('max_pyramiding', 0) - - pyr_enabled = pyr_rules.get('enabled', False) - add_size = pyr_rules.get('size_pct', 0) / 100.0 if pyr_enabled else 0 - add_threshold = pyr_rules.get('value', 0) / 100.0 - - stop_loss = risk_mgmt.get('stop_loss', {}) - sl_enabled = stop_loss.get('enabled', False) - sl_pct = stop_loss.get('value', 0) / 100.0 if sl_enabled else 0.0 - - trailing = risk_mgmt.get('trailing_stop', {}) - ts_enabled = trailing.get('enabled', False) - ts_activation = trailing.get('activation_profit', 0) / 100.0 - ts_callback = trailing.get('callback_pct', 0) / 100.0 - - return f''' + initial_size = pos_config.get("initial_size_pct", 10) / 100.0 + leverage = pos_config.get("leverage", 1) + max_pyramiding = pos_config.get("max_pyramiding", 0) + + pyr_enabled = pyr_rules.get("enabled", False) + add_size = pyr_rules.get("size_pct", 0) / 100.0 if pyr_enabled else 0 + add_threshold = pyr_rules.get("value", 0) / 100.0 + + stop_loss = risk_mgmt.get("stop_loss", {}) + sl_enabled = stop_loss.get("enabled", False) + sl_pct = stop_loss.get("value", 0) / 100.0 if sl_enabled else 0.0 + + trailing = risk_mgmt.get("trailing_stop", {}) + ts_activation = trailing.get("activation_profit", 0) / 100.0 + ts_callback = trailing.get("callback_pct", 0) / 100.0 + + return f""" # =========================== # 1. Parameters # =========================== @@ -75,13 +74,13 @@ max_pyramiding = {max_pyramiding} # Pyramiding add_position_pct = {add_size} -add_threshold_pct = {add_threshold} +add_threshold_pct = {add_threshold} # Risk Management stop_loss_pct = {sl_pct} take_profit_activation = {ts_activation} trailing_callback = {ts_callback} -''' +""" def _get_indicators_calculation(self, rules): code = """ @@ -90,18 +89,18 @@ trailing_callback = {ts_callback} # =========================== """ calculated = set() - + for rule in rules: - ind = rule.get('indicator') - params = rule.get('params', {}) - - if ind == 'supertrend': + ind = rule.get("indicator") + params = rule.get("params", {}) + + if ind == "supertrend": key = f"st_{params.get('period')}_{params.get('multiplier')}" if key not in calculated: code += f""" -# SuperTrend ({params.get('period')}, {params.get('multiplier')}) -period = {params.get('period', 14)} -multiplier = {params.get('multiplier', 3.0)} +# SuperTrend ({params.get("period")}, {params.get("multiplier")}) +period = {params.get("period", 14)} +multiplier = {params.get("multiplier", 3.0)} df['hl2'] = (df['high'] + df['low']) / 2 df['tr'] = np.maximum(df['high'] - df['low'], np.maximum(abs(df['high'] - df['close'].shift(1)), abs(df['low'] - df['close'].shift(1)))) df['atr'] = df['tr'].ewm(alpha=1/period, adjust=False).mean() @@ -120,12 +119,12 @@ for i in range(1, len(df)): final_upper[i] = basic_upper[i] else: final_upper[i] = final_upper[i-1] - + if basic_lower[i] > final_lower[i-1] or close_arr[i-1] < final_lower[i-1]: final_lower[i] = basic_lower[i] else: final_lower[i] = final_lower[i-1] - + prev_trend = trend[i-1] if prev_trend == -1 and close_arr[i] > final_upper[i-1]: trend[i] = 1 @@ -140,15 +139,15 @@ df['st_lower'] = final_lower """ calculated.add(key) - elif ind == 'ema': - period = params.get('period', 20) + elif ind == "ema": + period = params.get("period", 20) key = f"ema_{period}" if key not in calculated: code += f"\ndf['ema_{period}'] = df['close'].ewm(span={period}, adjust=False).mean()\n" calculated.add(key) - elif ind == 'rsi': - period = params.get('period', 14) + elif ind == "rsi": + period = params.get("period", 14) key = f"rsi_{period}" if key not in calculated: code += f""" @@ -161,10 +160,10 @@ df['rsi_{period}'] = 100 - (100 / (1 + rs)) """ calculated.add(key) - elif ind == 'macd': - fast = params.get('fast_period', 12) - slow = params.get('slow_period', 26) - signal = params.get('signal_period', 9) + elif ind == "macd": + fast = params.get("fast_period", 12) + slow = params.get("slow_period", 26) + signal = params.get("signal_period", 9) key = f"macd_{fast}_{slow}_{signal}" if key not in calculated: code += f""" @@ -177,9 +176,9 @@ df['macd_{fast}_{slow}_{signal}_hist'] = df['macd_{fast}_{slow}_{signal}_line'] """ calculated.add(key) - elif ind == 'bollinger': - period = params.get('period', 20) - std_dev = params.get('std_dev', 2.0) + elif ind == "bollinger": + period = params.get("period", 20) + std_dev = params.get("std_dev", 2.0) key = f"bb_{period}_{std_dev}" if key not in calculated: code += f""" @@ -192,9 +191,9 @@ df['bb_{period}_{std_dev}_mid'] = sma """ calculated.add(key) - elif ind == 'kdj': - period = params.get('period', 9) - signal_period = params.get('signal_period', 3) + elif ind == "kdj": + period = params.get("period", 9) + signal_period = params.get("signal_period", 3) key = f"kdj_{period}_{signal_period}" if key not in calculated: code += f""" @@ -208,15 +207,15 @@ df['kdj_{period}_{signal_period}_j'] = 3 * df['kdj_{period}_{signal_period}_k'] """ calculated.add(key) - elif ind == 'ma': - period = params.get('period', 20) - ma_type = params.get('ma_type', 'sma') + elif ind == "ma": + period = params.get("period", 20) + ma_type = params.get("ma_type", "sma") key = f"ma_{ma_type}_{period}" if key not in calculated: - if ma_type == 'ema': - code += f"\ndf['ma_{ma_type}_{period}'] = df['close'].ewm(span={period}, adjust=False).mean()\n" + if ma_type == "ema": + code += f"\ndf['ma_{ma_type}_{period}'] = df['close'].ewm(span={period}, adjust=False).mean()\n" else: - code += f"\ndf['ma_{ma_type}_{period}'] = df['close'].rolling(window={period}).mean()\n" + code += f"\ndf['ma_{ma_type}_{period}'] = df['close'].rolling(window={period}).mean()\n" calculated.add(key) return code @@ -232,139 +231,139 @@ df['raw_sell'] = False """ conditions_buy = [] conditions_sell = [] - + for rule in rules: - ind = rule.get('indicator') - params = rule.get('params', {}) - - if ind == 'supertrend': - signal = rule.get('signal', 'trend_bullish') - if signal == 'trend_bullish': + ind = rule.get("indicator") + params = rule.get("params", {}) + + if ind == "supertrend": + signal = rule.get("signal", "trend_bullish") + if signal == "trend_bullish": conditions_buy.append("(df['st_trend'] == 1) & (df['st_trend'].shift(1) == -1)") conditions_sell.append("(df['st_trend'] == -1) & (df['st_trend'].shift(1) == 1)") - elif signal == 'is_uptrend': + elif signal == "is_uptrend": conditions_buy.append("(df['st_trend'] == 1)") conditions_sell.append("(df['st_trend'] == -1)") - - elif ind == 'ema': - period = params.get('period', 20) - operator = rule.get('operator', 'price_above') + + elif ind == "ema": + period = params.get("period", 20) + operator = rule.get("operator", "price_above") col = f"df['ema_{period}']" - if operator == 'price_above': + if operator == "price_above": conditions_buy.append(f"(df['close'] > {col})") conditions_sell.append(f"(df['close'] < {col})") - elif operator == 'price_below': + elif operator == "price_below": conditions_buy.append(f"(df['close'] < {col})") conditions_sell.append(f"(df['close'] > {col})") - elif operator == 'cross_up': + elif operator == "cross_up": conditions_buy.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))") conditions_sell.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))") - elif operator == 'cross_down': + elif operator == "cross_down": conditions_buy.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))") conditions_sell.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))") - elif ind == 'rsi': - period = params.get('period', 14) - operator = rule.get('operator', '<') - thresh = params.get('threshold', 30) + elif ind == "rsi": + period = params.get("period", 14) + operator = rule.get("operator", "<") + thresh = params.get("threshold", 30) col = f"df['rsi_{period}']" - if operator == '<': + if operator == "<": conditions_buy.append(f"({col} < {thresh})") - conditions_sell.append(f"({col} > {100-thresh})") - elif operator == '>': + conditions_sell.append(f"({col} > {100 - thresh})") + elif operator == ">": conditions_buy.append(f"({col} > {thresh})") - conditions_sell.append(f"({col} < {100-thresh})") - elif operator == 'cross_up': + conditions_sell.append(f"({col} < {100 - thresh})") + elif operator == "cross_up": conditions_buy.append(f"({col} > {thresh}) & ({col}.shift(1) <= {thresh})") - conditions_sell.append(f"({col} < {100-thresh}) & ({col}.shift(1) >= {100-thresh})") - elif operator == 'cross_down': + conditions_sell.append(f"({col} < {100 - thresh}) & ({col}.shift(1) >= {100 - thresh})") + elif operator == "cross_down": conditions_buy.append(f"({col} < {thresh}) & ({col}.shift(1) >= {thresh})") - conditions_sell.append(f"({col} > {100-thresh}) & ({col}.shift(1) <= {100-thresh})") + conditions_sell.append(f"({col} > {100 - thresh}) & ({col}.shift(1) <= {100 - thresh})") - elif ind == 'macd': - fast = params.get('fast_period', 12) - slow = params.get('slow_period', 26) - signal = params.get('signal_period', 9) - operator = rule.get('operator', 'diff_gt_dea') + elif ind == "macd": + fast = params.get("fast_period", 12) + slow = params.get("slow_period", 26) + signal = params.get("signal_period", 9) + operator = rule.get("operator", "diff_gt_dea") line_col = f"df['macd_{fast}_{slow}_{signal}_line']" sig_col = f"df['macd_{fast}_{slow}_{signal}_signal']" - - if operator == 'diff_gt_dea': + + if operator == "diff_gt_dea": conditions_buy.append(f"({line_col} > {sig_col})") conditions_sell.append(f"({line_col} < {sig_col})") - elif operator == 'diff_lt_dea': + elif operator == "diff_lt_dea": conditions_buy.append(f"({line_col} < {sig_col})") conditions_sell.append(f"({line_col} > {sig_col})") - elif operator == 'cross_up': + elif operator == "cross_up": conditions_buy.append(f"({line_col} > {sig_col}) & ({line_col}.shift(1) <= {sig_col}.shift(1))") conditions_sell.append(f"({line_col} < {sig_col}) & ({line_col}.shift(1) >= {sig_col}.shift(1))") - elif operator == 'cross_down': + elif operator == "cross_down": conditions_buy.append(f"({line_col} < {sig_col}) & ({line_col}.shift(1) >= {sig_col}.shift(1))") conditions_sell.append(f"({line_col} > {sig_col}) & ({line_col}.shift(1) <= {sig_col}.shift(1))") - elif ind == 'bollinger': - period = params.get('period', 20) - std_dev = params.get('std_dev', 2.0) - operator = rule.get('operator', 'price_above_upper') + elif ind == "bollinger": + period = params.get("period", 20) + std_dev = params.get("std_dev", 2.0) + operator = rule.get("operator", "price_above_upper") upper = f"df['bb_{period}_{std_dev}_upper']" lower = f"df['bb_{period}_{std_dev}_lower']" mid = f"df['bb_{period}_{std_dev}_mid']" - - if operator == 'price_above_upper': + + if operator == "price_above_upper": conditions_buy.append(f"(df['close'] > {upper})") conditions_sell.append(f"(df['close'] < {lower})") - elif operator == 'price_below_lower': + elif operator == "price_below_lower": conditions_buy.append(f"(df['close'] < {lower})") conditions_sell.append(f"(df['close'] > {upper})") - elif operator == 'price_above_mid': + elif operator == "price_above_mid": conditions_buy.append(f"(df['close'] > {mid})") conditions_sell.append(f"(df['close'] < {mid})") - elif operator == 'price_below_mid': + elif operator == "price_below_mid": conditions_buy.append(f"(df['close'] < {mid})") conditions_sell.append(f"(df['close'] > {mid})") - elif operator == 'cross_up_lower': + elif operator == "cross_up_lower": conditions_buy.append(f"(df['close'] > {lower}) & (df['close'].shift(1) <= {lower}.shift(1))") conditions_sell.append(f"(df['close'] < {upper}) & (df['close'].shift(1) >= {upper}.shift(1))") - elif operator == 'cross_down_upper': + elif operator == "cross_down_upper": conditions_buy.append(f"(df['close'] < {upper}) & (df['close'].shift(1) >= {upper}.shift(1))") conditions_sell.append(f"(df['close'] > {lower}) & (df['close'].shift(1) <= {lower}.shift(1))") - elif ind == 'kdj': - period = params.get('period', 9) - signal_period = params.get('signal_period', 3) - operator = rule.get('operator', 'k_gt_d') + elif ind == "kdj": + period = params.get("period", 9) + signal_period = params.get("signal_period", 3) + operator = rule.get("operator", "k_gt_d") k_col = f"df['kdj_{period}_{signal_period}_k']" d_col = f"df['kdj_{period}_{signal_period}_d']" - - if operator == 'k_gt_d': + + if operator == "k_gt_d": conditions_buy.append(f"({k_col} > {d_col})") conditions_sell.append(f"({k_col} < {d_col})") - elif operator == 'k_lt_d': + elif operator == "k_lt_d": conditions_buy.append(f"({k_col} < {d_col})") conditions_sell.append(f"({k_col} > {d_col})") - elif operator == 'gold_cross': + elif operator == "gold_cross": conditions_buy.append(f"({k_col} > {d_col}) & ({k_col}.shift(1) <= {d_col}.shift(1))") conditions_sell.append(f"({k_col} < {d_col}) & ({k_col}.shift(1) >= {d_col}.shift(1))") - elif operator == 'death_cross': + elif operator == "death_cross": conditions_buy.append(f"({k_col} < {d_col}) & ({k_col}.shift(1) >= {d_col}.shift(1))") conditions_sell.append(f"({k_col} > {d_col}) & ({k_col}.shift(1) <= {d_col}.shift(1))") - elif ind == 'ma': - period = params.get('period', 20) - ma_type = params.get('ma_type', 'sma') - operator = rule.get('operator', 'price_above') + elif ind == "ma": + period = params.get("period", 20) + ma_type = params.get("ma_type", "sma") + operator = rule.get("operator", "price_above") col = f"df['ma_{ma_type}_{period}']" - - if operator == 'price_above': + + if operator == "price_above": conditions_buy.append(f"(df['close'] > {col})") conditions_sell.append(f"(df['close'] < {col})") - elif operator == 'price_below': + elif operator == "price_below": conditions_buy.append(f"(df['close'] < {col})") conditions_sell.append(f"(df['close'] > {col})") - elif operator == 'cross_up': + elif operator == "cross_up": conditions_buy.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))") conditions_sell.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))") - elif operator == 'cross_down': + elif operator == "cross_down": conditions_buy.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))") conditions_sell.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))") @@ -372,7 +371,7 @@ df['raw_sell'] = False code += f"\ndf['raw_buy'] = {' & '.join(conditions_buy)}\n" if conditions_sell: code += f"\ndf['raw_sell'] = {' & '.join(conditions_sell)}\n" - + return code def _get_core_loop(self, pos_config, pyr_rules, risk_mgmt): @@ -418,15 +417,15 @@ for i in range(len(df)): current_close = close_arr[i] current_high = high_arr[i] current_low = low_arr[i] - + if position == 1: # Long Position if current_high > highest_price: highest_price = current_high - + profit_pct = (highest_price - avg_entry_price) / avg_entry_price current_profit_pct = (current_close - avg_entry_price) / avg_entry_price - + # 1. Trailing Stop if take_profit_activation > 0 and profit_pct >= take_profit_activation: drawdown = (highest_price - current_close) / avg_entry_price @@ -437,7 +436,7 @@ for i in range(len(df)): position = 0 position_count = 0 continue - + # 2. Stop Loss if stop_loss_pct > 0: loss_pct = (avg_entry_price - current_low) / avg_entry_price @@ -448,7 +447,7 @@ for i in range(len(df)): position = 0 position_count = 0 continue - + # 3. Signal Exit (if enabled) # Note: Code2 uses raw_sell_arr for exit if raw_sell_arr[i]: @@ -457,11 +456,11 @@ for i in range(len(df)): close_long_text[i] = "Signal Exit" position = 0 position_count = 0 - + # Reverse to Short if trade_direction allows (simplified here) # For now we just close. continue - + # 4. Pyramiding (Add Long) if max_pyramiding > 0 and position_count < max_pyramiding + 1 and current_profit_pct > 0: # Condition: Price rise by threshold @@ -473,18 +472,18 @@ for i in range(len(df)): add_long_text[i] = "Add Long" position_count += 1 last_add_price = target_price - + elif position == -1: # Short Position # For Short, highest_price tracks the LOWEST price (best profit scenario) if highest_price == 0: highest_price = avg_entry_price if current_low < highest_price: highest_price = current_low - + # Profit: (Entry - Lowest) / Entry profit_pct = (avg_entry_price - highest_price) / avg_entry_price current_profit_pct = (avg_entry_price - current_close) / avg_entry_price - + # 1. Trailing Stop if take_profit_activation > 0 and profit_pct >= take_profit_activation: # Drawdown: (Current - Lowest) / Entry @@ -541,7 +540,7 @@ for i in range(len(df)): avg_entry_price = current_close last_add_price = current_close highest_price = current_close - + elif raw_sell_arr[i]: open_short_signals[i] = True open_short_price[i] = current_close @@ -568,64 +567,136 @@ df['close_long_text'] = close_long_text # Generate plot configs based on indicators plots = [] for rule in rules: - ind = rule.get('indicator') - params = rule.get('params', {}) - if ind == 'supertrend': - plots.append({ - "name": "SuperTrend Up", "type": "line", "data": "df['st_lower'].tolist()", "color": "#00FF00", "overlay": True - }) - plots.append({ - "name": "SuperTrend Down", "type": "line", "data": "df['st_upper'].tolist()", "color": "#FF0000", "overlay": True - }) - elif ind == 'ema': - p = params.get('period', 20) - plots.append({ - "name": f"EMA {p}", "type": "line", "data": f"df['ema_{p}'].tolist()", "color": "#FFA500", "overlay": True - }) - elif ind == 'ma': - p = params.get('period', 20) - t = params.get('ma_type', 'sma') - plots.append({ - "name": f"{t.upper()} {p}", "type": "line", "data": f"df['ma_{t}_{p}'].tolist()", "color": "#FFA500", "overlay": True - }) - elif ind == 'bollinger': - p = params.get('period', 20) - d = params.get('std_dev', 2.0) - plots.append({ - "name": "BB Upper", "type": "line", "data": f"df['bb_{p}_{d}_upper'].tolist()", "color": "#0088FE", "overlay": True - }) - plots.append({ - "name": "BB Lower", "type": "line", "data": f"df['bb_{p}_{d}_lower'].tolist()", "color": "#0088FE", "overlay": True - }) + ind = rule.get("indicator") + params = rule.get("params", {}) + if ind == "supertrend": + plots.append( + { + "name": "SuperTrend Up", + "type": "line", + "data": "df['st_lower'].tolist()", + "color": "#00FF00", + "overlay": True, + } + ) + plots.append( + { + "name": "SuperTrend Down", + "type": "line", + "data": "df['st_upper'].tolist()", + "color": "#FF0000", + "overlay": True, + } + ) + elif ind == "ema": + p = params.get("period", 20) + plots.append( + { + "name": f"EMA {p}", + "type": "line", + "data": f"df['ema_{p}'].tolist()", + "color": "#FFA500", + "overlay": True, + } + ) + elif ind == "ma": + p = params.get("period", 20) + t = params.get("ma_type", "sma") + plots.append( + { + "name": f"{t.upper()} {p}", + "type": "line", + "data": f"df['ma_{t}_{p}'].tolist()", + "color": "#FFA500", + "overlay": True, + } + ) + elif ind == "bollinger": + p = params.get("period", 20) + d = params.get("std_dev", 2.0) + plots.append( + { + "name": "BB Upper", + "type": "line", + "data": f"df['bb_{p}_{d}_upper'].tolist()", + "color": "#0088FE", + "overlay": True, + } + ) + plots.append( + { + "name": "BB Lower", + "type": "line", + "data": f"df['bb_{p}_{d}_lower'].tolist()", + "color": "#0088FE", + "overlay": True, + } + ) # MACD, RSI, KDJ are typically separate panes, not overlay. The `overlay` param controls this. - elif ind == 'macd': - f = params.get('fast_period', 12) - s = params.get('slow_period', 26) - si = params.get('signal_period', 9) - plots.append({ - "name": "MACD", "type": "line", "data": f"df['macd_{f}_{s}_{si}_line'].tolist()", "color": "#0088FE", "overlay": False - }) - plots.append({ - "name": "Signal", "type": "line", "data": f"df['macd_{f}_{s}_{si}_signal'].tolist()", "color": "#FF8042", "overlay": False - }) - elif ind == 'rsi': - p = params.get('period', 14) - plots.append({ - "name": f"RSI {p}", "type": "line", "data": f"df['rsi_{p}'].tolist()", "color": "#8884d8", "overlay": False - }) - elif ind == 'kdj': - p = params.get('period', 9) - si = params.get('signal_period', 3) - plots.append({ - "name": "K", "type": "line", "data": f"df['kdj_{p}_{si}_k'].tolist()", "color": "#8884d8", "overlay": False - }) - plots.append({ - "name": "D", "type": "line", "data": f"df['kdj_{p}_{si}_d'].tolist()", "color": "#82ca9d", "overlay": False - }) - plots.append({ - "name": "J", "type": "line", "data": f"df['kdj_{p}_{si}_j'].tolist()", "color": "#ffc658", "overlay": False - }) - + elif ind == "macd": + f = params.get("fast_period", 12) + s = params.get("slow_period", 26) + si = params.get("signal_period", 9) + plots.append( + { + "name": "MACD", + "type": "line", + "data": f"df['macd_{f}_{s}_{si}_line'].tolist()", + "color": "#0088FE", + "overlay": False, + } + ) + plots.append( + { + "name": "Signal", + "type": "line", + "data": f"df['macd_{f}_{s}_{si}_signal'].tolist()", + "color": "#FF8042", + "overlay": False, + } + ) + elif ind == "rsi": + p = params.get("period", 14) + plots.append( + { + "name": f"RSI {p}", + "type": "line", + "data": f"df['rsi_{p}'].tolist()", + "color": "#8884d8", + "overlay": False, + } + ) + elif ind == "kdj": + p = params.get("period", 9) + si = params.get("signal_period", 3) + plots.append( + { + "name": "K", + "type": "line", + "data": f"df['kdj_{p}_{si}_k'].tolist()", + "color": "#8884d8", + "overlay": False, + } + ) + plots.append( + { + "name": "D", + "type": "line", + "data": f"df['kdj_{p}_{si}_d'].tolist()", + "color": "#82ca9d", + "overlay": False, + } + ) + plots.append( + { + "name": "J", + "type": "line", + "data": f"df['kdj_{p}_{si}_j'].tolist()", + "color": "#ffc658", + "overlay": False, + } + ) + # Convert plots to string representation valid in Python plots_py = "[\n" for p in plots: @@ -685,4 +756,3 @@ output = {{ ] }} """ - diff --git a/backend_api_python/app/services/strategy_script_runtime.py b/backend_api_python/app/services/strategy_script_runtime.py index 51f6e37..c9bdcd2 100644 --- a/backend_api_python/app/services/strategy_script_runtime.py +++ b/backend_api_python/app/services/strategy_script_runtime.py @@ -2,6 +2,7 @@ Python 策略脚本(on_init / on_bar + ctx.buy/sell/close_position)运行时。 与回测逻辑对齐,供 TradingExecutor 实盘逐根 K 线调用。 """ + from __future__ import annotations from typing import Any, Callable, Dict, List, Optional, Tuple @@ -34,13 +35,13 @@ class ScriptPosition(dict): raise AttributeError(name) from exc def __bool__(self) -> bool: - return bool(self.get('side')) and float(self.get('size') or 0) > 0 + return bool(self.get("side")) and float(self.get("size") or 0) > 0 def __int__(self) -> int: - return int(self.get('direction') or 0) + return int(self.get("direction") or 0) def __float__(self) -> float: - return float(self.get('direction') or 0) + return float(self.get("direction") or 0) def __eq__(self, other: Any) -> bool: try: @@ -62,40 +63,44 @@ class ScriptPosition(dict): def clear_position(self) -> None: self.clear() - self.update({ - 'side': '', - 'size': 0.0, - 'entry_price': 0.0, - 'direction': 0, - 'amount': 0.0, - }) + self.update( + { + "side": "", + "size": 0.0, + "entry_price": 0.0, + "direction": 0, + "amount": 0.0, + } + ) def open_position(self, side: str, entry_price: float, amount: float) -> None: - direction = 1 if side == 'long' else (-1 if side == 'short' else 0) + direction = 1 if side == "long" else (-1 if side == "short" else 0) size = float(amount or 0.0) price = float(entry_price or 0.0) self.clear() - self.update({ - 'side': side, - 'size': size, - 'entry_price': price, - 'direction': direction, - 'amount': size, - }) + self.update( + { + "side": side, + "size": size, + "entry_price": price, + "direction": direction, + "amount": size, + } + ) def add_position(self, entry_price: float, amount: float) -> None: extra = float(amount or 0.0) if extra <= 0: return - current_size = float(self.get('size') or 0.0) - current_price = float(self.get('entry_price') or 0.0) + current_size = float(self.get("size") or 0.0) + current_price = float(self.get("entry_price") or 0.0) next_size = current_size + extra next_price = float(entry_price or current_price or 0.0) if current_size > 0 and current_price > 0 and next_size > 0: next_price = ((current_price * current_size) + (float(entry_price or current_price) * extra)) / next_size - self['size'] = next_size - self['amount'] = next_size - self['entry_price'] = next_price + self["size"] = next_size + self["amount"] = next_size + self["entry_price"] = next_price class StrategyScriptContext: @@ -119,28 +124,30 @@ class StrategyScriptContext: def bars(self, n: int = 1): start = max(0, self.current_index - int(n) + 1) out = [] - for _, row in self._bars_df.iloc[start:self.current_index + 1].iterrows(): - out.append(ScriptBar( - open=float(row.get('open') or 0), - high=float(row.get('high') or 0), - low=float(row.get('low') or 0), - close=float(row.get('close') or 0), - volume=float(row.get('volume') or 0), - timestamp=row.get('time') - )) + for _, row in self._bars_df.iloc[start : self.current_index + 1].iterrows(): + out.append( + ScriptBar( + open=float(row.get("open") or 0), + high=float(row.get("high") or 0), + low=float(row.get("low") or 0), + close=float(row.get("close") or 0), + volume=float(row.get("volume") or 0), + timestamp=row.get("time"), + ) + ) return out def log(self, message: Any): self._logs.append(str(message)) def buy(self, price: Any = None, amount: Any = None): - self._orders.append({'action': 'buy', 'price': price, 'amount': amount}) + self._orders.append({"action": "buy", "price": price, "amount": amount}) def sell(self, price: Any = None, amount: Any = None): - self._orders.append({'action': 'sell', 'price': price, 'amount': amount}) + self._orders.append({"action": "sell", "price": price, "amount": amount}) def close_position(self): - self._orders.append({'action': 'close'}) + self._orders.append({"action": "close"}) def compile_strategy_script_handlers(code: str) -> Tuple[Optional[Callable], Optional[Callable]]: @@ -154,38 +161,36 @@ def compile_strategy_script_handlers(code: str) -> Tuple[Optional[Callable], Opt import builtins def safe_import(name, *args, **kwargs): - allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time'] - if name in allowed_modules or name.split('.')[0] in allowed_modules: + allowed_modules = ["numpy", "pandas", "math", "json", "datetime", "time"] + if name in allowed_modules or name.split(".")[0] in allowed_modules: return builtins.__import__(name, *args, **kwargs) raise ImportError(f"Import not allowed: {name}") - safe_builtins = {k: getattr(builtins, k) for k in dir(builtins) - if not k.startswith('_') and k not in ['eval', 'exec', 'compile', 'open', 'input', 'help', 'exit', 'quit']} - safe_builtins['__import__'] = safe_import + safe_builtins = { + k: getattr(builtins, k) + for k in dir(builtins) + if not k.startswith("_") and k not in ["eval", "exec", "compile", "open", "input", "help", "exit", "quit"] + } + safe_builtins["__import__"] = safe_import exec_env = { - '__builtins__': safe_builtins, - 'np': np, - 'pd': pd, + "__builtins__": safe_builtins, + "np": np, + "pd": pd, } - from app.utils.safe_exec import validate_code_safety, safe_exec_code + from app.utils.safe_exec import safe_exec_code, validate_code_safety is_safe, error_msg = validate_code_safety(code) if not is_safe: raise ValueError(f"Code contains unsafe operations: {error_msg}") - exec_result = safe_exec_code( - code=code, - exec_globals=exec_env, - exec_locals=exec_env, - timeout=60 - ) - if not exec_result['success']: + exec_result = safe_exec_code(code=code, exec_globals=exec_env, exec_locals=exec_env, timeout=60) + if not exec_result["success"]: raise RuntimeError(f"Code execution failed: {exec_result.get('error')}") - on_init = exec_env.get('on_init') - on_bar = exec_env.get('on_bar') + on_init = exec_env.get("on_init") + on_bar = exec_env.get("on_bar") if not callable(on_bar): raise ValueError("Strategy script must define on_bar(ctx, bar)") if on_init is not None and not callable(on_init): diff --git a/backend_api_python/app/services/strategy_snapshot.py b/backend_api_python/app/services/strategy_snapshot.py index f308a15..2dc7677 100644 --- a/backend_api_python/app/services/strategy_snapshot.py +++ b/backend_api_python/app/services/strategy_snapshot.py @@ -121,20 +121,44 @@ class StrategySnapshotResolver: indicator_config = self._safe_dict(strategy.get("indicator_config")) trading_config = self._safe_dict(strategy.get("trading_config")) - cs_type = str(trading_config.get("cs_strategy_type") or trading_config.get("strategy_type") or "single").strip().lower() + cs_type = ( + str(trading_config.get("cs_strategy_type") or trading_config.get("strategy_type") or "single") + .strip() + .lower() + ) if cs_type == "cross_sectional": raise ValueError("Cross-sectional strategies are not supported in strategy backtest yet") symbol = str(override.get("symbol") or trading_config.get("symbol") or strategy.get("symbol") or "").strip() - market = str(override.get("market") or strategy.get("market_category") or trading_config.get("market_category") or "Crypto").strip() or "Crypto" + market = ( + str( + override.get("market") + or strategy.get("market_category") + or trading_config.get("market_category") + or "Crypto" + ).strip() + or "Crypto" + ) if ":" in symbol and "market" not in override: maybe_market, maybe_symbol = symbol.split(":", 1) market = maybe_market or market symbol = maybe_symbol or symbol - timeframe = str(override.get("timeframe") or trading_config.get("timeframe") or strategy.get("timeframe") or "1D").strip() or "1D" - initial_capital = self._to_float(override.get("initialCapital", trading_config.get("initial_capital", strategy.get("initial_capital", 10000))), 10000.0) - leverage = self._to_int(override.get("leverage", trading_config.get("leverage", strategy.get("leverage", 1))), 1) + timeframe = ( + str( + override.get("timeframe") or trading_config.get("timeframe") or strategy.get("timeframe") or "1D" + ).strip() + or "1D" + ) + initial_capital = self._to_float( + override.get( + "initialCapital", trading_config.get("initial_capital", strategy.get("initial_capital", 10000)) + ), + 10000.0, + ) + leverage = self._to_int( + override.get("leverage", trading_config.get("leverage", strategy.get("leverage", 1))), 1 + ) # Commission/slippage are backtest-only assumptions (not used by live ScriptStrategy execution). # Script strategies created from the UI may omit these; apply sensible backtest defaults. commission_raw = override.get("commission") @@ -161,7 +185,11 @@ class StrategySnapshotResolver: indicator_id = indicator_config.get("indicator_id") or strategy.get("indicator_id") indicator_name = indicator_config.get("indicator_name") or "" - code = (strategy.get("strategy_code") or "").strip() if is_script else (indicator_config.get("indicator_code") or "").strip() + code = ( + (strategy.get("strategy_code") or "").strip() + if is_script + else (indicator_config.get("indicator_code") or "").strip() + ) if not code and indicator_id and not is_script: code = self._fetch_indicator_code(indicator_id) diff --git a/backend_api_python/app/services/symbol_name.py b/backend_api_python/app/services/symbol_name.py index fce4de9..306c0e4 100644 --- a/backend_api_python/app/services/symbol_name.py +++ b/backend_api_python/app/services/symbol_name.py @@ -12,27 +12,19 @@ Notes: from __future__ import annotations -from typing import Optional - -import re import os +from typing import Optional import requests -from app.utils.logger import get_logger from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name -from app.data_sources.tencent import normalize_cn_code, normalize_hk_code +from app.utils.logger import get_logger logger = get_logger(__name__) def _normalize_symbol_for_market(market: str, symbol: str) -> str: - m = (market or '').strip() - s = (symbol or '').strip().upper() - if m == 'CNStock': - return normalize_cn_code(s) - if m == 'HKStock': - return normalize_hk_code(s) + s = (symbol or "").strip().upper() return s @@ -40,21 +32,24 @@ def _resolve_name_from_yfinance(symbol: str) -> Optional[str]: """ Best-effort company name via yfinance. """ + def _try_one(sym: str) -> Optional[str]: import yfinance as yf + t = yf.Ticker(sym) info = getattr(t, "info", None) if not isinstance(info, dict) or not info: return None - name = (info.get('longName') or info.get('shortName') or '').strip() + name = (info.get("longName") or info.get("shortName") or "").strip() return name if name else None + try: # yfinance uses '-' for some tickers (e.g. BRK-B) while users may input 'BRK.B' out = _try_one(symbol) if out: return out - if '.' in symbol: - out = _try_one(symbol.replace('.', '-')) + if "." in symbol: + out = _try_one(symbol.replace(".", "-")) if out: return out return None @@ -69,7 +64,7 @@ def _resolve_name_from_finnhub(symbol: str) -> Optional[str]: https://finnhub.io/docs/api/company-profile2 """ try: - api_key = (os.getenv('FINNHUB_API_KEY') or '').strip() + api_key = (os.getenv("FINNHUB_API_KEY") or "").strip() if not api_key: return None url = "https://finnhub.io/api/v1/stock/profile2" @@ -79,7 +74,7 @@ def _resolve_name_from_finnhub(symbol: str) -> Optional[str]: data = resp.json() if resp.text else {} if not isinstance(data, dict) or not data: return None - name = (data.get("name") or data.get("ticker") or '').strip() + name = (data.get("name") or data.get("ticker") or "").strip() return name if name else None except Exception as e: logger.debug(f"Finnhub name resolve failed: {symbol}: {e}") @@ -94,7 +89,7 @@ def resolve_symbol_name(market: str, symbol: str) -> Optional[str]: 2) Market-specific public sources 3) Reasonable fallback (None) """ - m = (market or '').strip() + m = (market or "").strip() s = _normalize_symbol_for_market(m, symbol) if not m or not s: return None @@ -105,35 +100,24 @@ def resolve_symbol_name(market: str, symbol: str) -> Optional[str]: return seed # 2) Market-specific - if m == 'USStock': + if m == "USStock": # Prefer Finnhub if configured (more stable for company name), # otherwise fall back to yfinance. return _resolve_name_from_finnhub(s) or _resolve_name_from_yfinance(s) - # CN/HK stocks: try Tencent quote name first (no key), then yfinance best-effort. - if m in ('CNStock', 'HKStock'): - try: - from app.data_sources.tencent import fetch_quote - parts = fetch_quote(s) - if parts and len(parts) > 1 and parts[1]: - return str(parts[1]).strip() - except Exception: - pass - return _resolve_name_from_yfinance(s) - # Crypto: at least return base ticker-like display (not a "company", but better than empty) - if m == 'Crypto': - if '/' in s: - base = s.split('/')[0].strip() + if m == "Crypto": + if "/" in s: + base = s.split("/")[0].strip() return base if base else None return s # Forex: keep as-is (e.g. EURUSD) – you can later replace with a nicer mapping if needed. - if m == 'Forex': + if m == "Forex": return s # Futures: keep as-is - if m == 'Futures': + if m == "Futures": return s return None diff --git a/backend_api_python/app/services/trading_executor.py b/backend_api_python/app/services/trading_executor.py index 99b9ec7..6379583 100644 --- a/backend_api_python/app/services/trading_executor.py +++ b/backend_api_python/app/services/trading_executor.py @@ -5,39 +5,40 @@ Strategy thread: Generates candlestick charts/prices, calculates signals, and wr Live trades are executed via PendingOrderWorker + app.services.live_trading (direct REST connection to each exchange); ccxt is not used for order placement in this module. """ -import time -import threading -import traceback + import os +import threading +import time +import traceback + try: import resource # Linux/Unix only except Exception: resource = None -from typing import Dict, List, Any, Optional, Tuple -from datetime import datetime import json -from decimal import Decimal, ROUND_DOWN, ROUND_UP -import pandas as pd -import numpy as np +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd -from app.utils.logger import get_logger -from app.utils.db import get_db_connection -from app.utils.strategy_runtime_logs import append_strategy_log from app.data_sources import DataSourceFactory +from app.services.indicator_params import IndicatorCaller, IndicatorParamsParser from app.services.kline import KlineService -from app.services.indicator_params import IndicatorParamsParser, IndicatorCaller from app.services.strategy_script_runtime import ( ScriptBar, StrategyScriptContext, compile_strategy_script_handlers, ) +from app.utils.db import get_db_connection +from app.utils.logger import get_logger +from app.utils.strategy_runtime_logs import append_strategy_log logger = get_logger(__name__) class TradingExecutor: """Real-time transaction executor (Signal Provider Mode)""" - + def __init__(self): # Instead of using a global connection, obtain it from the connection pool each time it is used. self.running_strategies = {} # {strategy_id: thread} @@ -53,13 +54,13 @@ class TradingExecutor: # Keyed by (strategy_id, symbol, signal_type, signal_timestamp). self._signal_dedup = {} # type: Dict[int, Dict[str, float]] self._signal_dedup_lock = threading.Lock() - self.kline_service = KlineService() # K-line service (with cache) + self.kline_service = KlineService() # K-line service (with cache) # Throttle writes to qd_strategy_logs (heartbeat), per strategy_id -> monotonic time self._strategy_ui_log_last_tick_ts = {} # type: Dict[int, float] - + # The upper limit of single-instance threads to avoid unlimited thread creation causing can't start new thread/OOM - self.max_threads = int(os.getenv('STRATEGY_MAX_THREADS', '64')) - + self.max_threads = int(os.getenv("STRATEGY_MAX_THREADS", "64")) + # Make sure the database field exists self._ensure_db_columns() @@ -73,23 +74,27 @@ class TradingExecutor: # PostgreSQL: Query columns using information_schema try: cursor.execute(""" - SELECT column_name FROM information_schema.columns + SELECT column_name FROM information_schema.columns WHERE table_name = 'qd_strategy_positions' """) cols = cursor.fetchall() or [] - col_names = {c.get('column_name') or c.get('COLUMN_NAME') for c in cols if isinstance(c, dict)} + col_names = {c.get("column_name") or c.get("COLUMN_NAME") for c in cols if isinstance(c, dict)} except Exception: col_names = set() - if 'highest_price' not in col_names: + if "highest_price" not in col_names: logger.info("Adding highest_price column to qd_strategy_positions...") - cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS highest_price DOUBLE PRECISION DEFAULT 0") + cursor.execute( + "ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS highest_price DOUBLE PRECISION DEFAULT 0" + ) db.commit() logger.info("highest_price column added") - if 'lowest_price' not in col_names: + if "lowest_price" not in col_names: logger.info("Adding lowest_price column to qd_strategy_positions...") - cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS lowest_price DOUBLE PRECISION DEFAULT 0") + cursor.execute( + "ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS lowest_price DOUBLE PRECISION DEFAULT 0" + ) db.commit() logger.info("lowest_price column added") @@ -106,36 +111,38 @@ class TradingExecutor: """ try: # New system: only supports swap (perpetual contract) / spot (spot) - if market_type != 'swap': + if market_type != "swap": return symbol - if not symbol or ':' in symbol: + if not symbol or ":" in symbol: return symbol - if not getattr(exchange, 'markets', None): + if not getattr(exchange, "markets", None): return symbol # If symbol itself is a contract market, return it directly try: m = exchange.market(symbol) - if m and (m.get('swap') or m.get('future') or m.get('contract')): + if m and (m.get("swap") or m.get("future") or m.get("contract")): return symbol except Exception: pass # OKX/some exchanges: Perpetual is usually BASE/QUOTE:QUOTE or BASE/QUOTE:USDT - if '/' not in symbol: + if "/" not in symbol: return symbol - base, quote = symbol.split('/', 1) + base, quote = symbol.split("/", 1) candidates = [] if quote: candidates.append(f"{base}/{quote}:{quote}") - if quote.upper() != 'USDT': + if quote.upper() != "USDT": candidates.append(f"{base}/{quote}:USDT") for cand in candidates: if cand in exchange.markets: cm = exchange.markets[cand] - if cm and (cm.get('swap') or cm.get('future') or cm.get('contract')): - logger.info(f"symbol normalized: {symbol} -> {cand} (exchange={exchange_id}, market_type={market_type})") + if cm and (cm.get("swap") or cm.get("future") or cm.get("contract")): + logger.info( + f"symbol normalized: {symbol} -> {cand} (exchange={exchange_id}, market_type={market_type})" + ) return cand return symbol @@ -146,27 +153,32 @@ class TradingExecutor: """Debug helper: record thread and memory usage to diagnose the root cause of 'can't start new thread'.""" try: import psutil # Use more precise metrics if installed + p = psutil.Process() mem = p.memory_info().rss / 1024 / 1024 th = p.num_threads() - logger.warning(f"{prefix}resource status: memory={mem:.1f}MB, threads={th}, " - f"running_strategies={len(self.running_strategies)}") + logger.warning( + f"{prefix}resource status: memory={mem:.1f}MB, threads={th}, " + f"running_strategies={len(self.running_strategies)}" + ) except Exception: try: th = threading.active_count() # Read VmRSS from /proc/self/status (for Linux containers) vmrss = None try: - with open('/proc/self/status') as f: + with open("/proc/self/status") as f: for line in f: - if line.startswith('VmRSS:'): + if line.startswith("VmRSS:"): vmrss = line.split()[1:3] # e.g. ['123456', 'kB'] break except Exception: pass vmrss_str = f"{vmrss[0]}{vmrss[1]}" if vmrss else "N/A" - logger.warning(f"{prefix}resource status: VmRSS={vmrss_str}, active_threads={th}, " - f"running_strategies={len(self.running_strategies)}") + logger.warning( + f"{prefix}resource status: VmRSS={vmrss_str}, active_threads={th}, " + f"running_strategies={len(self.running_strategies)}" + ) except Exception: pass @@ -387,14 +399,14 @@ class TradingExecutor: }, }, } - + def start_strategy(self, strategy_id: int) -> bool: """ launch strategy - + Args: strategy_id: Strategy ID - + Returns: Is it successful? """ @@ -416,13 +428,9 @@ class TradingExecutor: if strategy_id in self.running_strategies: logger.warning(f"Strategy {strategy_id} is already running") return False - + # Create and start threads - thread = threading.Thread( - target=self._run_strategy_loop, - args=(strategy_id,), - daemon=True - ) + thread = threading.Thread(target=self._run_strategy_loop, args=(strategy_id,), daemon=True) try: thread.start() except Exception as e: @@ -430,24 +438,24 @@ class TradingExecutor: self._log_resource_status(prefix="startup exception") raise e self.running_strategies[strategy_id] = thread - + logger.info(f"Strategy {strategy_id} started") self._console_print(f"[strategy:{strategy_id}] started") append_strategy_log(strategy_id, "info", "策略执行线程已启动") return True - + except Exception as e: logger.error(f"Failed to start strategy {strategy_id}: {str(e)}") logger.error(traceback.format_exc()) return False - + def stop_strategy(self, strategy_id: int) -> bool: """ stopping strategy - + Args: strategy_id: Strategy ID - + Returns: Is it successful? """ @@ -456,25 +464,22 @@ class TradingExecutor: if strategy_id not in self.running_strategies: logger.warning(f"Strategy {strategy_id} is not running") return False - + # Mark policy as stopped with get_db_connection() as db: cursor = db.cursor() - cursor.execute( - "UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s", - (strategy_id,) - ) + cursor.execute("UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s", (strategy_id,)) db.commit() cursor.close() - + # Removed from the run list (the thread will exit the next time the loop checks status) del self.running_strategies[strategy_id] - + logger.info(f"Strategy {strategy_id} stopped") self._console_print(f"[strategy:{strategy_id}] stopped (requested)") append_strategy_log(strategy_id, "info", "已请求停止策略(运行标志已清除)") return True - + except Exception as e: logger.error(f"Failed to stop strategy {strategy_id}: {str(e)}") logger.error(traceback.format_exc()) @@ -483,13 +488,13 @@ class TradingExecutor: def _df_to_script_exec_df(self, df: pd.DataFrame) -> pd.DataFrame: out = df.reset_index() c0 = out.columns[0] - if c0 != 'time': - out.rename(columns={c0: 'time'}, inplace=True) + if c0 != "time": + out.rename(columns={c0: "time"}, inplace=True) return out def _script_default_position_ratio(self, trading_config: Dict[str, Any]) -> float: try: - ep = (trading_config or {}).get('entry_pct') + ep = (trading_config or {}).get("entry_pct") if ep is not None: return float(self._to_ratio(ep, default=0.06)) except Exception: @@ -502,11 +507,11 @@ class TradingExecutor: if not pl: return p = pl[0] - side = (p.get('side') or 'long').strip().lower() - if side not in ('long', 'short'): + side = (p.get("side") or "long").strip().lower() + if side not in ("long", "short"): return - size = float(p.get('size') or 0) - ep = float(p.get('entry_price') or 0) + size = float(p.get("size") or 0) + ep = float(p.get("entry_price") or 0) if size > 0: ctx.position.open_position(side, ep, size) @@ -519,19 +524,19 @@ class TradingExecutor: ) -> Tuple[StrategyScriptContext, Optional[pd.Timestamp]]: df_exec = self._df_to_script_exec_df(df) ctx = StrategyScriptContext(df_exec, float(initial_capital or 0)) - raw = (trading_config or {}).get('script_runtime_state') or {} - params = raw.get('params') if isinstance(raw, dict) else {} + raw = (trading_config or {}).get("script_runtime_state") or {} + params = raw.get("params") if isinstance(raw, dict) else {} if isinstance(params, dict): ctx._params = dict(params) last_ts = None - ts_s = raw.get('last_closed_bar_ts') if isinstance(raw, dict) else None + ts_s = raw.get("last_closed_bar_ts") if isinstance(raw, dict) else None if ts_s: try: last_ts = pd.Timestamp(ts_s) if last_ts.tzinfo is None: - last_ts = last_ts.tz_localize('UTC') + last_ts = last_ts.tz_localize("UTC") else: - last_ts = last_ts.tz_convert('UTC') + last_ts = last_ts.tz_convert("UTC") except Exception: last_ts = None return ctx, last_ts @@ -541,13 +546,13 @@ class TradingExecutor: safe_params = json.loads(json.dumps(params or {}, default=str)) except Exception: safe_params = {} - ts_str = '' + ts_str = "" try: if closed_ts is not None: ts_str = pd.Timestamp(closed_ts).isoformat() except Exception: - ts_str = '' - state = {'last_closed_bar_ts': ts_str, 'params': safe_params} + ts_str = "" + state = {"last_closed_bar_ts": ts_str, "params": safe_params} try: with get_db_connection() as db: cur = db.cursor() @@ -556,7 +561,7 @@ class TradingExecutor: if not row: cur.close() return - tc = row.get('trading_config') + tc = row.get("trading_config") if isinstance(tc, str) and tc.strip(): try: tc = json.loads(tc) @@ -564,7 +569,7 @@ class TradingExecutor: tc = {} elif not isinstance(tc, dict): tc = {} - tc['script_runtime_state'] = state + tc["script_runtime_state"] = state cur.execute( "UPDATE qd_strategies_trading SET trading_config = %s WHERE id = %s", (json.dumps(tc, ensure_ascii=False), strategy_id), @@ -582,9 +587,9 @@ class TradingExecutor: closed_ts: pd.Timestamp, trading_config: Dict[str, Any], ) -> List[Dict[str, Any]]: - td = str(trade_direction or 'both').lower() - if td not in ('long', 'short', 'both'): - td = 'both' + td = str(trade_direction or "both").lower() + if td not in ("long", "short", "both"): + td = "both" default_ratio = self._script_default_position_ratio(trading_config) try: ts_i = int(closed_ts.timestamp()) @@ -593,12 +598,12 @@ class TradingExecutor: out: List[Dict[str, Any]] = [] trig = float(bar_close or 0) for order in list(ctx._orders or []): - action = str(order.get('action') or '').lower() + action = str(order.get("action") or "").lower() try: - order_price = float(order.get('price') or bar_close or 0) + order_price = float(order.get("price") or bar_close or 0) except Exception: order_price = trig - raw_amt = order.get('amount') + raw_amt = order.get("amount") pos_ratio = default_ratio if raw_amt is not None: try: @@ -607,36 +612,92 @@ class TradingExecutor: pos_ratio = v except Exception: pass - if action == 'close': + if action == "close": if ctx.position > 0: - out.append({'type': 'close_long', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i}) + out.append( + { + "type": "close_long", + "trigger_price": order_price or trig, + "position_size": 0, + "timestamp": ts_i, + } + ) ctx.position.clear_position() elif ctx.position < 0: - out.append({'type': 'close_short', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i}) + out.append( + { + "type": "close_short", + "trigger_price": order_price or trig, + "position_size": 0, + "timestamp": ts_i, + } + ) ctx.position.clear_position() continue - if action == 'buy': + if action == "buy": if ctx.position < 0: - out.append({'type': 'close_short', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i}) + out.append( + { + "type": "close_short", + "trigger_price": order_price or trig, + "position_size": 0, + "timestamp": ts_i, + } + ) ctx.position.clear_position() - if td in ('long', 'both'): + if td in ("long", "both"): if ctx.position == 0: - out.append({'type': 'open_long', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i}) - ctx.position.open_position('long', order_price or trig, pos_ratio) + out.append( + { + "type": "open_long", + "trigger_price": order_price or trig, + "position_size": pos_ratio, + "timestamp": ts_i, + } + ) + ctx.position.open_position("long", order_price or trig, pos_ratio) else: - out.append({'type': 'add_long', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i}) + out.append( + { + "type": "add_long", + "trigger_price": order_price or trig, + "position_size": pos_ratio, + "timestamp": ts_i, + } + ) ctx.position.add_position(order_price or trig, pos_ratio) continue - if action == 'sell': + if action == "sell": if ctx.position > 0: - out.append({'type': 'close_long', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i}) + out.append( + { + "type": "close_long", + "trigger_price": order_price or trig, + "position_size": 0, + "timestamp": ts_i, + } + ) ctx.position.clear_position() - if td in ('short', 'both'): + if td in ("short", "both"): if ctx.position == 0: - out.append({'type': 'open_short', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i}) - ctx.position.open_position('short', order_price or trig, pos_ratio) + out.append( + { + "type": "open_short", + "trigger_price": order_price or trig, + "position_size": pos_ratio, + "timestamp": ts_i, + } + ) + ctx.position.open_position("short", order_price or trig, pos_ratio) else: - out.append({'type': 'add_short', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i}) + out.append( + { + "type": "add_short", + "trigger_price": order_price or trig, + "position_size": pos_ratio, + "timestamp": ts_i, + } + ) ctx.position.add_position(order_price or trig, pos_ratio) return out @@ -667,12 +728,12 @@ class TradingExecutor: self._hydrate_script_ctx_from_positions(ctx, strategy_id, symbol) ctx._orders = [] bar = ScriptBar( - open=float(row.get('open') or 0), - high=float(row.get('high') or 0), - low=float(row.get('low') or 0), - close=float(row.get('close') or 0), - volume=float(row.get('volume') or 0), - timestamp=row.get('time'), + open=float(row.get("open") or 0), + high=float(row.get("high") or 0), + low=float(row.get("low") or 0), + close=float(row.get("close") or 0), + volume=float(row.get("volume") or 0), + timestamp=row.get("time"), ) try: on_bar(ctx, bar) @@ -680,138 +741,136 @@ class TradingExecutor: logger.error(f"Strategy {strategy_id} script on_bar error: {e}") logger.error(traceback.format_exc()) return [], last_closed_ts - bar_close = float(row.get('close') or 0) + bar_close = float(row.get("close") or 0) pending = self._script_orders_to_execution_signals(ctx, trade_direction, bar_close, closed_ts, trading_config) self._persist_script_runtime_state(strategy_id, closed_ts, ctx._params) logger.info(f"Strategy {strategy_id} script closed bar {closed_ts} -> {len(pending)} signal(s)") return pending, closed_ts - + def _run_strategy_loop(self, strategy_id: int): """ strategy run loop - + Args: strategy_id: Strategy ID """ logger.info(f"Strategy {strategy_id} loop starting") self._console_print(f"[strategy:{strategy_id}] loop initializing") - + try: # Load policy configuration strategy = self._load_strategy(strategy_id) if not strategy: logger.error(f"Strategy {strategy_id} not found") return - - stype = strategy.get('strategy_type') or '' - if stype not in ('IndicatorStrategy', 'ScriptStrategy'): + + stype = strategy.get("strategy_type") or "" + if stype not in ("IndicatorStrategy", "ScriptStrategy"): logger.error(f"Strategy {strategy_id} has unsupported strategy_type for realtime execution: {stype}") return - is_script = stype == 'ScriptStrategy' + is_script = stype == "ScriptStrategy" # Initialize policy state - trading_config = strategy['trading_config'] - indicator_config = strategy.get('indicator_config') or {} - ai_model_config = strategy.get('ai_model_config') or {} - execution_mode = (strategy.get('execution_mode') or 'signal').strip().lower() - if execution_mode not in ['signal', 'live']: - execution_mode = 'signal' - notification_config = strategy.get('notification_config') or {} - strategy_name = strategy.get('strategy_name') or f"strategy_{int(strategy_id)}" - symbol = trading_config.get('symbol', '') - timeframe = trading_config.get('timeframe', '1H') - + trading_config = strategy["trading_config"] + indicator_config = strategy.get("indicator_config") or {} + ai_model_config = strategy.get("ai_model_config") or {} + execution_mode = (strategy.get("execution_mode") or "signal").strip().lower() + if execution_mode not in ["signal", "live"]: + execution_mode = "signal" + notification_config = strategy.get("notification_config") or {} + strategy_name = strategy.get("strategy_name") or f"strategy_{int(strategy_id)}" + symbol = trading_config.get("symbol", "") + timeframe = trading_config.get("timeframe", "1H") + # Secure access to leverage and trade_direction try: - leverage_val = trading_config.get('leverage', 1) + leverage_val = trading_config.get("leverage", 1) if isinstance(leverage_val, (list, tuple)): leverage_val = leverage_val[0] if leverage_val else 1 leverage = float(leverage_val) - except: - logger.warning(f"Strategy {strategy_id} invalid leverage format, reset to 1: {trading_config.get('leverage')}") + except Exception: + logger.warning( + f"Strategy {strategy_id} invalid leverage format, reset to 1: {trading_config.get('leverage')}" + ) leverage = 1.0 - + # Get the market type, default is contract # Automatic judgment based on leverage: leverage = 1 for spot, leverage > 1 for contract - market_type = trading_config.get('market_type', 'swap') - if market_type not in ['swap', 'spot']: - logger.error(f"Strategy {strategy_id} invalid market_type={market_type} (only swap/spot supported); refusing to start") + market_type = trading_config.get("market_type", "swap") + if market_type not in ["swap", "spot"]: + logger.error( + f"Strategy {strategy_id} invalid market_type={market_type} (only swap/spot supported); refusing to start" + ) return - + # Automatically adjust market type based on leverage if leverage == 1.0: - market_type = 'spot' # Spot fixed 1x leverage + market_type = "spot" # Spot fixed 1x leverage logger.info(f"Strategy {strategy_id} leverage=1; auto-switch market_type to spot") else: # Contract market: uniformly use swap (perpetual) to avoid futures/delivery confusion that may lead to position/order checking in the wrong market. - market_type = 'swap' + market_type = "swap" logger.info(f"Strategy {strategy_id} derivatives trading; normalize market_type to: {market_type}") - + # Limit leverage based on market type - if market_type == 'spot': + if market_type == "spot": leverage = 1.0 # Spot fixed 1x leverage elif leverage < 1: leverage = 1.0 elif leverage > 125: leverage = 125.0 logger.warning(f"Strategy {strategy_id} leverage > 125; capped to 125") - + # Get the trading direction, spot can only go long - trade_direction = trading_config.get('trade_direction', 'long') - if market_type == 'spot': - trade_direction = 'long' # Spot prices can only be long + trade_direction = trading_config.get("trade_direction", "long") + if market_type == "spot": + trade_direction = "long" # Spot prices can only be long logger.info(f"Strategy {strategy_id} spot trading; force trade_direction=long") # Get market category (Crypto, USStock, Forex, Futures) # This determines which data source to use to obtain price and K-line data - market_category = (strategy.get('market_category') or 'Crypto').strip() + market_category = (strategy.get("market_category") or "Crypto").strip() logger.info(f"Strategy {strategy_id} market_category: {market_category}") - # Check if this is a cross-sectional strategy - cs_strategy_type = trading_config.get('cs_strategy_type', 'single') - if cs_strategy_type == 'cross_sectional': - # Run cross-sectional strategy loop - self._run_cross_sectional_strategy_loop( - strategy_id, strategy, trading_config, indicator_config, - ai_model_config, execution_mode, notification_config, - strategy_name, market_category, market_type, leverage, - initial_capital, indicator_code, indicator_id - ) - return - # Initialize exchange connection (no real connection required in signal mode) exchange = None - + # Safely obtain initial_capital try: - initial_capital_val = strategy.get('initial_capital', 1000) + initial_capital_val = strategy.get("initial_capital", 1000) if isinstance(initial_capital_val, (list, tuple)): initial_capital_val = initial_capital_val[0] if initial_capital_val else 1000 initial_capital = float(initial_capital_val) except Exception: - logger.warning(f"Strategy {strategy_id} invalid initial_capital format, reset to 1000: {strategy.get('initial_capital')}") + logger.warning( + f"Strategy {strategy_id} invalid initial_capital format, reset to 1000: {strategy.get('initial_capital')}" + ) initial_capital = 1000.0 indicator_id = None - indicator_code = '' - strategy_code = '' + indicator_code = "" + strategy_code = "" on_init_script = None on_bar_script = None if is_script: - strategy_code = (strategy.get('strategy_code') or '').strip() + strategy_code = (strategy.get("strategy_code") or "").strip() if not strategy_code: logger.error(f"Strategy {strategy_id} strategy_code is empty") return - if '\\n' in strategy_code and '\n' not in strategy_code: + if "\\n" in strategy_code and "\n" not in strategy_code: try: decoded = json.loads(f'"{strategy_code}"') if isinstance(decoded, str): strategy_code = decoded except Exception: strategy_code = ( - strategy_code.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r') - .replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\') + strategy_code.replace("\\n", "\n") + .replace("\\t", "\t") + .replace("\\r", "\r") + .replace('\\"', '"') + .replace("\\'", "'") + .replace("\\\\", "\\") ) try: on_init_script, on_bar_script = compile_strategy_script_handlers(strategy_code) @@ -820,9 +879,9 @@ class TradingExecutor: logger.error(traceback.format_exc()) return else: - indicator_config = strategy['indicator_config'] - indicator_id = indicator_config.get('indicator_id') - indicator_code = indicator_config.get('indicator_code', '') + indicator_config = strategy["indicator_config"] + indicator_id = indicator_config.get("indicator_id") + indicator_code = indicator_config.get("indicator_code", "") if not indicator_code and indicator_id: indicator_code = self._get_indicator_code_from_db(indicator_id) if not indicator_code: @@ -830,48 +889,64 @@ class TradingExecutor: return if not isinstance(indicator_code, str): indicator_code = str(indicator_code) - if '\\n' in indicator_code and '\n' not in indicator_code: + if "\\n" in indicator_code and "\n" not in indicator_code: try: decoded = json.loads(f'"{indicator_code}"') if isinstance(decoded, str): indicator_code = decoded logger.info(f"Strategy {strategy_id} decoded escaped indicator_code") except Exception as e: - logger.warning(f"Strategy {strategy_id} JSON decode failed; falling back to manual unescape: {str(e)}") + logger.warning( + f"Strategy {strategy_id} JSON decode failed; falling back to manual unescape: {str(e)}" + ) indicator_code = ( - indicator_code.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r') - .replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\') + indicator_code.replace("\\n", "\n") + .replace("\\t", "\t") + .replace("\\r", "\r") + .replace('\\"', '"') + .replace("\\'", "'") + .replace("\\\\", "\\") ) # Check if this is a cross-sectional strategy - cs_strategy_type = trading_config.get('cs_strategy_type', 'single') - if (not is_script) and cs_strategy_type == 'cross_sectional': + cs_strategy_type = trading_config.get("cs_strategy_type", "single") + if (not is_script) and cs_strategy_type == "cross_sectional": self._run_cross_sectional_strategy_loop( - strategy_id, strategy, trading_config, strategy['indicator_config'], - ai_model_config, execution_mode, notification_config, - strategy_name, market_category, market_type, leverage, - initial_capital, indicator_code, indicator_id + strategy_id, + strategy, + trading_config, + strategy["indicator_config"], + ai_model_config, + execution_mode, + notification_config, + strategy_name, + market_category, + market_type, + leverage, + initial_capital, + indicator_code, + indicator_id, ) return - if is_script and cs_strategy_type == 'cross_sectional': + if is_script and cs_strategy_type == "cross_sectional": logger.error(f"Strategy {strategy_id} ScriptStrategy does not support cross_sectional mode") return # Initialize the exchange connection (no real connection is required in signal mode) exchange = None - + # ============================================ # Initialization phase: Obtain historical K-lines and calculate indicators # ============================================ # logger.info(f"Strategy {strategy_id} initialization: Get historical K-line data...") - history_limit = int(os.getenv('K_LINE_HISTORY_GET_NUMBER', 500)) + history_limit = int(os.getenv("K_LINE_HISTORY_GET_NUMBER", 500)) klines = self._fetch_latest_kline(symbol, timeframe, limit=history_limit, market_category=market_category) if not klines or len(klines) < 2: logger.error(f"Strategy {strategy_id} failed to fetch K-lines") return - logger.info(rf'Strategy {strategy_id} history kline number: {len(klines)}') - + logger.info(rf"Strategy {strategy_id} history kline number: {len(klines)}") + # Convert to DataFrame df = self._klines_to_dataframe(klines) if len(df) == 0: @@ -887,12 +962,15 @@ class TradingExecutor: logger.info(f"Checking position synchronization during startup for strategy {strategy_id}...") # Call position synchronization logic (check even in signal mode) from app import get_pending_order_worker + worker = get_pending_order_worker() - if worker and hasattr(worker, '_sync_positions_best_effort'): + if worker and hasattr(worker, "_sync_positions_best_effort"): worker._sync_positions_best_effort(target_strategy_id=strategy_id) logger.info(f"Position synchronization completed during startup for strategy {strategy_id}") except Exception as e: - logger.warning(f"Position synchronization failed during startup for strategy {strategy_id} (startup continues): {e}") + logger.warning( + f"Position synchronization failed during startup for strategy {strategy_id} (startup continues): {e}" + ) # Get the current highest position price (read from local database) current_pos_list = self._get_current_positions(strategy_id, symbol) @@ -901,13 +979,13 @@ class TradingExecutor: initial_avg_entry_price = 0.0 initial_position_count = 0 initial_last_add_price = 0.0 - + if current_pos_list: pos = current_pos_list[0] # Take the first position (one-way position mode) - initial_highest = float(pos.get('highest_price', 0) or 0) - pos_side = pos.get('side', 'long') - initial_position = 1 if pos_side == 'long' else -1 - initial_avg_entry_price = float(pos.get('entry_price', 0) or 0) + initial_highest = float(pos.get("highest_price", 0) or 0) + pos_side = pos.get("side", "long") + initial_position = 1 if pos_side == "long" else -1 + initial_avg_entry_price = float(pos.get("entry_price", 0) or 0) initial_position_count = 1 # To simplify the process, assume it is a single position initial_last_add_price = initial_avg_entry_price @@ -931,8 +1009,14 @@ class TradingExecutor: logger.error(f"Strategy {strategy_id} on_init error: {e}") logger.error(traceback.format_exc()) pending_signals, last_script_closed_ts = self._script_evaluate_new_closed_bar( - df, script_ctx, on_bar_script, trade_direction, - last_script_closed_ts, strategy_id, symbol, trading_config, + df, + script_ctx, + on_bar_script, + trade_direction, + last_script_closed_ts, + strategy_id, + symbol, + trading_config, ) try: last_kline_time = int(df.index[-1].timestamp()) @@ -940,18 +1024,20 @@ class TradingExecutor: last_kline_time = int(time.time()) else: indicator_result = self._execute_indicator_with_prices( - indicator_code, df, trading_config, + indicator_code, + df, + trading_config, initial_highest_price=initial_highest, initial_position=initial_position, initial_avg_entry_price=initial_avg_entry_price, initial_position_count=initial_position_count, - initial_last_add_price=initial_last_add_price + initial_last_add_price=initial_last_add_price, ) if indicator_result is None: logger.error(f"Strategy {strategy_id} indicator execution failed") return - pending_signals = indicator_result.get('pending_signals', []) - last_kline_time = indicator_result.get('last_kline_time', 0) + pending_signals = indicator_result.get("pending_signals", []) + last_kline_time = indicator_result.get("last_kline_time", 0) logger.info(f"Strategy {strategy_id} initialized; pending_signals={len(pending_signals)}") if pending_signals: @@ -961,7 +1047,7 @@ class TradingExecutor: "info", f"Live trading cycle ready {symbol} {timeframe},Signal to be processed {len(pending_signals or [])} strip", ) - + # ============================================ # Main loop: unified tick cadence (default: 10s) # ============================================ @@ -969,7 +1055,7 @@ class TradingExecutor: # Note: `pending_orders` scanning stays at 1s (see PendingOrderWorker) to reduce live dispatch latency. try: # Global-only (no per-strategy override) - tick_interval_sec = int(os.getenv('STRATEGY_TICK_INTERVAL_SEC', '10')) + tick_interval_sec = int(os.getenv("STRATEGY_TICK_INTERVAL_SEC", "10")) except Exception: tick_interval_sec = 10 if tick_interval_sec < 1: @@ -977,19 +1063,20 @@ class TradingExecutor: last_tick_time = 0.0 last_kline_update_time = time.time() - + # Calculate K-line period (seconds) from app.data_sources.base import TIMEFRAME_SECONDS + timeframe_seconds = TIMEFRAME_SECONDS.get(timeframe, 3600) kline_update_interval = timeframe_seconds # Updated once every K-line cycle - + while True: try: # Check policy status if not self._is_strategy_running(strategy_id): logger.info(f"Strategy {strategy_id} stopped") break - + current_time = time.time() # Sleep until next tick to avoid CPU spin. @@ -1004,27 +1091,39 @@ class TradingExecutor: # 0. Virtual position mode, no need to synchronize exchanges # ============================================ # pass - + # ============================================ # 1. Fetch current price once per tick # ============================================ - current_price = self._fetch_current_price(exchange, symbol, market_type=market_type, market_category=market_category) + current_price = self._fetch_current_price( + exchange, symbol, market_type=market_type, market_category=market_category + ) if current_price is None: - logger.warning(f"Strategy {strategy_id} failed to fetch current price for {market_category}:{symbol}") + logger.warning( + f"Strategy {strategy_id} failed to fetch current price for {market_category}:{symbol}" + ) continue # ============================================ # 2. Check whether the K-line needs to be updated (updated once every K-line cycle, pulled from API) # ============================================ if current_time - last_kline_update_time >= kline_update_interval: - klines = self._fetch_latest_kline(symbol, timeframe, limit=history_limit, market_category=market_category) + klines = self._fetch_latest_kline( + symbol, timeframe, limit=history_limit, market_category=market_category + ) if klines and len(klines) >= 2: df = self._klines_to_dataframe(klines) if len(df) > 0: if is_script: new_sig, last_script_closed_ts = self._script_evaluate_new_closed_bar( - df, script_ctx, on_bar_script, trade_direction, - last_script_closed_ts, strategy_id, symbol, trading_config, + df, + script_ctx, + on_bar_script, + trade_direction, + last_script_closed_ts, + strategy_id, + symbol, + trading_config, ) pending_signals = new_sig try: @@ -1042,46 +1141,53 @@ class TradingExecutor: if current_pos_list: pos = current_pos_list[0] - initial_highest = float(pos.get('highest_price', 0) or 0) - pos_side = pos.get('side', 'long') - initial_position = 1 if pos_side == 'long' else -1 - initial_avg_entry_price = float(pos.get('entry_price', 0) or 0) + initial_highest = float(pos.get("highest_price", 0) or 0) + pos_side = pos.get("side", "long") + initial_position = 1 if pos_side == "long" else -1 + initial_avg_entry_price = float(pos.get("entry_price", 0) or 0) initial_position_count = 1 initial_last_add_price = initial_avg_entry_price indicator_result = self._execute_indicator_with_prices( - indicator_code, df, trading_config, + indicator_code, + df, + trading_config, initial_highest_price=initial_highest, initial_position=initial_position, initial_avg_entry_price=initial_avg_entry_price, initial_position_count=initial_position_count, - initial_last_add_price=initial_last_add_price + initial_last_add_price=initial_last_add_price, ) if indicator_result: - pending_signals = indicator_result.get('pending_signals', []) - last_kline_time = indicator_result.get('last_kline_time', 0) - new_hp = indicator_result.get('new_highest_price', 0) + pending_signals = indicator_result.get("pending_signals", []) + last_kline_time = indicator_result.get("last_kline_time", 0) + new_hp = indicator_result.get("new_highest_price", 0) last_kline_update_time = current_time # Update highest_price (using latest close as an approximation of current_price) if new_hp > 0 and current_pos_list: - current_close = float(df['close'].iloc[-1]) + current_close = float(df["close"].iloc[-1]) for p in current_pos_list: self._update_position( - strategy_id, p['symbol'], p['side'], - float(p['size']), float(p['entry_price']), + strategy_id, + p["symbol"], + p["side"], + float(p["size"]), + float(p["entry_price"]), current_close, - highest_price=new_hp + highest_price=new_hp, ) else: # ============================================ # 3. Non-K-line update tick: The script strategy is not recalculated here (only on_bar at the close of a new K-line). # ============================================ - if (not is_script) and 'df' in locals() and df is not None and len(df) > 0: + if (not is_script) and "df" in locals() and df is not None and len(df) > 0: try: realtime_df = df.copy() - realtime_df = self._update_dataframe_with_current_price(realtime_df, current_price, timeframe) + realtime_df = self._update_dataframe_with_current_price( + realtime_df, current_price, timeframe + ) current_pos_list = self._get_current_positions(strategy_id, symbol) initial_highest = 0.0 @@ -1092,36 +1198,41 @@ class TradingExecutor: if current_pos_list: pos = current_pos_list[0] - initial_highest = float(pos.get('highest_price', 0) or 0) - pos_side = pos.get('side', 'long') - initial_position = 1 if pos_side == 'long' else -1 - initial_avg_entry_price = float(pos.get('entry_price', 0) or 0) + initial_highest = float(pos.get("highest_price", 0) or 0) + pos_side = pos.get("side", "long") + initial_position = 1 if pos_side == "long" else -1 + initial_avg_entry_price = float(pos.get("entry_price", 0) or 0) initial_position_count = 1 initial_last_add_price = initial_avg_entry_price indicator_result = self._execute_indicator_with_prices( - indicator_code, realtime_df, trading_config, + indicator_code, + realtime_df, + trading_config, initial_highest_price=initial_highest, initial_position=initial_position, initial_avg_entry_price=initial_avg_entry_price, initial_position_count=initial_position_count, - initial_last_add_price=initial_last_add_price + initial_last_add_price=initial_last_add_price, ) if indicator_result: - pending_signals = indicator_result.get('pending_signals', []) - new_hp = indicator_result.get('new_highest_price', 0) + pending_signals = indicator_result.get("pending_signals", []) + new_hp = indicator_result.get("new_highest_price", 0) if new_hp > 0 and current_pos_list: for p in current_pos_list: self._update_position( - strategy_id, p['symbol'], p['side'], - float(p['size']), float(p['entry_price']), + strategy_id, + p["symbol"], + p["side"], + float(p["size"]), + float(p["entry_price"]), current_price, - highest_price=new_hp + highest_price=new_hp, ) except Exception as e: logger.warning(f"Strategy {strategy_id} realtime indicator recompute failed: {str(e)}") - + # ============================================ # 4. Evaluate triggers once per tick # ============================================ @@ -1131,7 +1242,7 @@ class TradingExecutor: expiration_threshold = timeframe_seconds * 2 valid_signals = [] for s in pending_signals: - signal_time = s.get('timestamp', 0) + signal_time = s.get("timestamp", 0) if signal_time == 0 or (current_ts - signal_time) < expiration_threshold: valid_signals.append(s) else: @@ -1141,39 +1252,46 @@ class TradingExecutor: # Unified cadence log: at most once per tick. if pending_signals: - logger.info(f"[monitoring] strategy={strategy_id} price={current_price}, pending_signals={len(pending_signals)}") + logger.info( + f"[monitoring] strategy={strategy_id} price={current_price}, pending_signals={len(pending_signals)}" + ) # Check if there is a signal to be triggered triggered_signals = [] signals_to_remove = [] - + for signal_info in pending_signals: - signal_type = signal_info.get('type') # 'open_long', 'close_long', 'open_short', 'close_short' - trigger_price = signal_info.get('trigger_price', 0) - + signal_type = signal_info.get("type") # 'open_long', 'close_long', 'open_short', 'close_short' + trigger_price = signal_info.get("trigger_price", 0) + # Check if price triggers triggered = False # [Key Fix] Position closing/stop loss and take profit signals default to "trigger immediately" - exit_trigger_mode = trading_config.get('exit_trigger_mode', 'immediate') # 'immediate' or 'price' - if signal_type in ['close_long', 'close_short'] and exit_trigger_mode == 'immediate': + exit_trigger_mode = trading_config.get( + "exit_trigger_mode", "immediate" + ) # 'immediate' or 'price' + if signal_type in ["close_long", "close_short"] and exit_trigger_mode == "immediate": triggered = True - + # [Optional] Whether the signal to open/increase a position is "triggered immediately" - entry_trigger_mode = trading_config.get('entry_trigger_mode', 'price') # 'price' or 'immediate' - if signal_type in ['open_long', 'open_short', 'add_long', 'add_short'] and entry_trigger_mode == 'immediate': + entry_trigger_mode = trading_config.get("entry_trigger_mode", "price") # 'price' or 'immediate' + if ( + signal_type in ["open_long", "open_short", "add_long", "add_short"] + and entry_trigger_mode == "immediate" + ): triggered = True if trigger_price > 0: - if signal_type in ['open_long', 'close_short', 'add_long']: + if signal_type in ["open_long", "close_short", "add_long"]: if current_price >= trigger_price: triggered = True - elif signal_type in ['open_short', 'close_long', 'add_short']: + elif signal_type in ["open_short", "close_long", "add_short"]: if current_price <= trigger_price: triggered = True else: triggered = True - + if triggered: triggered_signals.append(signal_info) signals_to_remove.append(signal_info) @@ -1205,12 +1323,12 @@ class TradingExecutor: ) if risk_sl: triggered_signals.append(risk_sl) - + # Remove a triggered signal from the pending trigger list for signal_info in signals_to_remove: if signal_info in pending_signals: pending_signals.remove(signal_info) - + # Execution trigger signal if triggered_signals: logger.info(f"Strategy {strategy_id} triggered signals: {triggered_signals}") @@ -1222,7 +1340,7 @@ class TradingExecutor: # - Only allow signals matching current state (flat/long/short). # - Always prefer close_* over open_*/add_*. # - Execute at most ONE signal per tick to avoid duplicated/re-entrant orders. - candidates = [s for s in triggered_signals if self._is_signal_allowed(state, s.get('type'))] + candidates = [s for s in triggered_signals if self._is_signal_allowed(state, s.get("type"))] # If both directions are present while flat, choose by trade_direction (deterministic). if state == "flat" and candidates: @@ -1259,9 +1377,9 @@ class TradingExecutor: break if selected: - signal_type = selected.get('type') - position_size = selected.get('position_size', 0) - trigger_price = selected.get('trigger_price', current_price) + signal_type = selected.get("type") + position_size = selected.get("position_size", 0) + trigger_price = selected.get("trigger_price", current_price) execute_price = trigger_price if trigger_price > 0 else current_price signal_ts = int(selected.get("timestamp") or 0) @@ -1295,11 +1413,12 @@ class TradingExecutor: # 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', + market=market_type or "Crypto", symbol=symbol, signal_type=signal_type, - signal_detail=f"Strategy: {strategy_name}\nSignal: {signal_type}\nPrice: {execute_price:.4f}" + signal_detail=f"Strategy: {strategy_name}\nSignal: {signal_type}\nPrice: {execute_price:.4f}", ) except Exception as link_e: logger.warning(f"Strategy signal linkage notification failed: {link_e}") @@ -1330,7 +1449,7 @@ class TradingExecutor: ) except Exception: pass - + except Exception as e: logger.error(f"Strategy {strategy_id} loop error: {str(e)}") logger.error(traceback.format_exc()) @@ -1340,7 +1459,7 @@ class TradingExecutor: except Exception: pass time.sleep(5) - + except Exception as e: logger.error(f"Strategy {strategy_id} crashed: {str(e)}") logger.error(traceback.format_exc()) @@ -1360,7 +1479,7 @@ class TradingExecutor: append_strategy_log(strategy_id, "info", "策略执行循环已退出") except Exception: pass - + def _sync_positions_with_exchange(self, strategy_id: int, exchange: Any, symbol: str, market_type: str): """ [Depracated] No need to synchronize exchange positions in signal mode @@ -1373,7 +1492,7 @@ class TradingExecutor: with get_db_connection() as db: cursor = db.cursor() query = """ - SELECT + SELECT id, strategy_name, strategy_type, status, initial_capital, leverage, decide_interval, execution_mode, notification_config, @@ -1385,37 +1504,39 @@ class TradingExecutor: cursor.execute(query, (strategy_id,)) strategy = cursor.fetchone() cursor.close() - + if strategy: # Parse JSON fields - for field in ['indicator_config', 'trading_config', 'notification_config', 'ai_model_config']: + for field in ["indicator_config", "trading_config", "notification_config", "ai_model_config"]: if isinstance(strategy.get(field), str): try: strategy[field] = json.loads(strategy[field]) - except: + except Exception as e: + logger.debug(f"Failed to parse {field}: {e}") strategy[field] = {} - + # exchange_config: local deployment stores plaintext JSON - exchange_config_str = strategy.get('exchange_config', '{}') + exchange_config_str = strategy.get("exchange_config", "{}") if isinstance(exchange_config_str, str) and exchange_config_str: try: - strategy['exchange_config'] = json.loads(exchange_config_str) + strategy["exchange_config"] = json.loads(exchange_config_str) except Exception as e: logger.error(f"Strategy {strategy_id} failed to parse exchange_config: {str(e)}") # Try parsing JSON directly (backwards compatible) try: - strategy['exchange_config'] = json.loads(exchange_config_str) - except: - strategy['exchange_config'] = {} + strategy["exchange_config"] = json.loads(exchange_config_str) + except Exception as e: + logger.debug(f"Failed to parse exchange_config: {e}") + strategy["exchange_config"] = {} else: - strategy['exchange_config'] = {} - + strategy["exchange_config"] = {} + return strategy - + except Exception as e: logger.error(f"Failed to load strategy config: {str(e)}") return None - + def _is_strategy_running(self, strategy_id: int) -> bool: """ Check whether the strategy is running. @@ -1425,48 +1546,42 @@ class TradingExecutor: # 1. Check database status with get_db_connection() as db: cursor = db.cursor() - cursor.execute( - "SELECT status FROM qd_strategies_trading WHERE id = %s", - (strategy_id,) - ) + cursor.execute("SELECT status FROM qd_strategies_trading WHERE id = %s", (strategy_id,)) result = cursor.fetchone() cursor.close() - db_status = result and result.get('status') == 'running' - + db_status = result and result.get("status") == "running" + # 2. Check if the thread is actually running with self.lock: thread = self.running_strategies.get(strategy_id) thread_running = thread is not None and thread.is_alive() - + # 3. If the database status is running but the thread is not running, it means the status is inconsistent (may be recovery failure after restart) if db_status and not thread_running: - logger.warning(f"Strategy {strategy_id} status mismatch: DB=running but thread not running. Updating DB status to stopped.") + logger.warning( + f"Strategy {strategy_id} status mismatch: DB=running but thread not running. Updating DB status to stopped." + ) # Update the database status to stopped to avoid the policy "zombie" state try: with get_db_connection() as db: cursor = db.cursor() cursor.execute( - "UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s", - (strategy_id,) + "UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s", (strategy_id,) ) db.commit() cursor.close() except Exception as e: logger.error(f"Failed to update strategy {strategy_id} status to stopped: {e}") return False - + # 4. Return True only if the database status and thread status are consistent return db_status and thread_running except Exception as e: logger.error(f"Error checking strategy {strategy_id} running status: {e}") return False - + def _init_exchange( - self, - exchange_config: Dict[str, Any], - market_type: str = None, - leverage: float = None, - strategy_id: int = None + self, exchange_config: Dict[str, Any], market_type: str = None, leverage: float = None, strategy_id: int = None ) -> Any: """ Placeholder: No exchange SDK instance is created within the strategy thread. @@ -1476,10 +1591,12 @@ class TradingExecutor: Candlestick charts/current prices are provided by data layers such as `KlineService` and `DataSourceFactory` (this layer may use ccxt to display market data, decoupled from order placement). """ return None - - def _fetch_latest_kline(self, symbol: str, timeframe: str, limit: int = 500, market_category: str = 'Crypto') -> List[Dict[str, Any]]: + + def _fetch_latest_kline( + self, symbol: str, timeframe: str, limit: int = 500, market_category: str = "Crypto" + ) -> List[Dict[str, Any]]: """Get the latest K-line data (get it from cache first) - + Args: symbol: trading pair/symbol timeframe: time period @@ -1489,19 +1606,17 @@ class TradingExecutor: try: # Use KlineService to obtain K-line data (automatically handle cache) return self.kline_service.get_kline( - market=market_category, - symbol=symbol, - timeframe=timeframe, - limit=limit, - before_time=int(time.time()) + market=market_category, symbol=symbol, timeframe=timeframe, limit=limit, before_time=int(time.time()) ) except Exception as e: logger.error(f"Failed to fetch K-lines for {market_category}:{symbol}: {str(e)}") return [] - - def _fetch_current_price(self, exchange: Any, symbol: str, market_type: str = None, market_category: str = 'Crypto') -> Optional[float]: + + def _fetch_current_price( + self, exchange: Any, symbol: str, market_type: str = None, market_category: str = "Crypto" + ) -> Optional[float]: """Get the current price (select the correct data source based on market_category) - + Args: exchange: exchange instance (None in signal mode) symbol: trading pair/symbol @@ -1523,13 +1638,13 @@ class TradingExecutor: del self._price_cache[cache_key] except Exception: pass - + try: # Select the correct data source based on market_category # Support: Crypto, USStock, Forex, Futures ticker = DataSourceFactory.get_ticker(market_category, symbol) if ticker: - price = float(ticker.get('last') or ticker.get('close') or 0) + price = float(ticker.get("last") or ticker.get("close") or 0) if price > 0: if cache_key and self._price_cache_ttl_sec > 0: try: @@ -1540,7 +1655,7 @@ class TradingExecutor: return price except Exception as e: logger.warning(f"Failed to fetch price for {market_category}:{symbol}: {e}") - + return None def _server_side_stop_loss_signal( @@ -1562,8 +1677,8 @@ class TradingExecutor: if trading_config is None: return None - enabled = trading_config.get('enable_server_side_stop_loss', True) - if str(enabled).lower() in ['0', 'false', 'no', 'off']: + enabled = trading_config.get("enable_server_side_stop_loss", True) + if str(enabled).lower() in ["0", "false", "no", "off"]: return None # Get the current position (use local database records as risk control basis) @@ -1572,16 +1687,16 @@ class TradingExecutor: return None pos = current_positions[0] - side = pos.get('side') - if side not in ['long', 'short']: + side = pos.get("side") + if side not in ["long", "short"]: return None - entry_price = float(pos.get('entry_price', 0) or 0) + entry_price = float(pos.get("entry_price", 0) or 0) if entry_price <= 0 or current_price <= 0: return None # Stop-loss is config-driven: if stop_loss_pct is not set or <= 0, do NOT stop-loss. - sl_cfg = trading_config.get('stop_loss_pct', 0) + sl_cfg = trading_config.get("stop_loss_pct", 0) sl = 0.0 try: sl_cfg = float(sl_cfg or 0) @@ -1606,28 +1721,28 @@ class TradingExecutor: candle_ts = int(now_ts // tf) * tf # Bulls: Falling below the stop loss line - if side == 'long': + if side == "long": stop_line = entry_price * (1 - sl) if current_price <= stop_line: return { - 'type': 'close_long', - 'trigger_price': 0, # Trigger immediately (controlled by exit_trigger_mode) - 'position_size': 0, - 'timestamp': candle_ts, - 'reason': 'server_stop_loss', - 'stop_loss_price': stop_line, + "type": "close_long", + "trigger_price": 0, # Trigger immediately (controlled by exit_trigger_mode) + "position_size": 0, + "timestamp": candle_ts, + "reason": "server_stop_loss", + "stop_loss_price": stop_line, } # Short: Stop loss line broken - elif side == 'short': + elif side == "short": stop_line = entry_price * (1 + sl) if current_price >= stop_line: return { - 'type': 'close_short', - 'trigger_price': 0, - 'position_size': 0, - 'timestamp': candle_ts, - 'reason': 'server_stop_loss', - 'stop_loss_price': stop_line, + "type": "close_short", + "trigger_price": 0, + "position_size": 0, + "timestamp": candle_ts, + "reason": "server_stop_loss", + "stop_loss_price": stop_line, } return None @@ -1663,20 +1778,20 @@ class TradingExecutor: return None pos = current_positions[0] - side = (pos.get('side') or '').strip().lower() - if side not in ['long', 'short']: + side = (pos.get("side") or "").strip().lower() + if side not in ["long", "short"]: return None - entry_price = float(pos.get('entry_price', 0) or 0) + entry_price = float(pos.get("entry_price", 0) or 0) if entry_price <= 0 or current_price <= 0: return None lev = max(1.0, float(leverage or 1.0)) - tp = self._to_ratio(trading_config.get('take_profit_pct')) - trailing_enabled = bool(trading_config.get('trailing_enabled')) - trailing_pct = self._to_ratio(trading_config.get('trailing_stop_pct')) - trailing_act = self._to_ratio(trading_config.get('trailing_activation_pct')) + tp = self._to_ratio(trading_config.get("take_profit_pct")) + trailing_enabled = bool(trading_config.get("trailing_enabled")) + trailing_pct = self._to_ratio(trading_config.get("trailing_stop_pct")) + trailing_act = self._to_ratio(trading_config.get("trailing_activation_pct")) tp_eff = (tp / lev) if tp > 0 else 0.0 trailing_pct_eff = (trailing_pct / lev) if trailing_pct > 0 else 0.0 @@ -1695,11 +1810,11 @@ class TradingExecutor: # Highest/lowest tracking (persisted in DB so restart continues trailing correctly) try: - hp = float(pos.get('highest_price') or 0.0) + hp = float(pos.get("highest_price") or 0.0) except Exception: hp = 0.0 try: - lp = float(pos.get('lowest_price') or 0.0) + lp = float(pos.get("lowest_price") or 0.0) except Exception: lp = 0.0 @@ -1715,9 +1830,9 @@ class TradingExecutor: try: self._update_position( strategy_id=strategy_id, - symbol=pos.get('symbol') or symbol, + symbol=pos.get("symbol") or symbol, side=side, - size=float(pos.get('size') or 0.0), + size=float(pos.get("size") or 0.0), entry_price=entry_price, current_price=float(current_price), highest_price=hp, @@ -1728,7 +1843,7 @@ class TradingExecutor: # 1) Trailing stop if trailing_enabled and trailing_pct_eff > 0: - if side == 'long': + if side == "long": active = True if trailing_act_eff > 0: active = hp >= entry_price * (1 + trailing_act_eff) @@ -1736,13 +1851,13 @@ class TradingExecutor: stop_line = hp * (1 - trailing_pct_eff) if current_price <= stop_line: return { - 'type': 'close_long', - 'trigger_price': 0, - 'position_size': 0, - 'timestamp': candle_ts, - 'reason': 'server_trailing_stop', - 'trailing_stop_price': stop_line, - 'highest_price': hp, + "type": "close_long", + "trigger_price": 0, + "position_size": 0, + "timestamp": candle_ts, + "reason": "server_trailing_stop", + "trailing_stop_price": stop_line, + "highest_price": hp, } else: active = True @@ -1752,140 +1867,149 @@ class TradingExecutor: stop_line = lp * (1 + trailing_pct_eff) if current_price >= stop_line: return { - 'type': 'close_short', - 'trigger_price': 0, - 'position_size': 0, - 'timestamp': candle_ts, - 'reason': 'server_trailing_stop', - 'trailing_stop_price': stop_line, - 'lowest_price': lp, + "type": "close_short", + "trigger_price": 0, + "position_size": 0, + "timestamp": candle_ts, + "reason": "server_trailing_stop", + "trailing_stop_price": stop_line, + "lowest_price": lp, } # 2) Fixed take-profit (only when trailing is disabled) if tp_eff > 0: - if side == 'long': + if side == "long": tp_line = entry_price * (1 + tp_eff) if current_price >= tp_line: return { - 'type': 'close_long', - 'trigger_price': 0, - 'position_size': 0, - 'timestamp': candle_ts, - 'reason': 'server_take_profit', - 'take_profit_price': tp_line, + "type": "close_long", + "trigger_price": 0, + "position_size": 0, + "timestamp": candle_ts, + "reason": "server_take_profit", + "take_profit_price": tp_line, } else: tp_line = entry_price * (1 - tp_eff) if current_price <= tp_line: return { - 'type': 'close_short', - 'trigger_price': 0, - 'position_size': 0, - 'timestamp': candle_ts, - 'reason': 'server_take_profit', - 'take_profit_price': tp_line, + "type": "close_short", + "trigger_price": 0, + "position_size": 0, + "timestamp": candle_ts, + "reason": "server_take_profit", + "take_profit_price": tp_line, } return None except Exception: return None - + def _klines_to_dataframe(self, klines: List[Dict[str, Any]]) -> pd.DataFrame: """Convert K-line data to DataFrame""" if not klines: # Returns an empty DataFrame with the correct columns - return pd.DataFrame(columns=['open', 'high', 'low', 'close', 'volume']) - + return pd.DataFrame(columns=["open", "high", "low", "close", "volume"]) + # Create DataFrame df = pd.DataFrame(klines) - + # Convert time column. # IMPORTANT: use UTC tz-aware index to avoid timezone skew when computing candle boundaries. - if 'time' in df.columns: - df['time'] = pd.to_datetime(df['time'], unit='s', utc=True) - df = df.set_index('time') - elif 'timestamp' in df.columns: - df['timestamp'] = pd.to_datetime(df['timestamp'], unit='s', utc=True) - df = df.set_index('timestamp') - + if "time" in df.columns: + df["time"] = pd.to_datetime(df["time"], unit="s", utc=True) + df = df.set_index("time") + elif "timestamp" in df.columns: + df["timestamp"] = pd.to_datetime(df["timestamp"], unit="s", utc=True) + df = df.set_index("timestamp") + # Make sure to include only the columns you need - required_columns = ['open', 'high', 'low', 'close', 'volume'] + required_columns = ["open", "high", "low", "close", "volume"] available_columns = [col for col in required_columns if col in df.columns] if not available_columns: logger.warning("K-lines are missing required columns") return pd.DataFrame(columns=required_columns) - + df = df[available_columns] - + # Cast all numeric columns to float64 type - for col in ['open', 'high', 'low', 'close', 'volume']: + for col in ["open", "high", "low", "close", "volume"]: if col in df.columns: # Convert to numeric type first, then cast to float64 - df[col] = pd.to_numeric(df[col], errors='coerce').astype('float64') - + df[col] = pd.to_numeric(df[col], errors="coerce").astype("float64") + # Delete rows containing NaN df = df.dropna() - + return df - def _update_dataframe_with_current_price(self, df: pd.DataFrame, current_price: float, timeframe: str) -> pd.DataFrame: + def _update_dataframe_with_current_price( + self, df: pd.DataFrame, current_price: float, timeframe: str + ) -> pd.DataFrame: """ Update the last bar of the DataFrame using the current price (for real-time calculations) """ if df is None or len(df) == 0: return df - + try: # Get the time of the last K-line last_time = df.index[-1] - + # Calculate the K-line starting time corresponding to the current time from app.data_sources.base import TIMEFRAME_SECONDS + timeframe_key = timeframe if timeframe_key not in TIMEFRAME_SECONDS: timeframe_key = str(timeframe_key).upper() if timeframe_key not in TIMEFRAME_SECONDS: timeframe_key = str(timeframe_key).lower() tf_seconds = TIMEFRAME_SECONDS.get(timeframe_key, 60) - + # Use epoch seconds directly to avoid naive datetime timezone conversion issues. last_ts = float(last_time.timestamp()) now_ts = float(time.time()) - + # Calculate the starting time of the K-line to which the current price belongs current_period_start = int(now_ts // tf_seconds) * tf_seconds - + # Check whether the last K-line is for the current cycle if abs(last_ts - current_period_start) < 2: # Update the last one - df.iloc[-1, df.columns.get_loc('close')] = current_price - df.iloc[-1, df.columns.get_loc('high')] = max(df.iloc[-1]['high'], current_price) - df.iloc[-1, df.columns.get_loc('low')] = min(df.iloc[-1]['low'], current_price) + df.iloc[-1, df.columns.get_loc("close")] = current_price + df.iloc[-1, df.columns.get_loc("high")] = max(df.iloc[-1]["high"], current_price) + df.iloc[-1, df.columns.get_loc("low")] = min(df.iloc[-1]["low"], current_price) elif current_period_start > last_ts: # Added new bank - new_row = pd.DataFrame({ - 'open': [current_price], - 'high': [current_price], - 'low': [current_price], - 'close': [current_price], - 'volume': [0.0] - }, index=[pd.to_datetime(current_period_start, unit='s', utc=True)]) - + new_row = pd.DataFrame( + { + "open": [current_price], + "high": [current_price], + "low": [current_price], + "close": [current_price], + "volume": [0.0], + }, + index=[pd.to_datetime(current_period_start, unit="s", utc=True)], + ) + df = pd.concat([df, new_row]) - + return df - + except Exception as e: logger.error(f"Failed to update realtime candle: {str(e)}") return df - + def _execute_indicator_with_prices( - self, indicator_code: str, df: pd.DataFrame, trading_config: Dict[str, Any], + self, + indicator_code: str, + df: pd.DataFrame, + trading_config: Dict[str, Any], initial_highest_price: float = 0.0, initial_position: int = 0, initial_avg_entry_price: float = 0.0, initial_position_count: int = 0, - initial_last_add_price: float = 0.0 + initial_last_add_price: float = 0.0, ) -> Optional[Dict[str, Any]]: """ Execute the indicator code and extract the signal and price to be triggered @@ -1893,371 +2017,453 @@ class TradingExecutor: try: # Execution indicator code executed_df, exec_env = self._execute_indicator_df( - indicator_code, df, trading_config, + indicator_code, + df, + trading_config, initial_highest_price=initial_highest_price, initial_position=initial_position, initial_avg_entry_price=initial_avg_entry_price, initial_position_count=initial_position_count, - initial_last_add_price=initial_last_add_price + initial_last_add_price=initial_last_add_price, ) if executed_df is None: return None - + # Extract the latest highest_price - new_highest_price = exec_env.get('highest_price', 0.0) - + new_highest_price = exec_env.get("highest_price", 0.0) + # Extract the time of the last K-line - last_kline_time = int(df.index[-1].timestamp()) if hasattr(df.index[-1], 'timestamp') else int(time.time()) - + last_kline_time = int(df.index[-1].timestamp()) if hasattr(df.index[-1], "timestamp") else int(time.time()) + # Extract the signal to be triggered pending_signals = [] - + # Supported indicator signal formats: # - Preferred (simple): df['buy'], df['sell'] as boolean # - Internal (4-way): df['open_long'], df['close_long'], df['open_short'], df['close_short'] as boolean - if all(col in executed_df.columns for col in ['buy', 'sell']) and not all(col in executed_df.columns for col in ['open_long', 'close_long', 'open_short', 'close_short']): + if all(col in executed_df.columns for col in ["buy", "sell"]) and not all( + col in executed_df.columns for col in ["open_long", "close_long", "open_short", "close_short"] + ): # Normalize buy/sell into 4-way columns for execution. - td = trading_config.get('trade_direction', trading_config.get('tradeDirection', 'both')) - td = str(td or 'both').lower() - if td not in ['long', 'short', 'both']: - td = 'both' + td = trading_config.get("trade_direction", trading_config.get("tradeDirection", "both")) + td = str(td or "both").lower() + if td not in ["long", "short", "both"]: + td = "both" - buy = executed_df['buy'].fillna(False).astype(bool) - sell = executed_df['sell'].fillna(False).astype(bool) + buy = executed_df["buy"].fillna(False).astype(bool) + sell = executed_df["sell"].fillna(False).astype(bool) executed_df = executed_df.copy() - if td == 'long': - executed_df['open_long'] = buy - executed_df['close_long'] = sell - executed_df['open_short'] = False - executed_df['close_short'] = False - elif td == 'short': - executed_df['open_long'] = False - executed_df['close_long'] = False - executed_df['open_short'] = sell - executed_df['close_short'] = buy + if td == "long": + executed_df["open_long"] = buy + executed_df["close_long"] = sell + executed_df["open_short"] = False + executed_df["close_short"] = False + elif td == "short": + executed_df["open_long"] = False + executed_df["close_long"] = False + executed_df["open_short"] = sell + executed_df["close_short"] = buy else: - executed_df['open_long'] = buy - executed_df['close_short'] = buy - executed_df['open_short'] = sell - executed_df['close_long'] = sell + executed_df["open_long"] = buy + executed_df["close_short"] = buy + executed_df["open_short"] = sell + executed_df["close_long"] = sell # Check for 4-way columns after normalization - if all(col in executed_df.columns for col in ['open_long', 'close_long', 'open_short', 'close_short']): + if all(col in executed_df.columns for col in ["open_long", "close_long", "open_short", "close_short"]): # Optimization point 3: Prevent “signal flicker” (Repainting) - signal_mode = trading_config.get('signal_mode', 'confirmed') # 'confirmed' or 'aggressive' - exit_signal_mode = trading_config.get('exit_signal_mode', 'aggressive') # 'confirmed' or 'aggressive' - + signal_mode = trading_config.get("signal_mode", "confirmed") # 'confirmed' or 'aggressive' + exit_signal_mode = trading_config.get("exit_signal_mode", "aggressive") # 'confirmed' or 'aggressive' + entry_check_set = set() exit_check_set = set() - + if len(executed_df) > 1: # Always check the last completed K-line entry_check_set.add(len(executed_df) - 2) exit_check_set.add(len(executed_df) - 2) - - if signal_mode == 'aggressive' and len(executed_df) > 0: + + if signal_mode == "aggressive" and len(executed_df) > 0: entry_check_set.add(len(executed_df) - 1) - - if exit_signal_mode == 'aggressive' and len(executed_df) > 0: + + if exit_signal_mode == "aggressive" and len(executed_df) > 0: exit_check_set.add(len(executed_df) - 1) - + # Traverse the index uniformly (preserving deterministic ordering) check_indices = sorted(entry_check_set.union(exit_check_set), reverse=True) - + for idx in check_indices: # Get the closing price of the K-line (as the default trigger price) - close_price = float(executed_df['close'].iloc[idx]) + close_price = float(executed_df["close"].iloc[idx]) # The timestamp of the signal - signal_timestamp = int(executed_df.index[idx].timestamp()) if hasattr(executed_df.index[idx], 'timestamp') else last_kline_time - + signal_timestamp = ( + int(executed_df.index[idx].timestamp()) + if hasattr(executed_df.index[idx], "timestamp") + else last_kline_time + ) + # Open long signal (only checked in entry_check_set) - if idx in entry_check_set and executed_df['open_long'].iloc[idx]: + if idx in entry_check_set and executed_df["open_long"].iloc[idx]: trigger_price = close_price position_size = 0.08 - if 'position_size' in executed_df.columns: - pos_size = executed_df['position_size'].iloc[idx] + if "position_size" in executed_df.columns: + pos_size = executed_df["position_size"].iloc[idx] if pos_size > 0: position_size = float(pos_size) - - if not any(s['type'] == 'open_long' and s.get('timestamp') == signal_timestamp for s in pending_signals): - pending_signals.append({ - 'type': 'open_long', - 'trigger_price': trigger_price, - 'position_size': position_size, - 'timestamp': signal_timestamp - }) - + + if not any( + s["type"] == "open_long" and s.get("timestamp") == signal_timestamp for s in pending_signals + ): + pending_signals.append( + { + "type": "open_long", + "trigger_price": trigger_price, + "position_size": position_size, + "timestamp": signal_timestamp, + } + ) + # Hirata signal - if idx in exit_check_set and executed_df['close_long'].iloc[idx]: + if idx in exit_check_set and executed_df["close_long"].iloc[idx]: trigger_price = close_price - if not any(s['type'] == 'close_long' and s.get('timestamp') == signal_timestamp for s in pending_signals): - pending_signals.append({ - 'type': 'close_long', - 'trigger_price': trigger_price, - 'position_size': 0, - 'timestamp': signal_timestamp - }) - + if not any( + s["type"] == "close_long" and s.get("timestamp") == signal_timestamp + for s in pending_signals + ): + pending_signals.append( + { + "type": "close_long", + "trigger_price": trigger_price, + "position_size": 0, + "timestamp": signal_timestamp, + } + ) + # Open short signal - if idx in entry_check_set and executed_df['open_short'].iloc[idx]: + if idx in entry_check_set and executed_df["open_short"].iloc[idx]: trigger_price = close_price position_size = 0.08 - if 'position_size' in executed_df.columns: - pos_size = executed_df['position_size'].iloc[idx] + if "position_size" in executed_df.columns: + pos_size = executed_df["position_size"].iloc[idx] if pos_size > 0: position_size = float(pos_size) - - if not any(s['type'] == 'open_short' and s.get('timestamp') == signal_timestamp for s in pending_signals): - pending_signals.append({ - 'type': 'open_short', - 'trigger_price': trigger_price, - 'position_size': position_size, - 'timestamp': signal_timestamp - }) - + + if not any( + s["type"] == "open_short" and s.get("timestamp") == signal_timestamp + for s in pending_signals + ): + pending_signals.append( + { + "type": "open_short", + "trigger_price": trigger_price, + "position_size": position_size, + "timestamp": signal_timestamp, + } + ) + # flat signal - if idx in exit_check_set and executed_df['close_short'].iloc[idx]: + if idx in exit_check_set and executed_df["close_short"].iloc[idx]: trigger_price = close_price - if not any(s['type'] == 'close_short' and s.get('timestamp') == signal_timestamp for s in pending_signals): - pending_signals.append({ - 'type': 'close_short', - 'trigger_price': trigger_price, - 'position_size': 0, - 'timestamp': signal_timestamp - }) - + if not any( + s["type"] == "close_short" and s.get("timestamp") == signal_timestamp + for s in pending_signals + ): + pending_signals.append( + { + "type": "close_short", + "trigger_price": trigger_price, + "position_size": 0, + "timestamp": signal_timestamp, + } + ) + # add bull signal - if idx in entry_check_set and 'add_long' in executed_df.columns and executed_df['add_long'].iloc[idx]: + if ( + idx in entry_check_set + and "add_long" in executed_df.columns + and executed_df["add_long"].iloc[idx] + ): trigger_price = close_price position_size = 0.06 - if 'position_size' in executed_df.columns: - pos_size = executed_df['position_size'].iloc[idx] + if "position_size" in executed_df.columns: + pos_size = executed_df["position_size"].iloc[idx] if pos_size > 0: position_size = float(pos_size) - if not any(s['type'] == 'add_long' and s.get('timestamp') == signal_timestamp for s in pending_signals): - pending_signals.append({ - 'type': 'add_long', - 'trigger_price': trigger_price, - 'position_size': position_size, - 'timestamp': signal_timestamp - }) - + if not any( + s["type"] == "add_long" and s.get("timestamp") == signal_timestamp for s in pending_signals + ): + pending_signals.append( + { + "type": "add_long", + "trigger_price": trigger_price, + "position_size": position_size, + "timestamp": signal_timestamp, + } + ) + # Air conditioning signal - if idx in entry_check_set and 'add_short' in executed_df.columns and executed_df['add_short'].iloc[idx]: + if ( + idx in entry_check_set + and "add_short" in executed_df.columns + and executed_df["add_short"].iloc[idx] + ): trigger_price = close_price position_size = 0.06 - if 'position_size' in executed_df.columns: - pos_size = executed_df['position_size'].iloc[idx] + if "position_size" in executed_df.columns: + pos_size = executed_df["position_size"].iloc[idx] if pos_size > 0: position_size = float(pos_size) - if not any(s['type'] == 'add_short' and s.get('timestamp') == signal_timestamp for s in pending_signals): - pending_signals.append({ - 'type': 'add_short', - 'trigger_price': trigger_price, - 'position_size': position_size, - 'timestamp': signal_timestamp - }) + if not any( + s["type"] == "add_short" and s.get("timestamp") == signal_timestamp for s in pending_signals + ): + pending_signals.append( + { + "type": "add_short", + "trigger_price": trigger_price, + "position_size": position_size, + "timestamp": signal_timestamp, + } + ) # Reduce / scale-out signals (optional) # These are used by position management rules (trend/adverse reduce) and should be treated as exits. - if idx in exit_check_set and 'reduce_long' in executed_df.columns and executed_df['reduce_long'].iloc[idx]: + if ( + idx in exit_check_set + and "reduce_long" in executed_df.columns + and executed_df["reduce_long"].iloc[idx] + ): trigger_price = close_price reduce_pct = 0.1 - if 'reduce_size' in executed_df.columns: + if "reduce_size" in executed_df.columns: try: - reduce_pct = float(executed_df['reduce_size'].iloc[idx] or 0) + reduce_pct = float(executed_df["reduce_size"].iloc[idx] or 0) except Exception: reduce_pct = 0.1 - elif 'position_size' in executed_df.columns: + elif "position_size" in executed_df.columns: try: - reduce_pct = float(executed_df['position_size'].iloc[idx] or 0) + reduce_pct = float(executed_df["position_size"].iloc[idx] or 0) except Exception: reduce_pct = 0.1 if reduce_pct <= 0: reduce_pct = 0.1 - if not any(s['type'] == 'reduce_long' and s.get('timestamp') == signal_timestamp for s in pending_signals): - pending_signals.append({ - 'type': 'reduce_long', - 'trigger_price': trigger_price, - 'position_size': reduce_pct, - 'timestamp': signal_timestamp - }) + if not any( + s["type"] == "reduce_long" and s.get("timestamp") == signal_timestamp + for s in pending_signals + ): + pending_signals.append( + { + "type": "reduce_long", + "trigger_price": trigger_price, + "position_size": reduce_pct, + "timestamp": signal_timestamp, + } + ) - if idx in exit_check_set and 'reduce_short' in executed_df.columns and executed_df['reduce_short'].iloc[idx]: + if ( + idx in exit_check_set + and "reduce_short" in executed_df.columns + and executed_df["reduce_short"].iloc[idx] + ): trigger_price = close_price reduce_pct = 0.1 - if 'reduce_size' in executed_df.columns: + if "reduce_size" in executed_df.columns: try: - reduce_pct = float(executed_df['reduce_size'].iloc[idx] or 0) + reduce_pct = float(executed_df["reduce_size"].iloc[idx] or 0) except Exception: reduce_pct = 0.1 - elif 'position_size' in executed_df.columns: + elif "position_size" in executed_df.columns: try: - reduce_pct = float(executed_df['position_size'].iloc[idx] or 0) + reduce_pct = float(executed_df["position_size"].iloc[idx] or 0) except Exception: reduce_pct = 0.1 if reduce_pct <= 0: reduce_pct = 0.1 - if not any(s['type'] == 'reduce_short' and s.get('timestamp') == signal_timestamp for s in pending_signals): - pending_signals.append({ - 'type': 'reduce_short', - 'trigger_price': trigger_price, - 'position_size': reduce_pct, - 'timestamp': signal_timestamp - }) - + if not any( + s["type"] == "reduce_short" and s.get("timestamp") == signal_timestamp + for s in pending_signals + ): + pending_signals.append( + { + "type": "reduce_short", + "trigger_price": trigger_price, + "position_size": reduce_pct, + "timestamp": signal_timestamp, + } + ) + return { - 'pending_signals': pending_signals, - 'last_kline_time': last_kline_time, - 'new_highest_price': new_highest_price + "pending_signals": pending_signals, + "last_kline_time": last_kline_time, + "new_highest_price": new_highest_price, } - + except Exception as e: logger.error(f"Failed to execute indicator and extract prices: {str(e)}") logger.error(traceback.format_exc()) return None - + def _execute_indicator_df( - self, indicator_code: str, df: pd.DataFrame, trading_config: Dict[str, Any], + self, + indicator_code: str, + df: pd.DataFrame, + trading_config: Dict[str, Any], initial_highest_price: float = 0.0, initial_position: int = 0, initial_avg_entry_price: float = 0.0, initial_position_count: int = 0, - initial_last_add_price: float = 0.0 + initial_last_add_price: float = 0.0, ) -> tuple[Optional[pd.DataFrame], dict]: """Execute the indicator code and return the executed DataFrame and execution environment.""" try: # Ensure that all numeric columns of the DataFrame are of type float64 df = df.copy() - for col in ['open', 'high', 'low', 'close', 'volume']: + for col in ["open", "high", "low", "close", "volume"]: if col in df.columns: if not pd.api.types.is_numeric_dtype(df[col]): - df[col] = pd.to_numeric(df[col], errors='coerce').astype('float64') + df[col] = pd.to_numeric(df[col], errors="coerce").astype("float64") else: - df[col] = df[col].astype('float64') - + df[col] = df[col].astype("float64") + # Delete rows containing NaN df = df.dropna() - + if len(df) == 0: logger.warning("DataFrame is empty; cannot execute indicator script") return None, {} - + # Initialize signal Series - signals = pd.Series(0, index=df.index, dtype='float64') - + signals = pd.Series(0, index=df.index, dtype="float64") + # Prepare execution environment # Expose the full trading config to indicator scripts so frontend parameters # (scale-in/out, position sizing, risk params) can be used directly. # Also provide a backtest-modal compatible nested config object: cfg.risk/cfg.scale/cfg.position. tc = dict(trading_config or {}) cfg = self._build_cfg_from_trading_config(tc) - + # === Indicator parameter support === # Get user-set indicator parameters from trading_config - user_indicator_params = tc.get('indicator_params', {}) + user_indicator_params = tc.get("indicator_params", {}) # Parse the parameters declared in the indicator code declared_params = IndicatorParamsParser.parse_params(indicator_code) # Merge parameters (user values ​​take precedence, otherwise default values ​​are used) merged_params = IndicatorParamsParser.merge_params(declared_params, user_indicator_params) - + # === Indicator caller support === # Get user ID and indicator ID (for call_indicator permission check) - user_id = tc.get('user_id', 1) - indicator_id = tc.get('indicator_id') + user_id = tc.get("user_id", 1) + indicator_id = tc.get("indicator_id") indicator_caller = IndicatorCaller(user_id, indicator_id) - + local_vars = { - 'df': df, - 'open': df['open'].astype('float64'), - 'high': df['high'].astype('float64'), - 'low': df['low'].astype('float64'), - 'close': df['close'].astype('float64'), - 'volume': df['volume'].astype('float64'), - 'signals': signals, - 'np': np, - 'pd': pd, - 'trading_config': tc, - 'config': tc, # alias - 'cfg': cfg, # normalized nested config - 'params': merged_params, # Indicator parameters (new) - 'call_indicator': indicator_caller.call_indicator, # Call other indicators (new) - 'leverage': float(trading_config.get('leverage', 1)), - 'initial_capital': float(trading_config.get('initial_capital', 1000)), - 'commission': 0.001, - 'trade_direction': str(trading_config.get('trade_direction', 'long')), - 'initial_highest_price': float(initial_highest_price), - 'initial_position': int(initial_position), - 'initial_avg_entry_price': float(initial_avg_entry_price), - 'initial_position_count': int(initial_position_count), - 'initial_last_add_price': float(initial_last_add_price) + "df": df, + "open": df["open"].astype("float64"), + "high": df["high"].astype("float64"), + "low": df["low"].astype("float64"), + "close": df["close"].astype("float64"), + "volume": df["volume"].astype("float64"), + "signals": signals, + "np": np, + "pd": pd, + "trading_config": tc, + "config": tc, # alias + "cfg": cfg, # normalized nested config + "params": merged_params, # Indicator parameters (new) + "call_indicator": indicator_caller.call_indicator, # Call other indicators (new) + "leverage": float(trading_config.get("leverage", 1)), + "initial_capital": float(trading_config.get("initial_capital", 1000)), + "commission": 0.001, + "trade_direction": str(trading_config.get("trade_direction", "long")), + "initial_highest_price": float(initial_highest_price), + "initial_position": int(initial_position), + "initial_avg_entry_price": float(initial_avg_entry_price), + "initial_position_count": int(initial_position_count), + "initial_last_add_price": float(initial_last_add_price), } - + import builtins + def safe_import(name, *args, **kwargs): - allowed_modules = ['numpy', 'pandas', 'math', 'json', 'time'] - if name in allowed_modules or name.split('.')[0] in allowed_modules: + allowed_modules = ["numpy", "pandas", "math", "json", "time"] + if name in allowed_modules or name.split(".")[0] in allowed_modules: return builtins.__import__(name, *args, **kwargs) raise ImportError(f"Importing modules is not allowed: {name}") - - safe_builtins = {k: getattr(builtins, k) for k in dir(builtins) - if not k.startswith('_') and k not in [ - 'eval', 'exec', 'compile', 'open', 'input', - 'help', 'exit', 'quit', '__import__', - 'copyright', 'credits', 'license' - ]} - safe_builtins['__import__'] = safe_import - + + safe_builtins = { + k: getattr(builtins, k) + for k in dir(builtins) + if not k.startswith("_") + and k + not in [ + "eval", + "exec", + "compile", + "open", + "input", + "help", + "exit", + "quit", + "__import__", + "copyright", + "credits", + "license", + ] + } + safe_builtins["__import__"] = safe_import + exec_env = local_vars.copy() - exec_env['__builtins__'] = safe_builtins - + exec_env["__builtins__"] = safe_builtins + pre_import_code = "import numpy as np\nimport pandas as pd\n" exec(pre_import_code, exec_env) - + # Compatibility fix: Convert the fillna(method=...) syntax of the old version of pandas to the new version of the syntax # pandas 2.0+ removes the method parameter of fillna(), you need to use ffill() or bfill() # Old syntax: df.fillna(method='ffill') or df.fillna(method="ffill") # New syntax: df.ffill() import re + compatibility_fixed_code = indicator_code # Replace fillna(method='ffill') or fillna(method="ffill") with ffill() compatibility_fixed_code = re.sub( - r'\.fillna\(\s*method\s*=\s*["\']ffill["\']\s*\)', - '.ffill()', - compatibility_fixed_code + r'\.fillna\(\s*method\s*=\s*["\']ffill["\']\s*\)', ".ffill()", compatibility_fixed_code ) # Replace fillna(method='bfill') or fillna(method="bfill") with bfill() compatibility_fixed_code = re.sub( - r'\.fillna\(\s*method\s*=\s*["\']bfill["\']\s*\)', - '.bfill()', - compatibility_fixed_code + r'\.fillna\(\s*method\s*=\s*["\']bfill["\']\s*\)', ".bfill()", compatibility_fixed_code ) - + # safe_exec_code here is assumed to already exist exec(compatibility_fixed_code, exec_env) - - executed_df = exec_env.get('df', df) + + executed_df = exec_env.get("df", df) # Validation: if chart signals are provided, df['buy']/df['sell'] must exist for execution normalization. - output_obj = exec_env.get('output') - has_output_signals = isinstance(output_obj, dict) and isinstance(output_obj.get('signals'), list) and len(output_obj.get('signals')) > 0 - if has_output_signals and not all(col in executed_df.columns for col in ['buy', 'sell']): + output_obj = exec_env.get("output") + has_output_signals = ( + isinstance(output_obj, dict) + and isinstance(output_obj.get("signals"), list) + and len(output_obj.get("signals")) > 0 + ) + if has_output_signals and not all(col in executed_df.columns for col in ["buy", "sell"]): raise ValueError( "Invalid indicator script: output['signals'] is provided, but df['buy'] and df['sell'] are missing. " "Please set df['buy'] and df['sell'] as boolean columns (len == len(df))." ) - + return executed_df, exec_env - + except Exception as e: logger.error(f"Failed to execute indicator script: {str(e)}") logger.error(traceback.format_exc()) return None, {} - - def _execute_indicator(self, indicator_code: str, df: pd.DataFrame, trading_config: Dict[str, Any]) -> Optional[Any]: + + def _execute_indicator( + self, indicator_code: str, df: pd.DataFrame, trading_config: Dict[str, Any] + ) -> Optional[Any]: """Compatible with older versions""" executed_df, _ = self._execute_indicator_df(indicator_code, df, trading_config) if executed_df is None: @@ -2276,13 +2482,13 @@ class TradingExecutor: """ cursor.execute(query, (strategy_id,)) all_positions = cursor.fetchall() - + matched_positions = [] for pos in all_positions: # Simplify matching logic: only match prefixes - if pos['symbol'].split(':')[0] == symbol.split(':')[0]: + if pos["symbol"].split(":")[0] == symbol.split(":")[0]: matched_positions.append(pos) - + cursor.close() return matched_positions except Exception as e: @@ -2292,7 +2498,7 @@ class TradingExecutor: def _execute_trading_logic(self, *args, **kwargs): """Deprecated.""" pass - + def _execute_signal( self, strategy_id: int, @@ -2306,12 +2512,12 @@ class TradingExecutor: trade_direction: str, leverage: int, initial_capital: float, - market_type: str = 'swap', - market_category: str = 'Crypto', - margin_mode: str = 'cross', + market_type: str = "swap", + market_category: str = "Crypto", + margin_mode: str = "cross", stop_loss_price: float = None, take_profit_price: float = None, - execution_mode: str = 'signal', + execution_mode: str = "signal", notification_config: Optional[Dict[str, Any]] = None, trading_config: Optional[Dict[str, Any]] = None, ai_model_config: Optional[Dict[str, Any]] = None, @@ -2325,13 +2531,15 @@ class TradingExecutor: return False # 1. Check trading direction restrictions - if market_type == 'spot' and 'short' in signal_type: - return False + if market_type == "spot" and "short" in signal_type: + return False sig = (signal_type or "").strip().lower() # 1.1 Open position AI filtering (only open_*) - if sig in ("open_long", "open_short") and self._is_entry_ai_filter_enabled(ai_model_config=ai_model_config, trading_config=trading_config): + if sig in ("open_long", "open_short") and self._is_entry_ai_filter_enabled( + ai_model_config=ai_model_config, trading_config=trading_config + ): ok_ai, ai_info = self._entry_ai_filter_allows( strategy_id=strategy_id, symbol=symbol, @@ -2375,7 +2583,7 @@ class TradingExecutor: current_price=current_price, symbol=symbol, ) - + amount = 0.0 # Frontend position sizing alignment: @@ -2386,21 +2594,21 @@ class TradingExecutor: position_size = self._to_ratio(ep, default=position_size if position_size is not None else 0.0) # Open / add sizing: position_size is treated as capital ratio in [0,1]. - if ('open' in sig or 'add' in sig): - if position_size is None or float(position_size) <= 0: - position_size = 0.05 - position_ratio = self._to_ratio(position_size, default=0.05) - if market_type == 'spot': - amount = available_capital * position_ratio / current_price - else: - # Futures sizing: treat available_capital as margin budget. - # Notional = margin * leverage, so base quantity = (margin * leverage) / price. - amount = (available_capital * position_ratio * leverage) / current_price + if "open" in sig or "add" in sig: + if position_size is None or float(position_size) <= 0: + position_size = 0.05 + position_ratio = self._to_ratio(position_size, default=0.05) + if market_type == "spot": + amount = available_capital * position_ratio / current_price + else: + # Futures sizing: treat available_capital as margin budget. + # Notional = margin * leverage, so base quantity = (margin * leverage) / price. + amount = (available_capital * position_ratio * leverage) / current_price # Reduce sizing: position_size is treated as a reduce ratio (close X% of current position). if sig in ("reduce_long", "reduce_short"): pos_side = "long" if "long" in sig else "short" - pos = next((p for p in current_positions if (p.get('side') or '').strip().lower() == pos_side), None) + pos = next((p for p in current_positions if (p.get("side") or "").strip().lower() == pos_side), None) if not pos: return False cur_size = float(pos.get("size") or 0.0) @@ -2415,23 +2623,23 @@ class TradingExecutor: amount = cur_size else: amount = reduce_amount - + # 3. Check reverse positions (one-way position logic) # ... (Simplified processing, assuming no reverse or processing by the user) ... # 4. Execute order enqueue (PendingOrderWorker will dispatch notifications in signal mode) - if 'close' in sig: + if "close" in sig: # Position closing logic: find the corresponding position size - pos = next((p for p in current_positions if p.get('side') and p['side'] in signal_type), None) + pos = next((p for p in current_positions if p.get("side") and p["side"] in signal_type), None) if not pos: return False - amount = float(pos['size'] or 0.0) + amount = float(pos["size"] or 0.0) if amount <= 0: return False - if amount <= 0 and ('open' in signal_type or 'add' in signal_type): + if amount <= 0 and ("open" in signal_type or "add" in signal_type): return False - + order_result = self._execute_exchange_order( exchange=exchange, strategy_id=strategy_id, @@ -2446,98 +2654,120 @@ class TradingExecutor: notification_config=notification_config, signal_ts=int(signal_ts or 0), ) - - if order_result and order_result.get('success'): + + if order_result and order_result.get("success"): # For live execution, the order is only enqueued here. # The actual fill/trade/position updates are performed by PendingOrderWorker. if str(execution_mode or "").strip().lower() == "live": return True # Update database status (signal mode / local simulation) - if 'open' in sig or 'add' in sig: + if "open" in sig or "add" in sig: self._record_trade( - strategy_id=strategy_id, symbol=symbol, type=signal_type, - price=current_price, amount=amount, value=amount*current_price + strategy_id=strategy_id, + symbol=symbol, + type=signal_type, + price=current_price, + amount=amount, + value=amount * current_price, ) - side = 'short' if 'short' in signal_type else 'long' - + side = "short" if "short" in signal_type else "long" + # Find existing positions to calculate average price - old_pos = next((p for p in current_positions if p['side'] == side), None) + old_pos = next((p for p in current_positions if p["side"] == side), None) new_size = amount new_entry = current_price if old_pos: - old_size = float(old_pos['size']) - old_entry = float(old_pos['entry_price']) + old_size = float(old_pos["size"]) + old_entry = float(old_pos["entry_price"]) new_size += old_size new_entry = ((old_size * old_entry) + (amount * current_price)) / new_size self._update_position( - strategy_id=strategy_id, symbol=symbol, side=side, - size=new_size, entry_price=new_entry, current_price=current_price + strategy_id=strategy_id, + symbol=symbol, + side=side, + size=new_size, + entry_price=new_entry, + current_price=current_price, ) elif sig.startswith("reduce_"): # Partial scale-out: reduce position size, keep entry price unchanged. # Calculate partial closing profit and loss in signal mode - side = 'short' if 'short' in signal_type else 'long' - old_pos = next((p for p in current_positions if p.get('side') == side), None) + side = "short" if "short" in signal_type else "long" + old_pos = next((p for p in current_positions if p.get("side") == side), None) if not old_pos: return True - old_size = float(old_pos.get('size') or 0.0) - old_entry = float(old_pos.get('entry_price') or 0.0) - + old_size = float(old_pos.get("size") or 0.0) + old_entry = float(old_pos.get("entry_price") or 0.0) + # Calculate the profit and loss of the position reduction part (in signal mode, excluding handling fees) reduce_profit = None if old_entry > 0 and amount > 0: - if side == 'long': + if side == "long": reduce_profit = (current_price - old_entry) * amount else: reduce_profit = (old_entry - current_price) * amount - + self._record_trade( - strategy_id=strategy_id, symbol=symbol, type=signal_type, - price=current_price, amount=amount, value=amount*current_price, - profit=reduce_profit + strategy_id=strategy_id, + symbol=symbol, + type=signal_type, + price=current_price, + amount=amount, + value=amount * current_price, + profit=reduce_profit, ) - + new_size = max(0.0, old_size - float(amount or 0.0)) if new_size <= old_size * 0.001: self._close_position(strategy_id, symbol, side) else: self._update_position( - strategy_id=strategy_id, symbol=symbol, side=side, - size=new_size, entry_price=old_entry, current_price=current_price + strategy_id=strategy_id, + symbol=symbol, + side=side, + size=new_size, + entry_price=old_entry, + current_price=current_price, ) - elif 'close' in sig: + elif "close" in sig: # Calculate closing profit and loss in signal mode - side = 'short' if 'short' in signal_type else 'long' - old_pos = next((p for p in current_positions if p.get('side') == side), None) - + side = "short" if "short" in signal_type else "long" + old_pos = next((p for p in current_positions if p.get("side") == side), None) + # Calculate profit and loss (in signal mode, excluding handling fees) close_profit = None if old_pos: - entry_price = float(old_pos.get('entry_price') or 0) + entry_price = float(old_pos.get("entry_price") or 0) if entry_price > 0 and amount > 0: - if side == 'long': + if side == "long": close_profit = (current_price - entry_price) * amount else: close_profit = (entry_price - current_price) * amount - + self._record_trade( - strategy_id=strategy_id, symbol=symbol, type=signal_type, - price=current_price, amount=amount, value=amount*current_price, - profit=close_profit + strategy_id=strategy_id, + symbol=symbol, + type=signal_type, + price=current_price, + amount=amount, + value=amount * current_price, + profit=close_profit, ) self._close_position(strategy_id, symbol, side) return True return False - + except Exception as e: logger.error(f"Failed to execute signal: {e}") return False - def _is_entry_ai_filter_enabled(self, *, ai_model_config: Optional[Dict[str, Any]], trading_config: Optional[Dict[str, Any]]) -> bool: + def _is_entry_ai_filter_enabled( + self, *, ai_model_config: Optional[Dict[str, Any]], trading_config: Optional[Dict[str, Any]] + ) -> bool: """Detect whether the strategy enabled 'AI filter on entry (open positions only)'.""" amc = ai_model_config if isinstance(ai_model_config, dict) else {} tc = trading_config if isinstance(trading_config, dict) else {} @@ -2601,6 +2831,7 @@ class TradingExecutor: # ── Billing: AI filter uses the same cost as ai_analysis ── try: from app.services.billing_service import get_billing_service + billing = get_billing_service() if billing.is_billing_enabled(): user_id = 1 @@ -2610,13 +2841,11 @@ class TradingExecutor: cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) row = cur.fetchone() cur.close() - user_id = int((row or {}).get('user_id') or 1) + user_id = int((row or {}).get("user_id") or 1) except Exception: pass ok, msg = billing.check_and_consume( - user_id=user_id, - feature='ai_analysis', - reference_id=f"ai_filter_{strategy_id}_{symbol}" + user_id=user_id, feature="ai_analysis", reference_id=f"ai_filter_{strategy_id}_{symbol}" ) if not ok: logger.warning(f"AI filter billing failed for strategy {strategy_id}: {msg}") @@ -2631,7 +2860,11 @@ class TradingExecutor: result = service.analyze(market, symbol, language, model=model) if isinstance(result, dict) and result.get("error"): - return False, {"ai_decision": "", "reason": "analysis_error", "analysis_error": str(result.get("error") or "")} + return False, { + "ai_decision": "", + "reason": "analysis_error", + "analysis_error": str(result.get("error") or ""), + } # FastAnalysisService directly returns the decision field ai_dec = str(result.get("decision", "")).strip().upper() @@ -2641,12 +2874,17 @@ class TradingExecutor: expected = "BUY" if signal_type == "open_long" else "SELL" confidence = result.get("confidence", 50) summary = result.get("summary", "") - + if ai_dec == expected: return True, {"ai_decision": ai_dec, "reason": "match", "confidence": confidence, "summary": summary} if ai_dec == "HOLD": return False, {"ai_decision": ai_dec, "reason": "ai_hold", "confidence": confidence, "summary": summary} - return False, {"ai_decision": ai_dec, "reason": "direction_mismatch", "confidence": confidence, "summary": summary} + return False, { + "ai_decision": ai_dec, + "reason": "direction_mismatch", + "confidence": confidence, + "summary": summary, + } except Exception as e: return False, {"ai_decision": "", "reason": "analysis_exception", "analysis_error": str(e)} @@ -2700,7 +2938,6 @@ class TradingExecutor: ) -> None: """Best-effort persistence of a notification row for the frontend notifications panel (browser channel).""" try: - now = int(time.time()) # Get user_id from strategy if not provided if user_id is None: try: @@ -2709,7 +2946,7 @@ class TradingExecutor: cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,)) row = cur.fetchone() cur.close() - user_id = int((row or {}).get('user_id') or 1) + user_id = int((row or {}).get("user_id") or 1) except Exception: user_id = 1 with get_db_connection() as db: @@ -2744,10 +2981,10 @@ class TradingExecutor: signal_type: str, amount: float, ref_price: Optional[float] = None, - market_type: str = 'swap', - market_category: str = 'Crypto', + market_type: str = "swap", + market_category: str = "Crypto", leverage: float = 1.0, - margin_mode: str = 'cross', + margin_mode: str = "cross", stop_loss_price: float = None, take_profit_price: float = None, # Order execution params (order_mode, maker_wait_sec, maker_offset_bps) are now @@ -2758,7 +2995,7 @@ class TradingExecutor: maker_retries: int = 3, close_fallback_to_market: bool = True, open_fallback_to_market: bool = True, - execution_mode: str = 'signal', + execution_mode: str = "signal", notification_config: Optional[Dict[str, Any]] = None, signal_ts: int = 0, ) -> Optional[Dict[str, Any]]: @@ -2806,19 +3043,21 @@ class TradingExecutor: # Local "signal provider mode": we keep the local state machine moving forward. return { - 'success': True, - 'pending': bool(pending_flag), - 'order_id': f"pending_{pending_id or int(time.time()*1000)}", - 'filled_amount': 0 if pending_flag else amount, - 'filled_base_amount': 0 if pending_flag else amount, - 'filled_price': 0 if pending_flag else ref_price, - 'total_cost': 0 if pending_flag else (float(amount or 0.0) * float(ref_price or 0.0) if ref_price else 0), - 'fee': 0, - 'message': 'Order enqueued to pending_orders' + "success": True, + "pending": bool(pending_flag), + "order_id": f"pending_{pending_id or int(time.time() * 1000)}", + "filled_amount": 0 if pending_flag else amount, + "filled_base_amount": 0 if pending_flag else amount, + "filled_price": 0 if pending_flag else ref_price, + "total_cost": 0 + if pending_flag + else (float(amount or 0.0) * float(ref_price or 0.0) if ref_price else 0), + "fee": 0, + "message": "Order enqueued to pending_orders", } except Exception as e: - logger.error(f"Signal execution failed: {e}") - return {'success': False, 'error': str(e)} + logger.error(f"Signal execution failed: {e}") + return {"success": False, "error": str(e)} def _enqueue_pending_order( self, @@ -2873,13 +3112,18 @@ class TradingExecutor: try: stsig = int(signal_ts or 0) # Strict "same candle" de-dup applies to open and close signals. - # Rationale: + # 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", "close_long", "close_short") + strict_candle_dedup = stsig > 0 and sig_norm in ( + "open_long", + "open_short", + "close_long", + "close_short", + ) if strict_candle_dedup: cur.execute( @@ -2944,7 +3188,7 @@ class TradingExecutor: try: cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,)) row = cur.fetchone() - user_id = int((row or {}).get('user_id') or 1) + user_id = int((row or {}).get("user_id") or 1) except Exception: pass @@ -2965,16 +3209,16 @@ class TradingExecutor: symbol, signal_type, int(signal_ts or 0), - market_type or 'swap', - 'market', + market_type or "swap", + "market", float(amount or 0.0), float(price or 0.0), mode, - 'pending', + "pending", 0, 0, 10, - '', + "", json.dumps(payload, ensure_ascii=False), ), ) @@ -3025,10 +3269,10 @@ class TradingExecutor: FROM qd_strategy_trades WHERE strategy_id = %s """, - (strategy_id,) + (strategy_id,), ) row = cursor.fetchone() or {} - realized_pnl = float(row.get('realized_pnl') or 0.0) + realized_pnl = float(row.get("realized_pnl") or 0.0) cursor.close() except Exception as e: logger.warning(f"Failed to calculate realized pnl for strategy {strategy_id}: {e}") @@ -3040,24 +3284,24 @@ class TradingExecutor: except Exception: positions = [] - normalized_symbol = (symbol or "").split(':')[0] + normalized_symbol = (symbol or "").split(":")[0] for pos in positions: try: - side = str(pos.get('side') or '').strip().lower() - size = float(pos.get('size') or 0.0) - entry_price = float(pos.get('entry_price') or 0.0) - if size <= 0 or entry_price <= 0 or side not in ('long', 'short'): + side = str(pos.get("side") or "").strip().lower() + size = float(pos.get("size") or 0.0) + entry_price = float(pos.get("entry_price") or 0.0) + if size <= 0 or entry_price <= 0 or side not in ("long", "short"): continue - mark_price = pos.get('current_price') - pos_symbol = str(pos.get('symbol') or '') - if current_price and normalized_symbol and pos_symbol.split(':')[0] == normalized_symbol: + mark_price = pos.get("current_price") + pos_symbol = str(pos.get("symbol") or "") + if current_price and normalized_symbol and pos_symbol.split(":")[0] == normalized_symbol: mark_price = current_price mark_price = float(mark_price or 0.0) if mark_price <= 0: continue - if side == 'long': + if side == "long": unrealized_pnl += (mark_price - entry_price) * size else: unrealized_pnl += (entry_price - mark_price) * size @@ -3067,7 +3311,17 @@ class TradingExecutor: equity = float(initial_capital or 0.0) + realized_pnl + unrealized_pnl return max(0.0, equity) - def _record_trade(self, strategy_id: int, symbol: str, type: str, price: float, amount: float, value: float, profit: float = None, commission: float = None): + def _record_trade( + self, + strategy_id: int, + symbol: str, + type: str, + price: float, + amount: float, + value: float, + profit: float = None, + commission: float = None, + ): """Record transactions to database""" try: # Get user_id from strategy @@ -3077,7 +3331,7 @@ class TradingExecutor: try: cursor.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,)) row = cursor.fetchone() - user_id = int((row or {}).get('user_id') or 1) + user_id = int((row or {}).get("user_id") or 1) except Exception: pass query = """ @@ -3087,7 +3341,9 @@ class TradingExecutor: %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() ) """ - cursor.execute(query, (user_id, strategy_id, symbol, type, price, amount, value, commission or 0, profit)) + cursor.execute( + query, (user_id, strategy_id, symbol, type, price, amount, value, commission or 0, profit) + ) db.commit() cursor.close() except Exception as e: @@ -3113,7 +3369,7 @@ class TradingExecutor: try: cursor.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,)) row = cursor.fetchone() - user_id = int((row or {}).get('user_id') or 1) + user_id = int((row or {}).get("user_id") or 1) except Exception: pass # Simplification: direct Update or Insert @@ -3130,9 +3386,10 @@ class TradingExecutor: lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE qd_strategy_positions.lowest_price END, updated_at = NOW() """ - cursor.execute(upsert_query, ( - user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price - )) + cursor.execute( + upsert_query, + (user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price), + ) db.commit() cursor.close() except Exception as e: @@ -3143,82 +3400,96 @@ class TradingExecutor: try: with get_db_connection() as db: cursor = db.cursor() - cursor.execute("DELETE FROM qd_strategy_positions WHERE strategy_id = %s AND symbol = %s AND side = %s", (strategy_id, symbol, side)) + cursor.execute( + "DELETE FROM qd_strategy_positions WHERE strategy_id = %s AND symbol = %s AND side = %s", + (strategy_id, symbol, side), + ) db.commit() cursor.close() except Exception as e: logger.error(f"Failed to close position: {e}") - + def _delete_position_by_id(self, position_id: int): - pass + pass def _update_positions(self, strategy_id: int, symbol: str, current_price: float): """Update current prices for all positions""" try: with get_db_connection() as db: cursor = db.cursor() - cursor.execute("UPDATE qd_strategy_positions SET current_price = %s WHERE strategy_id = %s AND symbol = %s", (current_price, strategy_id, symbol)) + cursor.execute( + "UPDATE qd_strategy_positions SET current_price = %s WHERE strategy_id = %s AND symbol = %s", + (current_price, strategy_id, symbol), + ) db.commit() cursor.close() except Exception: pass - + def _get_indicator_code_from_db(self, indicator_id: int) -> Optional[str]: try: with get_db_connection() as db: cursor = db.cursor() cursor.execute("SELECT code FROM qd_indicator_codes WHERE id = %s", (indicator_id,)) result = cursor.fetchone() - return result['code'] if result else None - except: + return result["code"] if result else None + except Exception as e: + logger.debug(f"Failed to get indicator code: {e}") return None - + def _get_all_positions(self, strategy_id: int) -> List[Dict[str, Any]]: """Get all positions of the strategy (used by cross-section strategy)""" try: with get_db_connection() as db: cursor = db.cursor() - cursor.execute(""" + cursor.execute( + """ SELECT id, symbol, side, size, entry_price, current_price, highest_price, lowest_price FROM qd_strategy_positions WHERE strategy_id = %s - """, (strategy_id,)) + """, + (strategy_id,), + ) return cursor.fetchall() or [] except Exception as e: logger.error(f"Failed to get all positions: {e}") return [] - + def _should_rebalance(self, strategy_id: int, rebalance_frequency: str) -> bool: """Check whether rebalancing should run.""" try: with get_db_connection() as db: cursor = db.cursor() - cursor.execute(""" + cursor.execute( + """ SELECT last_rebalance_at FROM qd_strategies_trading WHERE id = %s - """, (strategy_id,)) + """, + (strategy_id,), + ) result = cursor.fetchone() - if not result or not result.get('last_rebalance_at'): + if not result or not result.get("last_rebalance_at"): return True - - last_rebalance = result['last_rebalance_at'] + + last_rebalance = result["last_rebalance_at"] if isinstance(last_rebalance, str): from datetime import datetime - last_rebalance = datetime.fromisoformat(last_rebalance.replace('Z', '+00:00')) - + + last_rebalance = datetime.fromisoformat(last_rebalance.replace("Z", "+00:00")) + now = datetime.now() delta = now - last_rebalance - - if rebalance_frequency == 'daily': + + if rebalance_frequency == "daily": return delta.days >= 1 - elif rebalance_frequency == 'weekly': + elif rebalance_frequency == "weekly": return delta.days >= 7 - elif rebalance_frequency == 'monthly': + elif rebalance_frequency == "monthly": return delta.days >= 30 return True except Exception as e: logger.error(f"Failed to check rebalance: {e}") return True - + def _update_last_rebalance(self, strategy_id: int): """Update the last rebalance timestamp.""" try: @@ -3226,11 +3497,14 @@ class TradingExecutor: cursor = db.cursor() # Try to update, if column doesn't exist, ignore try: - cursor.execute(""" - UPDATE qd_strategies_trading - SET last_rebalance_at = NOW() + cursor.execute( + """ + UPDATE qd_strategies_trading + SET last_rebalance_at = NOW() WHERE id = %s - """, (strategy_id,)) + """, + (strategy_id,), + ) db.commit() except Exception: # Column may not exist, that's OK @@ -3238,14 +3512,14 @@ class TradingExecutor: cursor.close() except Exception as e: logger.warning(f"Failed to update last_rebalance_at: {e}") - + def _execute_cross_sectional_indicator( self, indicator_code: str, symbols: List[str], trading_config: Dict[str, Any], market_category: str, - timeframe: str + timeframe: str, ) -> Optional[Dict[str, Any]]: """ Execute cross-sectional strategy indicators and return scores and ranking for all instruments. @@ -3263,133 +3537,115 @@ class TradingExecutor: except Exception as e: logger.warning(f"Failed to fetch data for {symbol}: {e}") continue - + if not all_data: logger.error("No data available for cross-sectional strategy") return None - + # Prepare execution environment exec_env = { - 'symbols': list(all_data.keys()), - 'data': all_data, # {symbol: df} - 'scores': {}, # used to store scores - 'rankings': [], # used to store rankings - 'np': np, - 'pd': pd, - 'trading_config': trading_config, - 'config': trading_config, + "symbols": list(all_data.keys()), + "data": all_data, # {symbol: df} + "scores": {}, # used to store scores + "rankings": [], # used to store rankings + "np": np, + "pd": pd, + "trading_config": trading_config, + "config": trading_config, } - + # Execution indicator code import builtins - safe_builtins = {k: getattr(builtins, k) for k in dir(builtins) - if not k.startswith('_') and k not in [ - 'eval', 'exec', 'compile', 'open', 'input', - 'help', 'exit', 'quit', '__import__', - ]} - exec_env['__builtins__'] = safe_builtins - + + safe_builtins = { + k: getattr(builtins, k) + for k in dir(builtins) + if not k.startswith("_") + and k + not in [ + "eval", + "exec", + "compile", + "open", + "input", + "help", + "exit", + "quit", + "__import__", + ] + } + exec_env["__builtins__"] = safe_builtins + pre_import_code = "import numpy as np\nimport pandas as pd\n" exec(pre_import_code, exec_env) exec(indicator_code, exec_env) - - scores = exec_env.get('scores', {}) - rankings = exec_env.get('rankings', []) - + + scores = exec_env.get("scores", {}) + rankings = exec_env.get("rankings", []) + # If rankings are not provided, sort according to scores if not rankings and scores: rankings = sorted(scores.keys(), key=lambda x: scores.get(x, 0), reverse=True) - - return { - 'scores': scores, - 'rankings': rankings - } + + return {"scores": scores, "rankings": rankings} except Exception as e: logger.error(f"Failed to execute cross-sectional indicator: {e}") logger.error(traceback.format_exc()) return None - + def _generate_cross_sectional_signals( - self, - strategy_id: int, - rankings: List[str], - scores: Dict[str, float], - trading_config: Dict[str, Any] + self, strategy_id: int, rankings: List[str], scores: Dict[str, float], trading_config: Dict[str, Any] ) -> List[Dict[str, Any]]: """ Generate cross-sectional strategy signals from the ranking results. """ - portfolio_size = trading_config.get('portfolio_size', 10) - long_ratio = float(trading_config.get('long_ratio', 0.5)) - + portfolio_size = trading_config.get("portfolio_size", 10) + long_ratio = float(trading_config.get("long_ratio", 0.5)) + # Select the position target long_count = int(portfolio_size * long_ratio) short_count = portfolio_size - long_count - + long_symbols = set(rankings[:long_count]) if long_count > 0 else set() short_symbols = set(rankings[-short_count:]) if short_count > 0 and len(rankings) >= short_count else set() - + # Get the current position current_positions = self._get_all_positions(strategy_id) - current_long = {p['symbol'] for p in current_positions if p.get('side') == 'long'} - current_short = {p['symbol'] for p in current_positions if p.get('side') == 'short'} - + current_long = {p["symbol"] for p in current_positions if p.get("side") == "long"} + current_short = {p["symbol"] for p in current_positions if p.get("side") == "short"} + signals = [] - + # Generate long signal for symbol in long_symbols: if symbol not in current_long: # If there is currently no long position, open a long position if symbol in current_short: # If the current position is a short position, close the short position first and then open a long position - signals.append({ - 'symbol': symbol, - 'type': 'close_short', - 'score': scores.get(symbol, 0) - }) - signals.append({ - 'symbol': symbol, - 'type': 'open_long', - 'score': scores.get(symbol, 0) - }) - + signals.append({"symbol": symbol, "type": "close_short", "score": scores.get(symbol, 0)}) + signals.append({"symbol": symbol, "type": "open_long", "score": scores.get(symbol, 0)}) + # Close long positions that are not in the long list for symbol in current_long: if symbol not in long_symbols: - signals.append({ - 'symbol': symbol, - 'type': 'close_long', - 'score': scores.get(symbol, 0) - }) - + signals.append({"symbol": symbol, "type": "close_long", "score": scores.get(symbol, 0)}) + # Generate short signal for symbol in short_symbols: if symbol not in current_short: # If there is currently no short position, open a short position if symbol in current_long: # If you are currently in a long position, close the long position first and then open a short position. - signals.append({ - 'symbol': symbol, - 'type': 'close_long', - 'score': scores.get(symbol, 0) - }) - signals.append({ - 'symbol': symbol, - 'type': 'open_short', - 'score': scores.get(symbol, 0) - }) - + signals.append({"symbol": symbol, "type": "close_long", "score": scores.get(symbol, 0)}) + signals.append({"symbol": symbol, "type": "open_short", "score": scores.get(symbol, 0)}) + # Close short positions that are not in the short list for symbol in current_short: if symbol not in short_symbols: - signals.append({ - 'symbol': symbol, - 'type': 'close_short', - 'score': scores.get(symbol, 0) - }) - + signals.append({"symbol": symbol, "type": "close_short", "score": scores.get(symbol, 0)}) + return signals - + def _run_cross_sectional_strategy_loop( self, strategy_id: int, @@ -3405,34 +3661,33 @@ class TradingExecutor: leverage: float, initial_capital: float, indicator_code: str, - indicator_id: Optional[int] + indicator_id: Optional[int], ): """ Cross-sectional strategy execution loop. """ logger.info(f"Starting cross-sectional strategy loop for strategy {strategy_id}") - - symbol_list = trading_config.get('symbol_list', []) + + symbol_list = trading_config.get("symbol_list", []) if not symbol_list: logger.error(f"Strategy {strategy_id} has no symbol_list for cross-sectional strategy") return - - timeframe = trading_config.get('timeframe', '1H') - rebalance_frequency = trading_config.get('rebalance_frequency', 'daily') - tick_interval_sec = int(trading_config.get('decide_interval', 300)) - + + timeframe = trading_config.get("timeframe", "1H") + rebalance_frequency = trading_config.get("rebalance_frequency", "daily") + tick_interval_sec = int(trading_config.get("decide_interval", 300)) + last_tick_time = 0 - last_rebalance_time = 0 - + while True: try: # Check policy status if not self._is_strategy_running(strategy_id): logger.info(f"Cross-sectional strategy {strategy_id} stopped") break - + current_time = time.time() - + # Sleep until next tick if last_tick_time > 0: sleep_sec = (last_tick_time + tick_interval_sec) - current_time @@ -3440,36 +3695,37 @@ class TradingExecutor: time.sleep(min(sleep_sec, 1.0)) continue last_tick_time = current_time - + # Check whether position adjustment is needed if not self._should_rebalance(strategy_id, rebalance_frequency): continue - + logger.info(f"Cross-sectional strategy {strategy_id} rebalancing...") - - #Execution cross-section indicators + + # Execution cross-section indicators result = self._execute_cross_sectional_indicator( indicator_code, symbol_list, trading_config, market_category, timeframe ) - + if not result: - logger.warning(f"Cross-sectional indicator returned no result") + logger.warning("Cross-sectional indicator returned no result") continue - + # Generate signal signals = self._generate_cross_sectional_signals( - strategy_id, result['rankings'], result['scores'], trading_config + strategy_id, result["rankings"], result["scores"], trading_config ) - + if not signals: logger.info(f"No rebalancing needed for strategy {strategy_id}") self._update_last_rebalance(strategy_id) continue - + logger.info(f"Generated {len(signals)} signals for cross-sectional strategy {strategy_id}") - + # Execute transactions in batches from concurrent.futures import ThreadPoolExecutor, as_completed + with ThreadPoolExecutor(max_workers=min(10, len(signals))) as executor: futures = {} for signal in signals: @@ -3478,27 +3734,27 @@ class TradingExecutor: strategy_id=strategy_id, strategy_name=strategy_name, exchange=None, # Signal mode - symbol=signal['symbol'], + symbol=signal["symbol"], current_price=0.0, # Will be fetched in _execute_signal - signal_type=signal['type'], + signal_type=signal["type"], position_size=None, current_positions=[], - trade_direction='both', + trade_direction="both", leverage=leverage, initial_capital=initial_capital, market_type=market_type, market_category=market_category, - margin_mode='cross', + margin_mode="cross", stop_loss_price=None, take_profit_price=None, execution_mode=execution_mode, notification_config=notification_config, trading_config=trading_config, ai_model_config=ai_model_config, - signal_ts=int(current_time) + signal_ts=int(current_time), ) futures[future] = signal - + # Wait for all transactions to complete for future in as_completed(futures): signal = futures[future] @@ -3508,11 +3764,10 @@ class TradingExecutor: logger.info(f"Successfully executed signal: {signal['symbol']} {signal['type']}") except Exception as e: logger.error(f"Failed to execute signal {signal['symbol']} {signal['type']}: {e}") - + # Update position rebalancing time self._update_last_rebalance(strategy_id) - last_rebalance_time = current_time - + except Exception as e: logger.error(f"Cross-sectional strategy loop error: {e}") logger.error(traceback.format_exc()) diff --git a/backend_api_python/app/services/usdt_payment_service.py b/backend_api_python/app/services/usdt_payment_service.py index 040bff3..06bc299 100644 --- a/backend_api_python/app/services/usdt_payment_service.py +++ b/backend_api_python/app/services/usdt_payment_service.py @@ -9,16 +9,15 @@ MVP: import os import threading -import time -from datetime import datetime, timezone, timedelta +from datetime import datetime, timedelta, timezone from decimal import Decimal -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Optional, Tuple import requests +from app.services.billing_service import get_billing_service from app.utils.db import get_db_connection from app.utils.logger import get_logger -from app.services.billing_service import get_billing_service logger = get_logger(__name__) @@ -36,7 +35,9 @@ class UsdtPaymentService: "xpub_trc20": (os.getenv("USDT_TRC20_XPUB", "") or "").strip(), "trongrid_base": (os.getenv("TRONGRID_BASE_URL", "https://api.trongrid.io") or "").strip().rstrip("/"), "trongrid_key": (os.getenv("TRONGRID_API_KEY", "") or "").strip(), - "usdt_trc20_contract": (os.getenv("USDT_TRC20_CONTRACT", "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj") or "").strip(), + "usdt_trc20_contract": ( + os.getenv("USDT_TRC20_CONTRACT", "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj") or "" + ).strip(), "confirm_seconds": int(float(os.getenv("USDT_PAY_CONFIRM_SECONDS", "30") or 30)), "order_expire_minutes": int(float(os.getenv("USDT_PAY_EXPIRE_MINUTES", "30") or 30)), } @@ -66,7 +67,9 @@ class UsdtPaymentService: ) """ ) - cur.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_usdt_orders_address_unique ON qd_usdt_orders(chain, address)") + cur.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_usdt_orders_address_unique ON qd_usdt_orders(chain, address)" + ) cur.execute("CREATE INDEX IF NOT EXISTS idx_usdt_orders_user_id ON qd_usdt_orders(user_id)") cur.execute("CREATE INDEX IF NOT EXISTS idx_usdt_orders_status ON qd_usdt_orders(status)") except Exception: @@ -85,7 +88,7 @@ class UsdtPaymentService: This function supports both by normalizing to change-level before AddressIndex(). """ try: - from bip_utils import Bip44, Bip44Coins, Bip44Changes + from bip_utils import Bip44, Bip44Changes, Bip44Coins except Exception as e: raise RuntimeError(f"bip_utils_missing:{e}") @@ -162,14 +165,18 @@ class UsdtPaymentService: db.commit() cur.close() - return True, "success", { - "order_id": order_id, - "plan": plan, - "chain": "TRC20", - "amount_usdt": str(amount), - "address": address, - "expires_at": expires_at.isoformat(), - } + return ( + True, + "success", + { + "order_id": order_id, + "plan": plan, + "chain": "TRC20", + "amount_usdt": str(amount), + "address": address, + "expires_at": expires_at.isoformat(), + }, + ) except Exception as e: logger.error(f"create_order failed: {e}", exc_info=True) return False, f"error:{str(e)}", {} @@ -248,7 +255,9 @@ class UsdtPaymentService: if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc) if status == "pending" and exp <= now: - cur.execute("UPDATE qd_usdt_orders SET status = 'expired', updated_at = NOW() WHERE id = ?", (order_id,)) + cur.execute( + "UPDATE qd_usdt_orders SET status = 'expired', updated_at = NOW() WHERE id = ?", (order_id,) + ) return if chain != "TRC20": @@ -304,11 +313,15 @@ class UsdtPaymentService: if paid_at and paid_at.tzinfo is None: paid_at = paid_at.replace(tzinfo=timezone.utc) if paid_at and (now - paid_at).total_seconds() >= confirm_sec: - self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), row.get("tx_hash") or "") + self._confirm_and_activate_in_tx( + cur, row["id"], row.get("user_id"), row.get("plan"), row.get("tx_hash") or "" + ) return # Fallback: if paid_at missing but confirm_sec <= 0, confirm now if confirm_sec <= 0: - self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), row.get("tx_hash") or "") + self._confirm_and_activate_in_tx( + cur, row["id"], row.get("user_id"), row.get("plan"), row.get("tx_hash") or "" + ) def _confirm_and_activate_in_tx(self, cur, order_id: int, user_id: int, plan: str, tx_hash: str) -> None: """Mark order as confirmed and activate membership. Idempotent: skips if already confirmed.""" @@ -334,7 +347,9 @@ class UsdtPaymentService: except Exception as e: logger.error(f"USDT activate membership failed: order={order_id} err={e}", exc_info=True) - def _find_trc20_usdt_incoming(self, address: str, amount_usdt: Decimal, created_at: Optional[datetime]) -> Optional[Dict[str, Any]]: + def _find_trc20_usdt_incoming( + self, address: str, amount_usdt: Decimal, created_at: Optional[datetime] + ) -> Optional[Dict[str, Any]]: cfg = self._get_cfg() base = cfg["trongrid_base"] contract = cfg["usdt_trc20_contract"] @@ -438,6 +453,7 @@ class UsdtPaymentService: # ==================== Background Worker ==================== + class UsdtOrderWorker: """ Background thread that periodically scans pending/paid USDT orders diff --git a/backend_api_python/app/services/user_service.py b/backend_api_python/app/services/user_service.py index 7bd90ed..87df809 100644 --- a/backend_api_python/app/services/user_service.py +++ b/backend_api_python/app/services/user_service.py @@ -3,22 +3,24 @@ User Service - Multi-user management Handles user CRUD operations, password hashing, and role management. """ + import hashlib -import re -import time import os -from typing import Optional, Dict, Any, List +import re +from typing import Any, Dict, List, Optional + from app.utils.db import get_db_connection from app.utils.logger import get_logger logger = get_logger(__name__) # IANA timezone id subset check (e.g. Asia/Shanghai, America/New_York) -_TIMEZONE_ID_RE = re.compile(r'^[A-Za-z0-9_/+\-.]+$') +_TIMEZONE_ID_RE = re.compile(r"^[A-Za-z0-9_/+\-.]+$") # Try to import bcrypt for secure password hashing try: import bcrypt + HAS_BCRYPT = True except ImportError: HAS_BCRYPT = False @@ -27,50 +29,60 @@ except ImportError: class UserService: """User management service""" - + # Available roles (ordered by privilege level) - ROLES = ['viewer', 'user', 'manager', 'admin'] - + ROLES = ["viewer", "user", "manager", "admin"] + # Role permissions mapping ROLE_PERMISSIONS = { - 'viewer': ['dashboard', 'view'], - 'user': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio'], - 'manager': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings'], - 'admin': ['dashboard', 'view', 'indicator', 'backtest', 'strategy', 'portfolio', 'settings', 'user_manage', 'credentials'], + "viewer": ["dashboard", "view"], + "user": ["dashboard", "view", "indicator", "backtest", "strategy", "portfolio"], + "manager": ["dashboard", "view", "indicator", "backtest", "strategy", "portfolio", "settings"], + "admin": [ + "dashboard", + "view", + "indicator", + "backtest", + "strategy", + "portfolio", + "settings", + "user_manage", + "credentials", + ], } - + def hash_password(self, password: str) -> str: """Hash password using bcrypt (preferred) or SHA256 (fallback)""" if HAS_BCRYPT: salt = bcrypt.gensalt(rounds=12) - return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') + return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") else: # Fallback to SHA256 with salt salt = os.urandom(16).hex() - hashed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest() + hashed = hashlib.sha256((password + salt).encode("utf-8")).hexdigest() return f"sha256${salt}${hashed}" - + def verify_password(self, password: str, password_hash: str) -> bool: """Verify password against hash""" - if password_hash.startswith('$2b$') or password_hash.startswith('$2a$'): + if password_hash.startswith("$2b$") or password_hash.startswith("$2a$"): # bcrypt hash if HAS_BCRYPT: try: - return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8')) + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) except Exception: return False return False - elif password_hash.startswith('sha256$'): + elif password_hash.startswith("sha256$"): # SHA256 fallback hash - parts = password_hash.split('$') + parts = password_hash.split("$") if len(parts) != 3: return False salt = parts[1] stored_hash = parts[2] - computed = hashlib.sha256((password + salt).encode('utf-8')).hexdigest() + computed = hashlib.sha256((password + salt).encode("utf-8")).hexdigest() return computed == stored_hash return False - + def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]: """Get user by ID""" try: @@ -82,7 +94,7 @@ class UserService: credits, vip_expires_at, timezone, last_login_at, created_at, updated_at FROM qd_users WHERE id = ? """, - (user_id,) + (user_id,), ) row = cur.fetchone() cur.close() @@ -90,7 +102,7 @@ class UserService: except Exception as e: logger.error(f"get_user_by_id failed: {e}") return None - + def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]: """Get user by username (includes password_hash for auth)""" try: @@ -102,7 +114,7 @@ class UserService: status, role, timezone, last_login_at, created_at, updated_at FROM qd_users WHERE username = ? """, - (username,) + (username,), ) row = cur.fetchone() cur.close() @@ -110,7 +122,7 @@ class UserService: except Exception as e: logger.error(f"get_user_by_username failed: {e}") return None - + def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]: """Get user by email (includes password_hash for auth)""" if not email: @@ -124,7 +136,7 @@ class UserService: status, role, timezone, last_login_at, created_at, updated_at FROM qd_users WHERE LOWER(email) = LOWER(?) """, - (email,) + (email,), ) row = cur.fetchone() cur.close() @@ -132,7 +144,7 @@ class UserService: except Exception as e: logger.error(f"get_user_by_email failed: {e}") return None - + def authenticate(self, username: str, password: str) -> Optional[Dict[str, Any]]: """ Authenticate user with username/email and password. @@ -141,38 +153,35 @@ class UserService: """ # Try username first user = self.get_user_by_username(username) - + # If not found, try email (supports both username and email login) if not user: user = self.get_user_by_email(username) - + if not user: return None - - if user.get('status') != 'active': + + if user.get("status") != "active": logger.warning(f"Login attempt for disabled user: {username}") return None - - password_hash = user.get('password_hash', '') - + + password_hash = user.get("password_hash", "") + # Check if user has no password (code-login user) - if not password_hash or password_hash.strip() == '': + if not password_hash or password_hash.strip() == "": logger.info(f"Password login attempted for code-login user: {username}") # Return a special marker to indicate no password set # This allows the caller to provide a more specific error message - return {'_no_password': True, **user} - + return {"_no_password": True, **user} + if not self.verify_password(password, password_hash): return None - + # Update last login time try: with get_db_connection() as db: cur = db.cursor() - cur.execute( - "UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", - (user['id'],) - ) + cur.execute("UPDATE qd_users SET last_login_at = NOW() WHERE id = ?", (user["id"],)) db.commit() affected = cur.rowcount cur.close() @@ -182,45 +191,42 @@ class UserService: logger.info(f"Updated last_login_at for user_id={user['id']}") except Exception as e: logger.error(f"Failed to update last_login_at for user_id={user.get('id')}: {e}") - + # Remove password_hash from return value - user.pop('password_hash', None) + user.pop("password_hash", None) return user - + def get_token_version(self, user_id: int) -> int: """ Get the user's current token version number. - + Args: user_id: user ID - + Returns: Current token version number, default is 1 """ try: with get_db_connection() as db: cur = db.cursor() - cur.execute( - "SELECT token_version FROM qd_users WHERE id = ?", - (user_id,) - ) + cur.execute("SELECT token_version FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() if row: - return int(row.get('token_version') or 1) + return int(row.get("token_version") or 1) return 1 except Exception as e: logger.error(f"get_token_version failed: {e}") return 1 - + def increment_token_version(self, user_id: int) -> int: """ Increment the user's token version number and invalidate the old token. Used to implement single client login (kick out other devices). - + Args: user_id: user ID - + Returns: New token version number """ @@ -230,33 +236,30 @@ class UserService: # Increment token_version cur.execute( """ - UPDATE qd_users + UPDATE qd_users SET token_version = COALESCE(token_version, 0) + 1, updated_at = NOW() WHERE id = ? """, - (user_id,) + (user_id,), ) db.commit() - + # Get new token_version - cur.execute( - "SELECT token_version FROM qd_users WHERE id = ?", - (user_id,) - ) + cur.execute("SELECT token_version FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() - - new_version = int(row.get('token_version') or 1) if row else 1 + + new_version = int(row.get("token_version") or 1) if row else 1 logger.info(f"Incremented token_version for user_id={user_id} to {new_version}") return new_version except Exception as e: logger.error(f"increment_token_version failed: {e}") return 1 - + def create_user(self, data: Dict[str, Any] = None, **kwargs) -> Optional[int]: """ Create a new user. - + Args: data: dict with user fields, OR use keyword arguments: username: str (required), @@ -267,7 +270,7 @@ class UserService: status: str (optional, default 'active'), email_verified: bool (optional, default False), referred_by: int (optional, referrer user ID) - + Returns: New user ID or None if failed """ @@ -276,99 +279,99 @@ class UserService: data = kwargs else: data = {**data, **kwargs} - - username = (data.get('username') or '').strip() - password = data.get('password') # Can be None for code-login users - + + username = (data.get("username") or "").strip() + password = data.get("password") # Can be None for code-login users + if not username: raise ValueError("Username is required") - + if len(username) < 3 or len(username) > 50: raise ValueError("Username must be 3-50 characters") - + # Password validation only if provided if password and len(password) < 6: raise ValueError("Password must be at least 6 characters") - + # Check if username already exists existing = self.get_user_by_username(username) if existing: raise ValueError("Username already exists") - + # Hash password or use empty string for code-login users - password_hash = self.hash_password(password) if password else '' - email = (data.get('email') or '').strip() or None - nickname = (data.get('nickname') or '').strip() or username - role = data.get('role', 'user') - status = data.get('status', 'active') - email_verified = data.get('email_verified', False) - referred_by = data.get('referred_by') # Referrer user ID - + password_hash = self.hash_password(password) if password else "" + email = (data.get("email") or "").strip() or None + nickname = (data.get("nickname") or "").strip() or username + role = data.get("role", "user") + status = data.get("status", "active") + email_verified = data.get("email_verified", False) + referred_by = data.get("referred_by") # Referrer user ID + if role not in self.ROLES: - role = 'user' - + role = "user" + try: with get_db_connection() as db: cur = db.cursor() cur.execute( """ - INSERT INTO qd_users + INSERT INTO qd_users (username, password_hash, email, nickname, role, status, email_verified, referred_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) """, - (username, password_hash, email, nickname, role, status, email_verified, referred_by) + (username, password_hash, email, nickname, role, status, email_verified, referred_by), ) db.commit() user_id = cur.lastrowid cur.close() - + # For PostgreSQL, get the ID differently if user_id is None: cur = db.cursor() cur.execute("SELECT id FROM qd_users WHERE username = ?", (username,)) row = cur.fetchone() - user_id = row['id'] if row else None + user_id = row["id"] if row else None cur.close() - + logger.info(f"Created user: {username} (id={user_id}, referred_by={referred_by})") return user_id except Exception as e: logger.error(f"create_user failed: {e}") raise - + def update_user(self, user_id: int, data: Dict[str, Any]) -> bool: """ Update user information. - + Args: user_id: User ID data: Fields to update (email, nickname, avatar, role, status) """ - allowed_fields = ['email', 'nickname', 'avatar', 'role', 'status', 'timezone'] + allowed_fields = ["email", "nickname", "avatar", "role", "status", "timezone"] updates = [] values = [] - + for field in allowed_fields: if field in data: value = data[field] - if field == 'role' and value not in self.ROLES: + if field == "role" and value not in self.ROLES: continue - if field == 'timezone': - s = '' if value is None else str(value).strip() + if field == "timezone": + s = "" if value is None else str(value).strip() if s and (len(s) > 64 or not _TIMEZONE_ID_RE.match(s)): continue - updates.append('timezone = ?') + updates.append("timezone = ?") values.append(s) continue updates.append(f"{field} = ?") values.append(value) - + if not updates: return False - + updates.append("updated_at = NOW()") values.append(user_id) - + try: with get_db_connection() as db: cur = db.cursor() @@ -380,49 +383,48 @@ class UserService: except Exception as e: logger.error(f"update_user failed: {e}") return False - + def change_password(self, user_id: int, old_password: str, new_password: str) -> bool: """Change user password (requires old password verification, except for users with no password)""" user = self.get_user_by_id(user_id) if not user: return False - + # Get full user with password_hash with get_db_connection() as db: cur = db.cursor() cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() - + if not row: return False - - password_hash = row.get('password_hash', '') - + + password_hash = row.get("password_hash", "") + # If user has no password (code-login user), allow setting password without old password - if not password_hash or password_hash.strip() == '': + if not password_hash or password_hash.strip() == "": logger.info(f"Setting initial password for code-login user: {user_id}") return self.reset_password(user_id, new_password) - + # For users with existing password, verify old password if not self.verify_password(old_password, password_hash): return False - + return self.reset_password(user_id, new_password) - + def reset_password(self, user_id: int, new_password: str) -> bool: """Reset user password (admin operation, no old password required)""" if len(new_password) < 6: raise ValueError("Password must be at least 6 characters") - + password_hash = self.hash_password(new_password) - + try: with get_db_connection() as db: cur = db.cursor() cur.execute( - "UPDATE qd_users SET password_hash = ?, updated_at = NOW() WHERE id = ?", - (password_hash, user_id) + "UPDATE qd_users SET password_hash = ?, updated_at = NOW() WHERE id = ?", (password_hash, user_id) ) db.commit() cur.close() @@ -430,11 +432,11 @@ class UserService: except Exception as e: logger.error(f"reset_password failed: {e}") return False - + def update_password(self, user_id: int, new_password: str) -> bool: """Alias for reset_password - update user password without old password verification""" return self.reset_password(user_id, new_password) - + def delete_user(self, user_id: int) -> bool: """Delete a user""" try: @@ -447,15 +449,15 @@ class UserService: except Exception as e: logger.error(f"delete_user failed: {e}") return False - + def list_users(self, page: int = 1, page_size: int = 20, search: str = None) -> Dict[str, Any]: """List all users with pagination and optional search""" offset = (page - 1) * page_size - + try: with get_db_connection() as db: cur = db.cursor() - + # Build WHERE clause for search where_clause = "" params = [] @@ -463,12 +465,12 @@ class UserService: search_term = f"%{search.strip()}%" where_clause = "WHERE username LIKE ? OR email LIKE ? OR nickname LIKE ?" params = [search_term, search_term, search_term] - + # Get total count count_sql = f"SELECT COUNT(*) as count FROM qd_users {where_clause}" cur.execute(count_sql, tuple(params)) - total = cur.fetchone()['count'] - + total = cur.fetchone()["count"] + # Get users query_sql = f""" SELECT id, username, email, nickname, avatar, status, role, @@ -481,22 +483,22 @@ class UserService: cur.execute(query_sql, tuple(params + [page_size, offset])) users = cur.fetchall() cur.close() - + return { - 'items': users, - 'total': total, - 'page': page, - 'page_size': page_size, - 'total_pages': (total + page_size - 1) // page_size + "items": users, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size, } except Exception as e: logger.error(f"list_users failed: {e}") - return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0} - + return {"items": [], "total": 0, "page": 1, "page_size": page_size, "total_pages": 0} + def get_user_permissions(self, role: str) -> List[str]: """Get permissions for a role""" - return self.ROLE_PERMISSIONS.get(role, self.ROLE_PERMISSIONS['viewer']) - + return self.ROLE_PERMISSIONS.get(role, self.ROLE_PERMISSIONS["viewer"]) + def ensure_admin_exists(self): """ Ensure at least one admin user exists. @@ -506,24 +508,26 @@ class UserService: with get_db_connection() as db: cur = db.cursor() cur.execute("SELECT COUNT(*) as count FROM qd_users") - count = cur.fetchone()['count'] + count = cur.fetchone()["count"] cur.close() - + if count == 0: # Create admin using env credentials - admin_user = os.getenv('ADMIN_USER', 'admin') - admin_password = os.getenv('ADMIN_PASSWORD', 'admin123') - admin_email = os.getenv('ADMIN_EMAIL', 'admin@example.com') + admin_user = os.getenv("ADMIN_USER", "admin") + admin_password = os.getenv("ADMIN_PASSWORD", "admin123") + admin_email = os.getenv("ADMIN_EMAIL", "admin@example.com") - self.create_user({ - 'username': admin_user, - 'password': admin_password, - 'email': admin_email, - 'nickname': 'Administrator', - 'role': 'admin', - 'status': 'active', - 'email_verified': True # Admin email is pre-verified - }) + self.create_user( + { + "username": admin_user, + "password": admin_password, + "email": admin_email, + "nickname": "Administrator", + "role": "admin", + "status": "active", + "email_verified": True, # Admin email is pre-verified + } + ) logger.info(f"Created admin user: {admin_user} ({admin_email})") except Exception as e: logger.error(f"ensure_admin_exists failed: {e}") @@ -532,6 +536,7 @@ class UserService: # Global singleton _user_service = None + def get_user_service() -> UserService: """Get UserService singleton""" global _user_service diff --git a/backend_api_python/app/utils/__init__.py b/backend_api_python/app/utils/__init__.py index 3520690..2ac0551 100644 --- a/backend_api_python/app/utils/__init__.py +++ b/backend_api_python/app/utils/__init__.py @@ -1,9 +1,9 @@ """ tool module """ -from app.utils.logger import get_logger + from app.utils.cache import CacheManager from app.utils.http import get_retry_session +from app.utils.logger import get_logger -__all__ = ['get_logger', 'CacheManager', 'get_retry_session'] - +__all__ = ["get_logger", "CacheManager", "get_retry_session"] diff --git a/backend_api_python/app/utils/auth.py b/backend_api_python/app/utils/auth.py index dece1db..803227e 100644 --- a/backend_api_python/app/utils/auth.py +++ b/backend_api_python/app/utils/auth.py @@ -4,44 +4,43 @@ Authentication Utilities JWT token generation, verification, and middleware decorators. Supports multi-user authentication with role-based access control. """ -import jwt + import datetime import os from functools import wraps -from flask import request, jsonify, g + +import jwt +from flask import g, jsonify, request + from app.config.settings import Config from app.utils.logger import get_logger logger = get_logger(__name__) -def generate_token(user_id: int, username: str, role: str = 'user', token_version: int = 1) -> str: +def generate_token(user_id: int, username: str, role: str = "user", token_version: int = 1) -> str: """ Generate JWT token with user information. - + Args: user_id: User ID username: Username role: User role (admin/manager/user/viewer) token_version: Token version for single-client enforcement - + Returns: JWT token string """ try: payload = { - 'exp': datetime.datetime.utcnow() + datetime.timedelta(days=7), - 'iat': datetime.datetime.utcnow(), - 'sub': username, - 'user_id': user_id, - 'role': role, - 'token_version': token_version, # For single client login control + "exp": datetime.datetime.utcnow() + datetime.timedelta(days=7), + "iat": datetime.datetime.utcnow(), + "sub": username, + "user_id": user_id, + "role": role, + "token_version": token_version, # For single client login control } - return jwt.encode( - payload, - Config.SECRET_KEY, - algorithm='HS256' - ) + return jwt.encode(payload, Config.SECRET_KEY, algorithm="HS256") except Exception as e: logger.error(f"Token generation failed: {e}") return None @@ -50,26 +49,26 @@ def generate_token(user_id: int, username: str, role: str = 'user', token_versio def verify_token(token: str) -> dict: """ Verify JWT token and return payload. - + Args: token: JWT token string - + Returns: Token payload dict or None if invalid """ try: - payload = jwt.decode(token, Config.SECRET_KEY, algorithms=['HS256']) - + payload = jwt.decode(token, Config.SECRET_KEY, algorithms=["HS256"]) + # Verify token_version (single client login control) - user_id = payload.get('user_id') - token_version = payload.get('token_version') - + user_id = payload.get("user_id") + token_version = payload.get("token_version") + if user_id and token_version is not None: # Check if token_version in database matches if not _verify_token_version(user_id, token_version): logger.debug(f"Token version mismatch for user {user_id}: expected current, got {token_version}") return None - + return payload except jwt.ExpiredSignatureError: logger.debug("Token expired") @@ -83,29 +82,27 @@ def _verify_token_version(user_id: int, token_version: int) -> bool: """ Verify that the token version matches the version stored in the database. Used to implement single client login (kick out duplicate logins). - + Args: user_id: user ID token_version: version number in Token - + Returns: True if version matches, False otherwise """ try: from app.utils.db import get_db_connection + with get_db_connection() as db: cur = db.cursor() - cur.execute( - "SELECT token_version FROM qd_users WHERE id = ?", - (user_id,) - ) + cur.execute("SELECT token_version FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() - + if not row: return False - - db_token_version = row.get('token_version') or 1 + + db_token_version = row.get("token_version") or 1 return int(token_version) == int(db_token_version) except Exception as e: logger.error(f"_verify_token_version failed: {e}") @@ -115,45 +112,46 @@ def _verify_token_version(user_id: int, token_version: int) -> bool: def get_current_user_id() -> int: """Get current user ID from flask.g context""" - return getattr(g, 'user_id', None) + return getattr(g, "user_id", None) def get_current_user_role() -> str: """Get current user role from flask.g context""" - return getattr(g, 'user_role', 'user') + return getattr(g, "user_role", "user") def login_required(f): """ Decorator that enforces Bearer token auth. - + Sets g.user, g.user_id, g.user_role on successful auth. """ + @wraps(f) def decorated(*args, **kwargs): token = None - + # Read token from Authorization: Bearer - auth_header = request.headers.get('Authorization') + auth_header = request.headers.get("Authorization") if auth_header: parts = auth_header.split() - if len(parts) == 2 and parts[0].lower() == 'bearer': + if len(parts) == 2 and parts[0].lower() == "bearer": token = parts[1] - + if not token: - return jsonify({'code': 401, 'msg': 'Token missing', 'data': None}), 401 - + return jsonify({"code": 401, "msg": "Token missing", "data": None}), 401 + payload = verify_token(token) if not payload: - return jsonify({'code': 401, 'msg': 'Token invalid or expired', 'data': None}), 401 - + return jsonify({"code": 401, "msg": "Token invalid or expired", "data": None}), 401 + # Store user info in flask.g - g.user = payload.get('sub') - g.user_id = payload.get('user_id') - g.user_role = payload.get('role', 'user') - + g.user = payload.get("sub") + g.user_id = payload.get("user_id") + g.user_role = payload.get("role", "user") + return f(*args, **kwargs) - + return decorated @@ -162,12 +160,14 @@ def admin_required(f): Decorator that requires admin role. Must be used after @login_required. """ + @wraps(f) def decorated(*args, **kwargs): - role = getattr(g, 'user_role', None) - if role != 'admin': - return jsonify({'code': 403, 'msg': 'Admin access required', 'data': None}), 403 + role = getattr(g, "user_role", None) + if role != "admin": + return jsonify({"code": 403, "msg": "Admin access required", "data": None}), 403 return f(*args, **kwargs) + return decorated @@ -176,12 +176,14 @@ def manager_required(f): Decorator that requires manager or admin role. Must be used after @login_required. """ + @wraps(f) def decorated(*args, **kwargs): - role = getattr(g, 'user_role', None) - if role not in ('admin', 'manager'): - return jsonify({'code': 403, 'msg': 'Manager access required', 'data': None}), 403 + role = getattr(g, "user_role", None) + if role not in ("admin", "manager"): + return jsonify({"code": 403, "msg": "Manager access required", "data": None}), 403 return f(*args, **kwargs) + return decorated @@ -189,38 +191,38 @@ def permission_required(permission: str): """ Decorator factory that checks for a specific permission. Must be used after @login_required. - + Usage: @login_required @permission_required('strategy') def my_endpoint(): ... """ + def decorator(f): @wraps(f) def decorated(*args, **kwargs): - role = getattr(g, 'user_role', 'user') - + role = getattr(g, "user_role", "user") + # Import here to avoid circular import from app.services.user_service import get_user_service + permissions = get_user_service().get_user_permissions(role) - + if permission not in permissions: - return jsonify({ - 'code': 403, - 'msg': f'Permission denied: {permission}', - 'data': None - }), 403 - + return jsonify({"code": 403, "msg": f"Permission denied: {permission}", "data": None}), 403 + return f(*args, **kwargs) + return decorated + return decorator # Legacy compatibility: single-user mode fallback def _is_single_user_mode() -> bool: """Check if system is in single-user (legacy) mode""" - return os.getenv('SINGLE_USER_MODE', 'false').lower() == 'true' + return os.getenv("SINGLE_USER_MODE", "false").lower() == "true" def authenticate_legacy(username: str, password: str) -> dict: @@ -230,9 +232,9 @@ def authenticate_legacy(username: str, password: str) -> dict: """ if username == Config.ADMIN_USER and password == Config.ADMIN_PASSWORD: return { - 'user_id': 1, - 'username': username, - 'role': 'admin', - 'nickname': 'Admin', + "user_id": 1, + "username": username, + "role": "admin", + "nickname": "Admin", } return None diff --git a/backend_api_python/app/utils/cache.py b/backend_api_python/app/utils/cache.py index 389f0a3..1db70d3 100644 --- a/backend_api_python/app/utils/cache.py +++ b/backend_api_python/app/utils/cache.py @@ -3,24 +3,25 @@ Cache utilities. Local-first behavior: use in-memory cache by default. Redis is only used when explicitly enabled via environment variables. """ -import time -import threading -from typing import Optional, Any -import json -from app.utils.logger import get_logger +import json +import threading +import time +from typing import Any, Optional + from app.config import CacheConfig +from app.utils.logger import get_logger logger = get_logger(__name__) class MemoryCache: """In-memory cache (an alternative if Redis is unavailable)""" - + def __init__(self): self._cache = {} self._lock = threading.Lock() - + def get(self, key: str) -> Optional[str]: with self._lock: if key in self._cache: @@ -30,17 +31,17 @@ class MemoryCache: else: del self._cache[key] return None - + def setex(self, key: str, ttl: int, value: str): with self._lock: expiry = time.time() + ttl self._cache[key] = (value, expiry) - + def delete(self, key: str): with self._lock: if key in self._cache: del self._cache[key] - + def clear(self): with self._lock: self._cache.clear() @@ -48,10 +49,10 @@ class MemoryCache: class CacheManager: """cache manager""" - + _instance = None _lock = threading.Lock() - + def __new__(cls): if cls._instance is None: with cls._lock: @@ -59,11 +60,11 @@ class CacheManager: cls._instance = super().__new__(cls) cls._instance._initialized = False return cls._instance - + def __init__(self): if self._initialized: return - + self._initialized = True self._client = None self._use_redis = False @@ -77,6 +78,7 @@ class CacheManager: # Try Redis only when enabled. try: import redis + from app.config import RedisConfig self._client = redis.Redis( @@ -86,7 +88,7 @@ class CacheManager: password=RedisConfig.PASSWORD, decode_responses=True, socket_connect_timeout=RedisConfig.CONNECT_TIMEOUT, - socket_timeout=RedisConfig.SOCKET_TIMEOUT + socket_timeout=RedisConfig.SOCKET_TIMEOUT, ) self._client.ping() self._use_redis = True @@ -96,7 +98,7 @@ class CacheManager: logger.info(f"Redis is enabled but unavailable; using in-memory cache instead: {e}") self._client = MemoryCache() self._use_redis = False - + def get(self, key: str) -> Optional[Any]: """Get cache""" try: @@ -107,22 +109,21 @@ class CacheManager: except Exception as e: logger.error(f"Cache read failed: {e}") return None - + def set(self, key: str, value: Any, ttl: int = 300): """Set up cache""" try: self._client.setex(key, ttl, json.dumps(value)) except Exception as e: logger.error(f"Cache write failed: {e}") - + def delete(self, key: str): """Delete cache""" try: self._client.delete(key) except Exception as e: logger.error(f"Cache delete failed: {e}") - + @property def is_redis(self) -> bool: return self._use_redis - diff --git a/backend_api_python/app/utils/config_loader.py b/backend_api_python/app/utils/config_loader.py index 9e23879..413c0f8 100644 --- a/backend_api_python/app/utils/config_loader.py +++ b/backend_api_python/app/utils/config_loader.py @@ -10,8 +10,9 @@ flat keys like `openrouter.api_key` become nested dicts like: "openrouter": {"api_key": "..."} } """ -from typing import Dict, Any, Optional, List, Tuple + import os +from typing import Any, Dict, List, Optional, Tuple from app.utils.logger import get_logger @@ -31,15 +32,15 @@ def load_addon_config() -> Dict[str, Any]: Nested config dict (PHP-compatible shape) """ global _config_cache - + # If the cache exists, return directly if _config_cache is not None: return _config_cache - + config: Dict[str, Any] = {} def set_nested(cfg: Dict[str, Any], dotted_key: str, value: Any) -> None: - keys = dotted_key.split('.') + keys = dotted_key.split(".") ref = cfg for i, k in enumerate(keys): if i == len(keys) - 1: @@ -54,82 +55,66 @@ def load_addon_config() -> Dict[str, Any]: if val is None: return None val = str(val).strip() - return val if val != '' else None + return val if val != "" else None # Map env vars to PHP-style dotted keys. mappings: List[Tuple[str, str, str]] = [ # internal - ('INTERNAL_API_KEY', 'internal_api.key', 'string'), - + ("INTERNAL_API_KEY", "internal_api.key", "string"), # OpenRouter / LLM - ('OPENROUTER_API_KEY', 'openrouter.api_key', 'string'), - ('OPENROUTER_API_URL', 'openrouter.api_url', 'string'), - ('OPENROUTER_MODEL', 'openrouter.model', 'string'), - ('OPENROUTER_TEMPERATURE', 'openrouter.temperature', 'float'), - ('OPENROUTER_MAX_TOKENS', 'openrouter.max_tokens', 'int'), - ('OPENROUTER_TIMEOUT', 'openrouter.timeout', 'int'), - ('OPENROUTER_CONNECT_TIMEOUT', 'openrouter.connect_timeout', 'int'), - + ("OPENROUTER_API_KEY", "openrouter.api_key", "string"), + ("OPENROUTER_API_URL", "openrouter.api_url", "string"), + ("OPENROUTER_MODEL", "openrouter.model", "string"), + ("OPENROUTER_TEMPERATURE", "openrouter.temperature", "float"), + ("OPENROUTER_MAX_TOKENS", "openrouter.max_tokens", "int"), + ("OPENROUTER_TIMEOUT", "openrouter.timeout", "int"), + ("OPENROUTER_CONNECT_TIMEOUT", "openrouter.connect_timeout", "int"), # OpenAI Direct - ('OPENAI_API_KEY', 'openai.api_key', 'string'), - ('OPENAI_BASE_URL', 'openai.base_url', 'string'), - ('OPENAI_MODEL', 'openai.model', 'string'), - + ("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'), - + ("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'), - + ("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'), - + ("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'), - + ("LLM_PROVIDER", "llm.provider", "string"), # App - ('RATE_LIMIT', 'app.rate_limit', 'int'), - ('ENABLE_CACHE', 'app.enable_cache', 'bool'), - ('ENABLE_REQUEST_LOG', 'app.enable_request_log', 'bool'), - + ("RATE_LIMIT", "app.rate_limit", "int"), + ("ENABLE_CACHE", "app.enable_cache", "bool"), + ("ENABLE_REQUEST_LOG", "app.enable_request_log", "bool"), # Data source common - ('DATA_SOURCE_TIMEOUT', 'data_source.timeout', 'int'), - ('DATA_SOURCE_RETRY', 'data_source.retry_count', 'int'), - ('DATA_SOURCE_RETRY_BACKOFF', 'data_source.retry_backoff', 'float'), - + ("DATA_SOURCE_TIMEOUT", "data_source.timeout", "int"), + ("DATA_SOURCE_RETRY", "data_source.retry_count", "int"), + ("DATA_SOURCE_RETRY_BACKOFF", "data_source.retry_backoff", "float"), # Finnhub - ('FINNHUB_API_KEY', 'finnhub.api_key', 'string'), - ('FINNHUB_TIMEOUT', 'finnhub.timeout', 'int'), - ('FINNHUB_RATE_LIMIT', 'finnhub.rate_limit', 'int'), - + ("FINNHUB_API_KEY", "finnhub.api_key", "string"), + ("FINNHUB_TIMEOUT", "finnhub.timeout", "int"), + ("FINNHUB_RATE_LIMIT", "finnhub.rate_limit", "int"), # CCXT - ('CCXT_DEFAULT_EXCHANGE', 'ccxt.default_exchange', 'string'), - ('CCXT_TIMEOUT', 'ccxt.timeout', 'int'), - + ("CCXT_DEFAULT_EXCHANGE", "ccxt.default_exchange", "string"), + ("CCXT_TIMEOUT", "ccxt.timeout", "int"), # Other sources - ('YFINANCE_TIMEOUT', 'yfinance.timeout', 'int'), - ('AKSHARE_TIMEOUT', 'akshare.timeout', 'int'), - ('TIINGO_API_KEY', 'tiingo.api_key', 'string'), - ('TIINGO_TIMEOUT', 'tiingo.timeout', 'int'), - ('TWELVE_DATA_API_KEY', 'twelve_data.api_key', 'string'), - + ("YFINANCE_TIMEOUT", "yfinance.timeout", "int"), + ("TIINGO_API_KEY", "tiingo.api_key", "string"), + ("TIINGO_TIMEOUT", "tiingo.timeout", "int"), # Search (Google CSE / Bing) - ('SEARCH_PROVIDER', 'search.provider', 'string'), - ('SEARCH_MAX_RESULTS', 'search.max_results', 'int'), - ('SEARCH_GOOGLE_API_KEY', 'search.google.api_key', 'string'), - ('SEARCH_GOOGLE_CX', 'search.google.cx', 'string'), - ('SEARCH_BING_API_KEY', 'search.bing.api_key', 'string'), - + ("SEARCH_PROVIDER", "search.provider", "string"), + ("SEARCH_MAX_RESULTS", "search.max_results", "int"), + ("SEARCH_GOOGLE_API_KEY", "search.google.api_key", "string"), + ("SEARCH_GOOGLE_CX", "search.google.cx", "string"), + ("SEARCH_BING_API_KEY", "search.bing.api_key", "string"), # Tavily (AI-optimized search) - ('TAVILY_API_KEYS', 'tavily.api_keys', 'string'), - + ("TAVILY_API_KEYS", "tavily.api_keys", "string"), # SerpAPI (Google/Bing scraper) - ('SERPAPI_KEYS', 'serpapi.api_keys', 'string'), + ("SERPAPI_KEYS", "serpapi.api_keys", "string"), ] for env_name, dotted_key, value_type in mappings: @@ -149,77 +134,78 @@ def load_addon_config() -> Dict[str, Any]: def _convert_config_value(value: str, value_type: str) -> Any: """ Convert configuration values ​​according to type (consistent with the convertConfigValue method on the PHP side) - + Args: value: configuration value string (may be None) value_type: configuration type - + Returns: Converted configuration value """ # Handling None or null values - if value is None or value == '': - if value_type == 'int': + if value is None or value == "": + if value_type == "int": return 0 - elif value_type == 'float': + elif value_type == "float": return 0.0 - elif value_type == 'bool': + elif value_type == "bool": return False - elif value_type == 'json': + elif value_type == "json": return {} else: - return '' - + return "" + try: - if value_type == 'int': + if value_type == "int": return int(value) - elif value_type == 'float': + elif value_type == "float": return float(value) - elif value_type == 'bool': - return bool(value) or value == '1' or value == 'true' or value == 'True' - elif value_type == 'json': + elif value_type == "bool": + return bool(value) or value == "1" or value == "true" or value == "True" + elif value_type == "json": import json + try: return json.loads(value) if value else {} except (json.JSONDecodeError, TypeError): return {} else: - return str(value) if value is not None else '' + return str(value) if value is not None else "" except (ValueError, TypeError) as e: logger.warning(f"Config value type conversion failed: value={value}, type={value_type}, error={str(e)}") # Returns default value if conversion fails - if value_type == 'int': + if value_type == "int": return 0 - elif value_type == 'float': + elif value_type == "float": return 0.0 - elif value_type == 'bool': + elif value_type == "bool": return False - elif value_type == 'json': + elif value_type == "json": return {} else: - return str(value) if value is not None else '' + return str(value) if value is not None else "" def get_internal_api_key() -> Optional[str]: """ Get the internal API key (preferably read from environment variables) - + Returns: Internal API key, returns None if not configured """ try: - env_val = os.getenv('INTERNAL_API_KEY', '').strip() + env_val = os.getenv("INTERNAL_API_KEY", "").strip() if env_val: return env_val config = load_addon_config() - api_key = config.get('internal_api', {}).get('key') - + api_key = config.get("internal_api", {}).get("key") + if api_key: logger.debug(f"Loaded INTERNAL_API_KEY from env-config shape, length: {len(api_key)}") else: logger.warning("Missing INTERNAL_API_KEY (env).") - + return api_key except Exception as e: logger.error(f"Failed to load internal API key: {str(e)}") @@ -233,4 +219,3 @@ def clear_config_cache(): global _config_cache _config_cache = None logger.debug("Addon config cache cleared") - diff --git a/backend_api_python/app/utils/db.py b/backend_api_python/app/utils/db.py index 98a8733..0ce8173 100644 --- a/backend_api_python/app/utils/db.py +++ b/backend_api_python/app/utils/db.py @@ -5,7 +5,7 @@ Provides unified interface for PostgreSQL database operations. Usage: from app.utils.db import get_db_connection - + with get_db_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,)) @@ -18,16 +18,22 @@ Configuration: # Re-export from PostgreSQL module from app.utils.db_postgres import ( - get_pg_connection as get_db_connection, - get_pg_connection_sync as get_db_connection_sync, - is_postgres_available, close_pool as close_db, ) +from app.utils.db_postgres import ( + get_pg_connection as get_db_connection, +) +from app.utils.db_postgres import ( + get_pg_connection_sync as get_db_connection_sync, +) +from app.utils.db_postgres import ( + is_postgres_available, +) def get_db_type() -> str: """Get database type (always postgresql)""" - return 'postgresql' + return "postgresql" def is_postgres() -> bool: @@ -42,6 +48,7 @@ def init_database(): """ if is_postgres_available(): from app.utils.logger import get_logger + logger = get_logger(__name__) logger.info("PostgreSQL connection verified") else: @@ -55,11 +62,11 @@ def close_db_connection(): __all__ = [ - 'get_db_connection', - 'get_db_connection_sync', - 'close_db_connection', - 'init_database', - 'close_db', - 'get_db_type', - 'is_postgres', + "get_db_connection", + "get_db_connection_sync", + "close_db_connection", + "init_database", + "close_db", + "get_db_type", + "is_postgres", ] diff --git a/backend_api_python/app/utils/db_postgres.py b/backend_api_python/app/utils/db_postgres.py index 599b546..d66cd7c 100644 --- a/backend_api_python/app/utils/db_postgres.py +++ b/backend_api_python/app/utils/db_postgres.py @@ -4,19 +4,21 @@ PostgreSQL Database Connection Utility Supports multi-user mode with connection pooling. Provides placeholder conversion for backward compatibility with legacy code. """ + import os import threading -from typing import Optional, Any, List, Dict from contextlib import contextmanager +from typing import Any, Dict, List, Optional + from app.utils.logger import get_logger logger = get_logger(__name__) # Try to import psycopg2 try: - import psycopg2 from psycopg2 import pool from psycopg2.extras import RealDictCursor + HAS_PSYCOPG2 = True except ImportError: HAS_PSYCOPG2 = False @@ -29,7 +31,7 @@ _pool_lock = threading.Lock() def _get_database_url() -> str: """Get database connection URL from environment""" - return os.getenv('DATABASE_URL', '').strip() + return os.getenv("DATABASE_URL", "").strip() def _parse_database_url(url: str) -> Dict[str, Any]: @@ -38,131 +40,133 @@ def _parse_database_url(url: str) -> Dict[str, Any]: """ if not url: return {} - + # Remove protocol prefix - if url.startswith('postgresql://'): + if url.startswith("postgresql://"): url = url[13:] - elif url.startswith('postgres://'): + elif url.startswith("postgres://"): url = url[11:] else: return {} - + result = {} - + # Split user:password@host:port/dbname - if '@' in url: - auth, hostpart = url.rsplit('@', 1) - if ':' in auth: - result['user'], result['password'] = auth.split(':', 1) + if "@" in url: + auth, hostpart = url.rsplit("@", 1) + if ":" in auth: + result["user"], result["password"] = auth.split(":", 1) else: - result['user'] = auth + result["user"] = auth else: hostpart = url - + # Split host:port/dbname - if '/' in hostpart: - hostport, result['dbname'] = hostpart.split('/', 1) + if "/" in hostpart: + hostport, result["dbname"] = hostpart.split("/", 1) else: hostport = hostpart - - if ':' in hostport: - result['host'], port_str = hostport.split(':', 1) - result['port'] = int(port_str) + + if ":" in hostport: + result["host"], port_str = hostport.split(":", 1) + result["port"] = int(port_str) else: - result['host'] = hostport - result['port'] = 5432 - + result["host"] = hostport + result["port"] = 5432 + return result def _get_connection_pool(): """Get or create connection pool""" global _connection_pool - + if _connection_pool is not None: return _connection_pool - + with _pool_lock: if _connection_pool is not None: return _connection_pool - + if not HAS_PSYCOPG2: raise RuntimeError("psycopg2 is not installed. Cannot use PostgreSQL.") - + db_url = _get_database_url() if not db_url: raise RuntimeError("DATABASE_URL environment variable is not set.") - + params = _parse_database_url(db_url) if not params: raise RuntimeError(f"Invalid DATABASE_URL format: {db_url}") - + try: _connection_pool = pool.ThreadedConnectionPool( minconn=2, maxconn=20, - host=params.get('host', 'localhost'), - port=params.get('port', 5432), - user=params.get('user', 'quantdinger'), - password=params.get('password', ''), - dbname=params.get('dbname', 'quantdinger'), + host=params.get("host", "localhost"), + port=params.get("port", 5432), + user=params.get("user", "quantdinger"), + password=params.get("password", ""), + dbname=params.get("dbname", "quantdinger"), connect_timeout=10, ) - logger.info(f"PostgreSQL connection pool created: {params.get('host')}:{params.get('port')}/{params.get('dbname')}") + logger.info( + f"PostgreSQL connection pool created: {params.get('host')}:{params.get('port')}/{params.get('dbname')}" + ) except Exception as e: logger.error(f"Failed to create PostgreSQL connection pool: {e}") raise - + return _connection_pool class PostgresCursor: """PostgreSQL cursor wrapper with placeholder conversion for backward compatibility""" - + def __init__(self, cursor): self._cursor = cursor self._last_insert_id = None - + def _convert_placeholders(self, query: str) -> str: """ Convert ? placeholders to PostgreSQL %s for backward compatibility. Also handle some SQL syntax differences. """ # Replace ? -> %s - query = query.replace('?', '%s') - + query = query.replace("?", "%s") + # INSERT OR IGNORE -> PostgreSQL: INSERT ... ON CONFLICT DO NOTHING - query = query.replace('INSERT OR IGNORE', 'INSERT') - + query = query.replace("INSERT OR IGNORE", "INSERT") + return query - + def execute(self, query: str, args: Any = None): """Execute SQL statement""" query = self._convert_placeholders(query) - + # Check if this is an INSERT and add RETURNING id if not present - is_insert = query.strip().upper().startswith('INSERT') - if is_insert and 'RETURNING' not in query.upper(): - query = query.rstrip(';').rstrip() + ' RETURNING id' - + is_insert = query.strip().upper().startswith("INSERT") + if is_insert and "RETURNING" not in query.upper(): + query = query.rstrip(";").rstrip() + " RETURNING id" + if args: if not isinstance(args, (tuple, list)): args = (args,) result = self._cursor.execute(query, args) else: result = self._cursor.execute(query) - + # Capture last insert id for INSERT statements if is_insert: try: row = self._cursor.fetchone() - if row and 'id' in row: - self._last_insert_id = row['id'] + if row and "id" in row: + self._last_insert_id = row["id"] except Exception: pass - + return result - + def fetchone(self) -> Optional[Dict[str, Any]]: """Fetch single row""" row = self._cursor.fetchone() @@ -170,7 +174,7 @@ class PostgresCursor: return None # RealDictCursor already returns a dict, so return as-is return row if isinstance(row, dict) else dict(row) if row else None - + def fetchall(self) -> List[Dict[str, Any]]: """Fetch all rows""" rows = self._cursor.fetchall() @@ -178,16 +182,16 @@ class PostgresCursor: return [] # RealDictCursor already returns dicts, so return as-is return [row if isinstance(row, dict) else dict(row) for row in rows] - + def close(self): """Close cursor""" self._cursor.close() - + @property def lastrowid(self) -> Optional[int]: """Get last inserted row ID""" return self._last_insert_id - + @property def rowcount(self) -> int: """Get affected row count""" @@ -196,23 +200,23 @@ class PostgresCursor: class PostgresConnection: """PostgreSQL connection wrapper""" - + def __init__(self, conn): self._conn = conn self._pool = _get_connection_pool() - + def cursor(self) -> PostgresCursor: """Create cursor""" return PostgresCursor(self._conn.cursor(cursor_factory=RealDictCursor)) - + def commit(self): """Commit transaction""" self._conn.commit() - + def rollback(self): """Rollback transaction""" self._conn.rollback() - + def close(self): """Return connection to pool""" if self._pool and self._conn: @@ -280,7 +284,7 @@ def execute_sql(sql: str, params: tuple = None) -> List[Dict[str, Any]]: with get_pg_connection() as conn: cursor = conn.cursor() cursor.execute(sql, params) - if sql.strip().upper().startswith('SELECT'): + if sql.strip().upper().startswith("SELECT"): return cursor.fetchall() conn.commit() return [] @@ -290,11 +294,11 @@ def is_postgres_available() -> bool: """Check if PostgreSQL is available""" if not HAS_PSYCOPG2: return False - + db_url = _get_database_url() if not db_url: return False - + try: with get_pg_connection() as conn: cursor = conn.cursor() diff --git a/backend_api_python/app/utils/http.py b/backend_api_python/app/utils/http.py index 5f96d11..44e26fb 100644 --- a/backend_api_python/app/utils/http.py +++ b/backend_api_python/app/utils/http.py @@ -1,24 +1,23 @@ """ HTTP tool module """ + import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def get_retry_session( - retries: int = 3, - backoff_factor: float = 0.5, - status_forcelist: tuple = (500, 502, 503, 504) + retries: int = 3, backoff_factor: float = 0.5, status_forcelist: tuple = (500, 502, 503, 504) ) -> requests.Session: """ Get HTTP Session with retry mechanism - + Args: retries: number of retries backoff_factor: retry interval factor status_forcelist: HTTP status codes that need to be retried - + Returns: Configured Session instance """ @@ -31,11 +30,10 @@ def get_retry_session( status_forcelist=status_forcelist, ) adapter = HTTPAdapter(max_retries=retry) - session.mount('http://', adapter) - session.mount('https://', adapter) + session.mount("http://", adapter) + session.mount("https://", adapter) return session # Global shared session global_session = get_retry_session() - diff --git a/backend_api_python/app/utils/language.py b/backend_api_python/app/utils/language.py index b449cec..dab80d3 100644 --- a/backend_api_python/app/utils/language.py +++ b/backend_api_python/app/utils/language.py @@ -9,7 +9,6 @@ from __future__ import annotations from typing import Optional - SUPPORTED_LANGS = { "en-US", "zh-CN", @@ -82,5 +81,3 @@ def detect_request_language(flask_request, body: Optional[dict] = None, default: return lang return default - - diff --git a/backend_api_python/app/utils/logger.py b/backend_api_python/app/utils/logger.py index 6d568de..2d1a9fb 100644 --- a/backend_api_python/app/utils/logger.py +++ b/backend_api_python/app/utils/logger.py @@ -1,6 +1,7 @@ """ Logging utilities (local-only friendly). """ + import logging import os from logging.handlers import RotatingFileHandler @@ -8,35 +9,32 @@ from logging.handlers import RotatingFileHandler def setup_logger(): """Configure global logs""" - log_level = os.getenv('LOG_LEVEL', 'INFO') - log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - - logging.basicConfig( - level=getattr(logging, log_level.upper()), - format=log_format - ) - + log_level = os.getenv("LOG_LEVEL", "INFO") + log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + logging.basicConfig(level=getattr(logging, log_level.upper()), format=log_format) + # Filter werkzeug's INFO level logs (reduce noise) # Only keep WARNING and above levels - werkzeug_logger = logging.getLogger('werkzeug') + werkzeug_logger = logging.getLogger("werkzeug") werkzeug_logger.setLevel(logging.WARNING) - + # Filter INFO level logs for kline routes (reduce noise) # Only keep WARNING and above levels - kline_logger = logging.getLogger('app.routes.kline') + kline_logger = logging.getLogger("app.routes.kline") kline_logger.setLevel(logging.WARNING) - + # Create log directory - log_dir = 'logs' + log_dir = "logs" if not os.path.exists(log_dir): os.makedirs(log_dir) - + # Add file handler file_handler = RotatingFileHandler( - os.path.join(log_dir, 'app.log'), - maxBytes=10*1024*1024, # 10MB + os.path.join(log_dir, "app.log"), + maxBytes=10 * 1024 * 1024, # 10MB backupCount=5, - encoding='utf-8' + encoding="utf-8", ) file_handler.setFormatter(logging.Formatter(log_format)) logging.getLogger().addHandler(file_handler) @@ -45,12 +43,11 @@ def setup_logger(): def get_logger(name: str) -> logging.Logger: """ Get the logger with the specified name - + Args: name: logger name - + Returns: Logger instance """ return logging.getLogger(name) - diff --git a/backend_api_python/app/utils/safe_exec.py b/backend_api_python/app/utils/safe_exec.py index fb7e895..f8dbd79 100644 --- a/backend_api_python/app/utils/safe_exec.py +++ b/backend_api_python/app/utils/safe_exec.py @@ -2,13 +2,14 @@ Secure code execution tools Provides timeouts, resource limits and a sandbox environment """ + +import os import signal import sys -import os import threading import traceback -from typing import Dict, Any, Optional, Tuple from contextlib import contextmanager +from typing import Any, Dict, Optional, Tuple from app.utils.logger import get_logger @@ -17,6 +18,7 @@ logger = get_logger(__name__) class TimeoutError(Exception): """Code execution timeout exception""" + pass @@ -24,39 +26,39 @@ class TimeoutError(Exception): def timeout_context(seconds: int): """ Code execution timeout context manager - + Notice: - Only works on Unix/Linux systems - Only valid in the main thread, non-main threads will be downgraded to unlimited timeout - Will downgrade to unlimited timeout on Windows - + Args: seconds: timeout (seconds) """ # Check if in main thread is_main_thread = threading.current_thread() is threading.main_thread() - - if sys.platform == 'win32': + + if sys.platform == "win32": # signal.alarm is not supported on Windows, only warnings can be logged logger.warning("Windows does not support signal-based timeouts; execution time limits may not work") yield return - + if not is_main_thread: # Non-main threads cannot use signal. Warnings are logged but timeouts are not limited. # logger.warning(f"Currently running in a non-main thread (thread: {threading.current_thread().name})," # f"signal timeout is not available, code execution may not limit the time") yield return - + def timeout_handler(signum, frame): raise TimeoutError(f"Code execution timed out after {seconds} seconds") - + try: # Set up signal handler old_handler = signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(seconds) - + try: yield finally: @@ -74,41 +76,42 @@ def safe_exec_code( exec_globals: Dict[str, Any], exec_locals: Optional[Dict[str, Any]] = None, timeout: int = 30, - max_memory_mb: Optional[int] = None + max_memory_mb: Optional[int] = None, ) -> Dict[str, Any]: """ Safe execution of Python code - + Args: code: Python code to execute exec_globals: dictionary of global variables exec_locals: dictionary of local variables (if None, exec_globals is used) timeout: timeout (seconds), default 30 seconds max_memory_mb: Maximum memory limit (MB), default 500MB - + Returns: Execution result dictionary, including: - success: bool, whether the execution was successful - error: str, error message (if failure) - result: Any, execution result (if any) - + Raises: TimeoutError: If the code execution times out """ if exec_locals is None: exec_locals = exec_globals - + # Set memory limit (if supported) if max_memory_mb is None: max_memory_mb = 500 # Default 500MB - + try: # Note: resource.setrlimit is process level and affects the entire API process. # The previous global limit of 500MB could prevent parallel strategies/threads from allocating memory. # Only set if SAFE_EXEC_ENABLE_RLIMIT is explicitly turned on. - if sys.platform != 'win32' and os.getenv('SAFE_EXEC_ENABLE_RLIMIT', 'false').lower() == 'true': + if sys.platform != "win32" and os.getenv("SAFE_EXEC_ENABLE_RLIMIT", "false").lower() == "true": try: import resource + max_memory_bytes = max_memory_mb * 1024 * 1024 resource.setrlimit(resource.RLIMIT_AS, (max_memory_bytes, max_memory_bytes)) logger.debug(f"Memory limit set: {max_memory_mb}MB (SAFE_EXEC_ENABLE_RLIMIT enabled)") @@ -116,150 +119,159 @@ def safe_exec_code( logger.warning(f"Failed to set memory limit: {str(e)}") else: logger.debug("No resource memory limit (SAFE_EXEC_ENABLE_RLIMIT disabled or unsupported platform)") - + # On Windows, timeout_context doesn't really limit the time # But a warning will be logged with timeout_context(timeout): exec(code, exec_globals, exec_locals) - - return { - 'success': True, - 'error': None, - 'result': None - } - - except MemoryError as e: + + return {"success": True, "error": None, "result": None} + + except MemoryError: error_msg = f"Code execution exceeded the memory limit of {max_memory_mb}MB" logger.error(f"Code execution out of memory (limit={max_memory_mb}MB)") - return { - 'success': False, - 'error': error_msg, - 'result': None - } + return {"success": False, "error": error_msg, "result": None} except TimeoutError as e: error_msg = str(e) logger.error(f"Code execution timed out (timeout={timeout}s)") - return { - 'success': False, - 'error': error_msg, - 'result': None - } + return {"success": False, "error": error_msg, "result": None} except Exception as e: error_msg = f"Code execution error: {str(e)}\n{traceback.format_exc()}" logger.error(f"Code execution error: {str(e)}") logger.error(traceback.format_exc()) - return { - 'success': False, - 'error': error_msg, - 'result': None - } + return {"success": False, "error": error_msg, "result": None} def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]: """ Verify code security (basic check) - + Check your code for dangerous function calls or imports - + Args: code: Python code to check - + Returns: (is_safe: bool, error_message: Optional[str]) """ import ast import re - + # Dangerous keywords and function names dangerous_patterns = [ # System command execution - r'\bos\.system\b', - r'\bos\.popen\b', - r'\bos\.spawn\b', - r'\bos\.exec\b', - r'\bos\.fork\b', - r'\bsubprocess\b', - r'\bcommands\b', + r"\bos\.system\b", + r"\bos\.popen\b", + r"\bos\.spawn\b", + r"\bos\.exec\b", + r"\bos\.fork\b", + r"\bsubprocess\b", + r"\bcommands\b", # code execution - r'\b__import__\s*\(', - r'\beval\s*\(', - r'\bexec\s*\(', - r'\bcompile\s*\(', + r"\b__import__\s*\(", + r"\beval\s*\(", + r"\bexec\s*\(", + r"\bcompile\s*\(", # File operations - r'\bopen\s*\(', - r'\bfile\s*\(', - r'\b__builtins__\b', + r"\bopen\s*\(", + r"\bfile\s*\(", + r"\b__builtins__\b", # module import - r'\bimport\s+os\b', - r'\bimport\s+sys\b', - r'\bimport\s+subprocess\b', - r'\bimport\s+pymysql\b', - r'\bimport\s+sqlite3\b', - r'\bimport\s+requests\b', - r'\bimport\s+urllib\b', - r'\bimport\s+http\b', - r'\bimport\s+socket\b', - r'\bimport\s+ftplib\b', - r'\bimport\s+telnetlib\b', - r'\bimport\s+pickle\b', - r'\bimport\s+cpickle\b', - r'\bimport\s+marshal\b', - r'\bimport\s+ctypes\b', - r'\bimport\s+multiprocessing\b', - r'\bimport\s+threading\b', - r'\bimport\s+concurrent\b', + r"\bimport\s+os\b", + r"\bimport\s+sys\b", + r"\bimport\s+subprocess\b", + r"\bimport\s+pymysql\b", + r"\bimport\s+sqlite3\b", + r"\bimport\s+requests\b", + r"\bimport\s+urllib\b", + r"\bimport\s+http\b", + r"\bimport\s+socket\b", + r"\bimport\s+ftplib\b", + r"\bimport\s+telnetlib\b", + r"\bimport\s+pickle\b", + r"\bimport\s+cpickle\b", + r"\bimport\s+marshal\b", + r"\bimport\s+ctypes\b", + r"\bimport\s+multiprocessing\b", + r"\bimport\s+threading\b", + r"\bimport\s+concurrent\b", # Reflection and metaprogramming (possibly used to bypass restrictions) - r'\bgetattr\s*\(.*__import__', - r'\bgetattr\s*\(.*eval', - r'\bgetattr\s*\(.*exec', - r'\bsetattr\s*\(', - r'\b__getattribute__\b', - r'\b__setattr__\b', - r'\b__dict__\b', - r'\bglobals\s*\(', - r'\blocals\s*\(', - r'\bdir\s*\(', - r'\btype\s*\(.*\)\s*\(', # type() may be used to create new types - r'\b__class__\b', - r'\b__bases__\b', - r'\b__subclasses__\b', - r'\b__mro__\b', - r'\b__init__\b.*__import__', - r'\b__new__\b.*__import__', + r"\bgetattr\s*\(.*__import__", + r"\bgetattr\s*\(.*eval", + r"\bgetattr\s*\(.*exec", + r"\bsetattr\s*\(", + r"\b__getattribute__\b", + r"\b__setattr__\b", + r"\b__dict__\b", + r"\bglobals\s*\(", + r"\blocals\s*\(", + r"\bdir\s*\(", + r"\btype\s*\(.*\)\s*\(", # type() may be used to create new types + r"\b__class__\b", + r"\b__bases__\b", + r"\b__subclasses__\b", + r"\b__mro__\b", + r"\b__init__\b.*__import__", + r"\b__new__\b.*__import__", # Other dangerous operations - r'\b__builtins__\s*\[', - r'\b__builtins__\s*\.', - r'\b__import__\s*\(', - r'\bimportlib\b', - r'\bimp\b', + r"\b__builtins__\s*\[", + r"\b__builtins__\s*\.", + r"\b__import__\s*\(", + r"\bimportlib\b", + r"\bimp\b", ] - + # Check your code for dangerous patterns for pattern in dangerous_patterns: if re.search(pattern, code): return False, f"Dangerous code pattern detected: {pattern}" - + # Try parsing the AST, checking for dangerous nodes try: tree = ast.parse(code) - + # List of dangerous modules (extended) dangerous_modules = [ - 'os', 'sys', 'subprocess', 'pymysql', 'sqlite3', - 'requests', 'urllib', 'http', 'socket', 'ftplib', 'telnetlib', - 'pickle', 'cpickle', 'marshal', 'ctypes', - 'multiprocessing', 'threading', 'concurrent', - 'importlib', 'imp', 'builtins' + "os", + "sys", + "subprocess", + "pymysql", + "sqlite3", + "requests", + "urllib", + "http", + "socket", + "ftplib", + "telnetlib", + "pickle", + "cpickle", + "marshal", + "ctypes", + "multiprocessing", + "threading", + "concurrent", + "importlib", + "imp", + "builtins", ] - + # List of dangerous functions (extended) # Note: hasattr is safe and is only used to check attributes, not to access dangerous_functions = [ - 'eval', 'exec', 'compile', '__import__', - 'getattr', 'setattr', 'delattr', # hasattr has been removed, it is safe - 'globals', 'locals', 'vars', 'dir', 'type' + "eval", + "exec", + "compile", + "__import__", + "getattr", + "setattr", + "delattr", # hasattr has been removed, it is safe + "globals", + "locals", + "vars", + "dir", + "type", ] - + # Check for dangerous function calls for node in ast.walk(tree): # Check for calls to dangerous functions @@ -268,42 +280,58 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]: func_name = node.func.id if func_name in dangerous_functions: return False, f"Dangerous function call detected: {func_name}()" - + # Check if there are calls to os.system etc. if isinstance(node.func, ast.Attribute): if isinstance(node.func.value, ast.Name): if node.func.value.id in dangerous_modules: return False, f"Dangerous module call detected: {node.func.value.id}.{node.func.attr}" - + # Check if there are bypass methods such as getattr(builtins, '__import__') - if isinstance(node.func, ast.Name) and node.func.id == 'getattr': + if isinstance(node.func, ast.Name) and node.func.id == "getattr": # Check the parameters of getattr if len(node.args) >= 2: - if isinstance(node.args[0], ast.Name) and node.args[0].id in ['builtins', '__builtins__']: + if isinstance(node.args[0], ast.Name) and node.args[0].id in ["builtins", "__builtins__"]: if isinstance(node.args[1], ast.Constant) and node.args[1].value in dangerous_functions: - return False, f"Detected an attempt to bypass restrictions via getattr: getattr({node.args[0].id}, '{node.args[1].value}')" - + return ( + False, + f"Detected an attempt to bypass restrictions via getattr: getattr({node.args[0].id}, '{node.args[1].value}')", + ) + # Check the import statement: the use of import is prohibited in user scripts (security dependencies are uniformly injected by the platform) for node in ast.walk(tree): if isinstance(node, ast.Import): - return False, "Import statements are not allowed in scripts. Use the platform-provided objects such as pd and np directly." - + return ( + False, + "Import statements are not allowed in scripts. Use the platform-provided objects such as pd and np directly.", + ) + if isinstance(node, ast.ImportFrom): - return False, "Import statements are not allowed in scripts. Use the platform-provided objects such as pd and np directly." - + return ( + False, + "Import statements are not allowed in scripts. Use the platform-provided objects such as pd and np directly.", + ) + # Check if there is an attempt to access __builtins__ for node in ast.walk(tree): if isinstance(node, ast.Attribute): - if isinstance(node.attr, str) and node.attr.startswith('__') and node.attr.endswith('__'): - if node.attr in ['__builtins__', '__import__', '__class__', '__bases__', '__subclasses__', '__mro__']: + if isinstance(node.attr, str) and node.attr.startswith("__") and node.attr.endswith("__"): + if node.attr in [ + "__builtins__", + "__import__", + "__class__", + "__bases__", + "__subclasses__", + "__mro__", + ]: # Check if used in dangerous context - if isinstance(node.value, ast.Name) and node.value.id in ['builtins', '__builtins__']: + if isinstance(node.value, ast.Name) and node.value.id in ["builtins", "__builtins__"]: return False, f"Dangerous attribute access detected: {node.value.id}.{node.attr}" - + except SyntaxError as e: return False, f"Code syntax error: {str(e)}" except Exception as e: # If AST parsing fails, log a warning but allow to continue (possibly incomplete code) logger.warning(f"AST parse failed; skipping safety checks: {str(e)}") - + return True, None diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 4cb6146..1c664cf 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -164,15 +164,10 @@ FINNHUB_TIMEOUT=10 FINNHUB_RATE_LIMIT=60 CCXT_DEFAULT_EXCHANGE=coinbase CCXT_TIMEOUT=10000 -AKSHARE_TIMEOUT=30 YFINANCE_TIMEOUT=30 TIINGO_API_KEY= TIINGO_TIMEOUT=10 -# Twelve Data (CN/HK stock K-lines — recommended for overseas servers) -# Free tier: 800 API credits/day, 8 requests/minute. https://twelvedata.com -TWELVE_DATA_API_KEY= - # AI search / news SEARCH_PROVIDER=google SEARCH_MAX_RESULTS=10 diff --git a/backend_api_python/gunicorn_config.py b/backend_api_python/gunicorn_config.py index 1ec4194..0d77e77 100644 --- a/backend_api_python/gunicorn_config.py +++ b/backend_api_python/gunicorn_config.py @@ -1,6 +1,7 @@ """ Gunicorn configuration file (production environment) """ + import multiprocessing # server socket @@ -34,4 +35,3 @@ tmp_upload_dir = None # SSL (if required) # keyfile = None # certfile = None - diff --git a/backend_api_python/migrations/init.sql b/backend_api_python/migrations/init.sql index 4bd3324..7148bba 100644 --- a/backend_api_python/migrations/init.sql +++ b/backend_api_python/migrations/init.sql @@ -232,7 +232,7 @@ END$$; DO $$ BEGIN IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_strategies_trading' AND column_name = 'last_rebalance_at' ) THEN ALTER TABLE qd_strategies_trading ADD COLUMN last_rebalance_at TIMESTAMP; @@ -663,28 +663,6 @@ INSERT INTO qd_market_symbols (market, symbol, name, exchange, currency, is_acti ('Futures', 'ZW', 'Wheat', 'CBOT', 'USD', 1, 1, 93), ('Futures', 'ES', 'S&P 500 E-mini', 'CME', 'USD', 1, 1, 92), ('Futures', 'NQ', 'NASDAQ 100 E-mini', 'CME', 'USD', 1, 1, 91), --- A股 (CNStock) -('CNStock', '600519', '贵州茅台', 'SSE', 'CNY', 1, 1, 100), -('CNStock', '600036', '招商银行', 'SSE', 'CNY', 1, 1, 99), -('CNStock', '601318', '中国平安', 'SSE', 'CNY', 1, 1, 98), -('CNStock', '600900', '长江电力', 'SSE', 'CNY', 1, 1, 97), -('CNStock', '601899', '紫金矿业', 'SSE', 'CNY', 1, 1, 96), -('CNStock', '000858', '五粮液', 'SZSE', 'CNY', 1, 1, 95), -('CNStock', '000333', '美的集团', 'SZSE', 'CNY', 1, 1, 94), -('CNStock', '002594', '比亚迪', 'SZSE', 'CNY', 1, 1, 93), -('CNStock', '300750', '宁德时代', 'SZSE', 'CNY', 1, 1, 92), -('CNStock', '000001', '平安银行', 'SZSE', 'CNY', 1, 1, 91), --- 港股/H股 (HKStock) -('HKStock', '00700', '腾讯控股', 'HKEX', 'HKD', 1, 1, 100), -('HKStock', '09988', '阿里巴巴-W', 'HKEX', 'HKD', 1, 1, 99), -('HKStock', '03690', '美团-W', 'HKEX', 'HKD', 1, 1, 98), -('HKStock', '01810', '小米集团-W', 'HKEX', 'HKD', 1, 1, 97), -('HKStock', '00939', '建设银行', 'HKEX', 'HKD', 1, 1, 96), -('HKStock', '01299', '友邦保险', 'HKEX', 'HKD', 1, 1, 95), -('HKStock', '02318', '中国平安', 'HKEX', 'HKD', 1, 1, 94), -('HKStock', '00388', '香港交易所', 'HKEX', 'HKD', 1, 1, 93), -('HKStock', '00883', '中国海洋石油', 'HKEX', 'HKD', 1, 1, 92), -('HKStock', '01398', '工商银行', 'HKEX', 'HKD', 1, 1, 91) ON CONFLICT (market, symbol) DO NOTHING; -- ============================================================================= @@ -727,7 +705,7 @@ CREATE INDEX IF NOT EXISTS idx_analysis_memory_user ON qd_analysis_memory(user_i DO $$ BEGIN IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_analysis_memory' AND column_name = 'user_id' ) THEN ALTER TABLE qd_analysis_memory ADD COLUMN user_id INT; @@ -746,7 +724,7 @@ END $$; DO $$ BEGIN IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_users' AND column_name = 'token_version' ) THEN ALTER TABLE qd_users ADD COLUMN token_version INTEGER DEFAULT 1; @@ -808,31 +786,31 @@ CREATE INDEX IF NOT EXISTS idx_comments_user ON qd_indicator_comments(user_id); DO $$ BEGIN IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'purchase_count' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN purchase_count INTEGER DEFAULT 0; RAISE NOTICE 'Added purchase_count column to qd_indicator_codes'; END IF; - + IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'avg_rating' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN avg_rating DECIMAL(3,2) DEFAULT 0; RAISE NOTICE 'Added avg_rating column to qd_indicator_codes'; END IF; - + IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'rating_count' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN rating_count INTEGER DEFAULT 0; RAISE NOTICE 'Added rating_count column to qd_indicator_codes'; END IF; - + IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'view_count' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN view_count INTEGER DEFAULT 0; diff --git a/backend_api_python/pyproject.toml b/backend_api_python/pyproject.toml new file mode 100644 index 0000000..cb48b98 --- /dev/null +++ b/backend_api_python/pyproject.toml @@ -0,0 +1,32 @@ +[tool.ruff] +line-length = 120 +target-version = "py312" +extend-exclude = [ + ".git", + ".venv", + "__pycache__", + "migrations", +] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] + +[tool.ruff.format] +indent-style = "space" +line-ending = "lf" +quote-style = "double" + +[tool.vulture] +paths = ["app", "scripts", "run.py", "gunicorn_config.py"] +exclude = ["migrations/", "__pycache__/"] +ignore_decorators = [ + "@*.route", + "@*.get", + "@*.post", + "@*.put", + "@*.patch", + "@*.delete", + "@*.errorhandler", +] +ignore_names = ["test_*"] +min_confidence = 80 diff --git a/backend_api_python/requirements-dev.txt b/backend_api_python/requirements-dev.txt new file mode 100644 index 0000000..6be3349 --- /dev/null +++ b/backend_api_python/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pre-commit \ No newline at end of file diff --git a/backend_api_python/run.py b/backend_api_python/run.py index c69125a..0a07504 100644 --- a/backend_api_python/run.py +++ b/backend_api_python/run.py @@ -1,9 +1,13 @@ """ QuantDinger Python API entrypoint. """ + import os import sys +from app import create_app +from app.config.settings import Config + # Ensure UTF-8 console output on Windows to avoid UnicodeEncodeError in logs. # (PowerShell default encoding may be GBK/CP936.) try: @@ -18,6 +22,7 @@ except Exception: # This keeps local deployment simple: edit one file and run. try: from dotenv import load_dotenv + this_dir = os.path.dirname(os.path.abspath(__file__)) # Primary: backend_api_python/.env (same dir as run.py) load_dotenv(os.path.join(this_dir, ".env"), override=False) @@ -32,6 +37,7 @@ except Exception: # keeping console logs clean in local mode. os.environ.setdefault("TQDM_DISABLE", "1") + # Optional: normalize outbound proxy settings for the whole process. # This makes requests/yfinance/finnhub/tiingo/GoogleSearch etc work behind a local proxy. def _apply_proxy_env(): @@ -45,24 +51,59 @@ def _apply_proxy_env(): os.environ[key] = value # If user provided explicit proxy URL, honor it. - proxy_url = (os.getenv('PROXY_URL') or '').strip() + proxy_url = (os.getenv("PROXY_URL") or "").strip() if not proxy_url: return # Standard env vars used by requests and many libraries. - _set_if_blank('ALL_PROXY', proxy_url) - _set_if_blank('HTTP_PROXY', proxy_url) - _set_if_blank('HTTPS_PROXY', proxy_url) + _set_if_blank("ALL_PROXY", proxy_url) + _set_if_blank("HTTP_PROXY", proxy_url) + _set_if_blank("HTTPS_PROXY", proxy_url) + _apply_proxy_env() +################# + + +# Optional: Disable SSL verification for development in corporate environments (SSL Inspection) +# This is NOT recommended for production use. +def _apply_ssl_verify(): + verify_ssl = os.getenv("PYTHON_SSL_VERIFY", "true").lower() == "true" + if not verify_ssl: + import requests + import urllib3 + from urllib3.exceptions import InsecureRequestWarning + + # Suppress InsecureRequestWarning + urllib3.disable_warnings(InsecureRequestWarning) + + # Monkey patch requests.Session.request to default verify=False + # This affects requests, yfinance, and many other libraries. + original_request = requests.Session.request + + def patched_request(self, method, url, *args, **kwargs): + if "verify" not in kwargs: + kwargs["verify"] = False + return original_request(self, method, url, *args, **kwargs) + + requests.Session.request = patched_request + + # Also set common environment variables to skip verification + os.environ["CURL_CA_BUNDLE"] = "" + os.environ["REQUESTS_CA_BUNDLE"] = "" + os.environ["PYTHONHTTPSVERIFY"] = "0" + + print("[DEV ONLY] SSL Verification disabled globally (Corporate SSL Inspection bypass)") + + +_apply_ssl_verify() + +################## # Add project root to Python path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from app import create_app -from app.config.settings import Config - # Create app instance (for gunicorn use) # gunicorn -c gunicorn_config.py "run:app" app = create_app() @@ -72,7 +113,7 @@ def main(): """Start application""" # Keep startup messages ASCII-only and short. print("QuantDinger Python API v2.2.2") - + # ========== Critical Security Check for SECRET_KEY ========== # In production (DEBUG=False), the SECRET_KEY MUST NOT use the default example value. # This prevents attackers from forging JWT tokens with admin privileges. @@ -90,17 +131,12 @@ def main(): # Print to both stdout and raise to stop the server print(msg) raise RuntimeError("Insecure SECRET_KEY configuration: using default example value in non-debug mode") - + print(f"Service starting at: http://{Config.HOST}:{Config.PORT}") - + # Flask dev server is for local development only. - app.run( - host=Config.HOST, - port=Config.PORT, - debug=Config.DEBUG, - threaded=True - ) + app.run(host=Config.HOST, port=Config.PORT, debug=Config.DEBUG, threaded=True) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/backend_api_python/scripts/backfill_zero_trades.py b/backend_api_python/scripts/backfill_zero_trades.py index 9c073a5..a9704ac 100644 --- a/backend_api_python/scripts/backfill_zero_trades.py +++ b/backend_api_python/scripts/backfill_zero_trades.py @@ -19,9 +19,8 @@ Notes: from __future__ import annotations import argparse -import time -from datetime import datetime, timezone -from typing import Any, Dict, Optional, Tuple, List +from datetime import datetime +from typing import Any, Dict, List, Optional from app.utils.db import get_db_connection @@ -96,7 +95,9 @@ def _find_best_order_match( if not cand: return None # If the closest candidates are tied, such as identical executed_at, treat it as ambiguous and skip it - if len(cand) >= 2 and abs(int(cand[0]["executed_at"]) - trade_ts) == abs(int(cand[1]["executed_at"]) - trade_ts): + if len(cand) >= 2 and abs(int(cand[0]["executed_at"]) - trade_ts) == abs( + int(cand[1]["executed_at"]) - trade_ts + ): return None return cand[0] @@ -132,14 +133,22 @@ def main() -> None: ap.add_argument("--until", type=str, required=True, help="YYYY-MM-DD or YYYY/MM/DD") ap.add_argument("--window-sec", type=int, default=600, help="Match window, default ±600 seconds") ap.add_argument("--limit", type=int, default=500, help="Maximum number of trades to process") - ap.add_argument("--apply", action="store_true", help="Actually write to the database; default is dry-run output only") + ap.add_argument( + "--apply", action="store_true", help="Actually write to the database; default is dry-run output only" + ) args = ap.parse_args() since_ts = _parse_date_to_ts(args.since) - until_ts = _parse_date_to_ts(args.until) + 24 * 3600 - 1 if len(args.until.strip()) <= 10 else _parse_date_to_ts(args.until) + until_ts = ( + _parse_date_to_ts(args.until) + 24 * 3600 - 1 + if len(args.until.strip()) <= 10 + else _parse_date_to_ts(args.until) + ) trades = _fetch_bad_trades(args.strategy_id, since_ts, until_ts, args.limit) - print(f"[scan] bad_trades={len(trades)} strategy_id={args.strategy_id} since={since_ts} until={until_ts} apply={args.apply}") + print( + f"[scan] bad_trades={len(trades)} strategy_id={args.strategy_id} since={since_ts} until={until_ts} apply={args.apply}" + ) fixed = 0 skipped = 0 @@ -173,5 +182,3 @@ def main() -> None: if __name__ == "__main__": main() - - diff --git a/backend_api_python/scripts/run_calibration.py b/backend_api_python/scripts/run_calibration.py index 6cb523e..22f6838 100644 --- a/backend_api_python/scripts/run_calibration.py +++ b/backend_api_python/scripts/run_calibration.py @@ -7,10 +7,11 @@ Usage: AI_CALIBRATION_MARKET=Crypto python scripts/run_calibration.py AI_CALIBRATION_MARKETS=Crypto,USStock python scripts/run_calibration.py """ -import sys -import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from app.services.ai_calibration import AICalibrationService diff --git a/backend_api_python/scripts/run_reflection_task.py b/backend_api_python/scripts/run_reflection_task.py index dce539e..c965d3d 100644 --- a/backend_api_python/scripts/run_reflection_task.py +++ b/backend_api_python/scripts/run_reflection_task.py @@ -1,18 +1,20 @@ -import sys import os +import sys + from dotenv import load_dotenv # Add the backend directory to the Python path so app.* can be imported # The app package lives under backend_api_python/app while this script is under backend_api_python/scripts -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from app.services.reflection import ReflectionService + def main(): # Load backend envs for DATABASE_URL, reflection switches, etc. # This script may be run locally, so we must load .env explicitly. - backend_env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '.env')) - root_env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env')) + backend_env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env")) + root_env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".env")) if os.path.exists(root_env_path): load_dotenv(root_env_path, override=False) if os.path.exists(backend_env_path): @@ -28,6 +30,6 @@ def main(): print("Reflection stats:", stats) print("Task Completed.") + if __name__ == "__main__": main() - diff --git a/backend_api_python/scripts/simulate_trading_executor.py b/backend_api_python/scripts/simulate_trading_executor.py index 8dd3f3c..cd51d62 100644 --- a/backend_api_python/scripts/simulate_trading_executor.py +++ b/backend_api_python/scripts/simulate_trading_executor.py @@ -19,7 +19,7 @@ import os import sys import time from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List def _ensure_backend_on_syspath() -> None: @@ -70,17 +70,18 @@ def _make_klines_1m(base: float = 3000.0, n: int = 200) -> List[Dict[str, Any]]: else: price *= 1.008 # +0.8% per bar - o = price * 0.999 - c = price - h = max(o, c) * 1.0005 - l = min(o, c) * 0.9995 + open_price = price * 0.999 + close_price = price + high_price = max(open_price, close_price) * 1.0005 + low_price = min(open_price, close_price) * 0.9995 + klines.append( { "time": int(ts), - "open": float(o), - "high": float(h), - "low": float(l), - "close": float(c), + "open": float(open_price), + "high": float(high_price), + "low": float(low_price), + "close": float(close_price), "volume": 1.0, } ) @@ -179,20 +180,22 @@ def _find_klines_with_signal(ex: TradingExecutor, indicator_code: str) -> List[D price *= float(down_mult) else: price *= float(up_mult) - o = price * 0.999 - c = price - h = max(o, c) * 1.0005 - l = min(o, c) * 0.9995 - row["open"] = float(o) - row["high"] = float(h) - row["low"] = float(l) - row["close"] = float(c) + open_price = price * 0.999 + close_price = price + high_price = max(open_price, close_price) * 1.0005 + low_price = min(open_price, close_price) * 0.9995 + row["open"] = float(open_price) + row["high"] = float(high_price) + row["low"] = float(low_price) + row["close"] = float(close_price) cnt = _count_signals_for_klines(ex, indicator_code, kl, "both", 5, 1000.0) if cnt["buy"] > 0 or cnt["sell"] > 0: kl2 = _trim_klines_to_last_signal(ex, indicator_code, kl, keep_before=220, keep_after=0) cnt2 = _count_signals_for_klines(ex, indicator_code, kl2, "both", 5, 1000.0) - print(f"[OK] Found signals with pattern down={down_mult}, up={up_mult}, split={split}: {cnt} -> trimmed={cnt2}, bars={len(kl2)}") + print( + f"[OK] Found signals with pattern down={down_mult}, up={up_mult}, split={split}: {cnt} -> trimmed={cnt2}, bars={len(kl2)}" + ) return kl2 print(f"[MISS] Pattern down={down_mult}, up={up_mult}, split={split}: {cnt}") @@ -390,5 +393,3 @@ def main() -> None: if __name__ == "__main__": main() - - diff --git a/docker-compose.yml b/docker-compose.yml index 663777f..7490267 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,25 +84,25 @@ services: # ======================== # Frontend (Nginx serving prebuilt dist) # ======================== - frontend: - build: - context: . - dockerfile: frontend/Dockerfile - args: - RUNTIME_IMAGE: ${IMAGE_PREFIX:-}nginx:1.25-alpine - container_name: quantdinger-frontend - restart: unless-stopped - ports: - - "${FRONTEND_PORT:-8888}:80" - depends_on: - - backend - networks: - - quantdinger-network - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost/health"] - interval: 30s - timeout: 10s - retries: 3 + # frontend: + # build: + # context: . + # dockerfile: frontend/Dockerfile + # args: + # RUNTIME_IMAGE: ${IMAGE_PREFIX:-}nginx:1.25-alpine + # container_name: quantdinger-frontend + # restart: unless-stopped + # ports: + # - "${FRONTEND_PORT:-8888}:80" + # depends_on: + # - backend + # networks: + # - quantdinger-network + # healthcheck: + # test: ["CMD", "curl", "-f", "http://localhost/health"] + # interval: 30s + # timeout: 10s + # retries: 3 volumes: postgres_data: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9ad8fcf..8e43cf5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,46 +4,6 @@ This document records version updates, new features, bug fixes, and database mig --- -## 2026-04-07 — 数据库:`qd_market_symbols` 补充 A股 / H股热门标的 - -已在 **Docker** 内对运行中的 PostgreSQL 执行完毕(`INSERT 0 20`)。**新库**若使用当前仓库中的 `migrations/init.sql` 初始化,已包含同批种子数据,无需重复执行。 - -**在已有库上手动执行(等价 SQL,可重复执行,`ON CONFLICT DO NOTHING`):** - -```sql -INSERT INTO qd_market_symbols (market, symbol, name, exchange, currency, is_active, is_hot, sort_order) VALUES -('CNStock', '600519', '贵州茅台', 'SSE', 'CNY', 1, 1, 100), -('CNStock', '600036', '招商银行', 'SSE', 'CNY', 1, 1, 99), -('CNStock', '601318', '中国平安', 'SSE', 'CNY', 1, 1, 98), -('CNStock', '600900', '长江电力', 'SSE', 'CNY', 1, 1, 97), -('CNStock', '601899', '紫金矿业', 'SSE', 'CNY', 1, 1, 96), -('CNStock', '000858', '五粮液', 'SZSE', 'CNY', 1, 1, 95), -('CNStock', '000333', '美的集团', 'SZSE', 'CNY', 1, 1, 94), -('CNStock', '002594', '比亚迪', 'SZSE', 'CNY', 1, 1, 93), -('CNStock', '300750', '宁德时代', 'SZSE', 'CNY', 1, 1, 92), -('CNStock', '000001', '平安银行', 'SZSE', 'CNY', 1, 1, 91), -('HKStock', '00700', '腾讯控股', 'HKEX', 'HKD', 1, 1, 100), -('HKStock', '09988', '阿里巴巴-W', 'HKEX', 'HKD', 1, 1, 99), -('HKStock', '03690', '美团-W', 'HKEX', 'HKD', 1, 1, 98), -('HKStock', '01810', '小米集团-W', 'HKEX', 'HKD', 1, 1, 97), -('HKStock', '00939', '建设银行', 'HKEX', 'HKD', 1, 1, 96), -('HKStock', '01299', '友邦保险', 'HKEX', 'HKD', 1, 1, 95), -('HKStock', '02318', '中国平安', 'HKEX', 'HKD', 1, 1, 94), -('HKStock', '00388', '香港交易所', 'HKEX', 'HKD', 1, 1, 93), -('HKStock', '00883', '中国海洋石油', 'HKEX', 'HKD', 1, 1, 92), -('HKStock', '01398', '工商银行', 'HKEX', 'HKD', 1, 1, 91) -ON CONFLICT (market, symbol) DO NOTHING; -``` - -**Docker 一行示例(文件需 UTF-8):** - -```bash -docker cp backend_api_python/migrations/.sql quantdinger-db:/tmp/migrate.sql -docker compose exec -T postgres psql -U quantdinger -d quantdinger -f /tmp/migrate.sql -``` - ---- - ## V3.0.1 (2026-04-05) — Frontend / docs - **Front-end version**: `QuantDinger-Vue-src/package.json`, footer display and `frontend/VERSION` are unified to **3.0.1**. @@ -228,7 +188,7 @@ CREATE TABLE IF NOT EXISTS qd_polymarket_markets ( DO $$ BEGIN IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_polymarket_markets' AND column_name = 'slug' ) THEN ALTER TABLE qd_polymarket_markets ADD COLUMN slug VARCHAR(255); @@ -568,14 +528,14 @@ See `docs/CROSS_SECTIONAL_STRATEGY_GUIDE_CN.md` or `docs/CROSS_SECTIONAL_STRATEG -- are stored in the trading_config JSON field, not as separate database columns. -- This migration only adds the last_rebalance_at timestamp field which is needed for rebalancing logic. -DO $$ +DO $$ BEGIN IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'qd_strategies_trading' + SELECT 1 FROM information_schema.columns + WHERE table_name = 'qd_strategies_trading' AND column_name = 'last_rebalance_at' ) THEN - ALTER TABLE qd_strategies_trading + ALTER TABLE qd_strategies_trading ADD COLUMN last_rebalance_at TIMESTAMP; RAISE NOTICE 'Added last_rebalance_at column to qd_strategies_trading'; ELSE @@ -761,7 +721,7 @@ CREATE TABLE IF NOT EXISTS qd_analysis_memory ( DO $$ BEGIN IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_analysis_memory' AND column_name = 'raw_result' ) THEN ALTER TABLE qd_analysis_memory ADD COLUMN raw_result JSONB; @@ -772,7 +732,7 @@ END $$; DO $$ BEGIN IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_analysis_memory' AND column_name = 'user_id' ) THEN ALTER TABLE qd_analysis_memory ADD COLUMN user_id INT; @@ -820,64 +780,64 @@ DO $$ BEGIN -- Purchase count IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'purchase_count' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN purchase_count INTEGER DEFAULT 0; END IF; - + -- Average rating IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'avg_rating' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN avg_rating DECIMAL(3,2) DEFAULT 0; END IF; - + -- Rating count IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'rating_count' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN rating_count INTEGER DEFAULT 0; END IF; - + -- View count IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'view_count' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN view_count INTEGER DEFAULT 0; END IF; - + -- Review status IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'review_status' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN review_status VARCHAR(20) DEFAULT 'approved'; UPDATE qd_indicator_codes SET review_status = 'approved' WHERE publish_to_community = 1; END IF; - + -- Review note IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'review_note' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN review_note TEXT DEFAULT ''; END IF; - + -- Reviewed at IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'reviewed_at' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN reviewed_at TIMESTAMP; END IF; - + -- Reviewed by IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_indicator_codes' AND column_name = 'reviewed_by' ) THEN ALTER TABLE qd_indicator_codes ADD COLUMN reviewed_by INTEGER; @@ -891,15 +851,15 @@ DO $$ BEGIN -- Token version (for single-client login) IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + SELECT 1 FROM information_schema.columns WHERE table_name = 'qd_users' AND column_name = 'token_version' ) THEN ALTER TABLE qd_users ADD COLUMN token_version INTEGER DEFAULT 1; END IF; - + -- Notification settings IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns + 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 '{}'; diff --git a/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_CN.md b/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_CN.md index dc7acfc..d5569b3 100644 --- a/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_CN.md +++ b/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_CN.md @@ -34,24 +34,24 @@ ### 参数说明 -- **cs_strategy_type**: +- **cs_strategy_type**: - `'single'`: 单标的策略(默认,原有功能) - `'cross_sectional'`: 截面策略 -- **symbol_list**: +- **symbol_list**: - 标的列表,格式为 `["Market:SYMBOL", ...]` - 例如:`["Crypto:BTC/USDT", "Crypto:ETH/USDT"]` -- **portfolio_size**: +- **portfolio_size**: - 持仓组合大小,即同时持有的标的数量 - 例如:10 表示同时持有10个标的 -- **long_ratio**: +- **long_ratio**: - 做多比例,0-1之间的浮点数 - 例如:0.5 表示50%做多,50%做空 - 例如:1.0 表示100%做多(不做空) -- **rebalance_frequency**: +- **rebalance_frequency**: - 调仓频率 - `'daily'`: 每日调仓 - `'weekly'`: 每周调仓 @@ -74,7 +74,7 @@ for symbol, df in data.items(): # 计算每个标的的因子值 # 例如:动量因子 momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100 - + # 例如:RSI指标 def calculate_rsi(prices, period=14): delta = prices.diff() @@ -83,9 +83,9 @@ for symbol, df in data.items(): rs = gain / loss rsi = 100 - (100 / (1 + rs)) return rsi.iloc[-1] - + rsi = calculate_rsi(df['close'], 14) - + # 综合评分(可以根据需要调整权重) score = momentum * 0.6 + (100 - rsi) * 0.4 scores[symbol] = score @@ -175,7 +175,7 @@ scores = {} for symbol, df in data.items(): # 20周期动量 momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100 - + # RSI delta = df['close'].diff() gain = (delta.where(delta > 0, 0)).rolling(window=14).mean() @@ -183,7 +183,7 @@ for symbol, df in data.items(): rs = gain / loss rsi = 100 - (100 / (1 + rs)) rsi_value = rsi.iloc[-1] - + # 综合评分 score = momentum * 0.7 + (100 - rsi_value) * 0.3 scores[symbol] = score diff --git a/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_EN.md b/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_EN.md index e735cfc..7a89c68 100644 --- a/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_EN.md +++ b/docs/CROSS_SECTIONAL_STRATEGY_GUIDE_EN.md @@ -34,24 +34,24 @@ When creating or editing a strategy, add the following parameters to `trading_co ### Parameter Description -- **cs_strategy_type**: +- **cs_strategy_type**: - `'single'`: Single-symbol strategy (default, original functionality) - `'cross_sectional'`: Cross-sectional strategy -- **symbol_list**: +- **symbol_list**: - List of symbols, format: `["Market:SYMBOL", ...]` - Example: `["Crypto:BTC/USDT", "Crypto:ETH/USDT"]` -- **portfolio_size**: +- **portfolio_size**: - Portfolio size, i.e., the number of symbols to hold simultaneously - Example: 10 means holding 10 symbols at the same time -- **long_ratio**: +- **long_ratio**: - Long ratio, a float between 0 and 1 - Example: 0.5 means 50% long, 50% short - Example: 1.0 means 100% long (no short positions) -- **rebalance_frequency**: +- **rebalance_frequency**: - Rebalancing frequency - `'daily'`: Daily rebalancing - `'weekly'`: Weekly rebalancing @@ -74,7 +74,7 @@ for symbol, df in data.items(): # Calculate factor values for each symbol # Example: Momentum factor momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100 - + # Example: RSI indicator def calculate_rsi(prices, period=14): delta = prices.diff() @@ -83,9 +83,9 @@ for symbol, df in data.items(): rs = gain / loss rsi = 100 - (100 / (1 + rs)) return rsi.iloc[-1] - + rsi = calculate_rsi(df['close'], 14) - + # Composite score (adjust weights as needed) score = momentum * 0.6 + (100 - rsi) * 0.4 scores[symbol] = score @@ -175,7 +175,7 @@ scores = {} for symbol, df in data.items(): # 20-period momentum momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100 - + # RSI delta = df['close'].diff() gain = (delta.where(delta > 0, 0)).rolling(window=14).mean() @@ -183,7 +183,7 @@ for symbol, df in data.items(): rs = gain / loss rsi = 100 - (100 / (1 + rs)) rsi_value = rsi.iloc[-1] - + # Composite score score = momentum * 0.7 + (100 - rsi_value) * 0.3 scores[symbol] = score diff --git a/docs/README_CN.md b/docs/README_CN.md index 24ca26f..4264c8b 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -91,7 +91,7 @@ docker-compose up -d --build > - 启动: > `docker-compose up -d --build` -> **Windows PowerShell**: +> **Windows PowerShell**: > - 复制后端配置: > `Copy-Item backend_api_python\env.example -Destination backend_api_python\.env` > - 如果需要更多高级配置,直接查看这个文件下半部分的 “Advanced / rarely changed”: diff --git a/docs/STRATEGY_DEV_GUIDE.md b/docs/STRATEGY_DEV_GUIDE.md index 11381b6..9a8b9e1 100644 --- a/docs/STRATEGY_DEV_GUIDE.md +++ b/docs/STRATEGY_DEV_GUIDE.md @@ -98,13 +98,13 @@ For charting, you often want to place the signal icon slightly above or below th ```python # Place Buy marker 0.5% below the Low buy_marks = [ - df['low'].iloc[i] * 0.995 if df['buy'].iloc[i] else None + df['low'].iloc[i] * 0.995 if df['buy'].iloc[i] else None for i in range(len(df)) ] # Place Sell marker 0.5% above the High sell_marks = [ - df['high'].iloc[i] * 1.005 if df['sell'].iloc[i] else None + df['high'].iloc[i] * 1.005 if df['sell'].iloc[i] else None for i in range(len(df)) ] ``` @@ -166,12 +166,12 @@ df['sell'] = sell # ----------------------- # Calculate marker positions buy_marks = [ - df['low'].iloc[i] * 0.995 if buy.iloc[i] else None + df['low'].iloc[i] * 0.995 if buy.iloc[i] else None for i in range(len(df)) ] sell_marks = [ - df['high'].iloc[i] * 1.005 if sell.iloc[i] else None + df['high'].iloc[i] * 1.005 if sell.iloc[i] else None for i in range(len(df)) ] diff --git a/docs/STRATEGY_DEV_GUIDE_CN.md b/docs/STRATEGY_DEV_GUIDE_CN.md index d874ea8..6931528 100644 --- a/docs/STRATEGY_DEV_GUIDE_CN.md +++ b/docs/STRATEGY_DEV_GUIDE_CN.md @@ -98,13 +98,13 @@ df['sell'] = condition_sell.fillna(False) ```python # 将买入标记放在最低价下方 0.5% 处 buy_marks = [ - df['low'].iloc[i] * 0.995 if df['buy'].iloc[i] else None + df['low'].iloc[i] * 0.995 if df['buy'].iloc[i] else None for i in range(len(df)) ] # 将卖出标记放在最高价上方 0.5% 处 sell_marks = [ - df['high'].iloc[i] * 1.005 if df['sell'].iloc[i] else None + df['high'].iloc[i] * 1.005 if df['sell'].iloc[i] else None for i in range(len(df)) ] ``` @@ -166,12 +166,12 @@ df['sell'] = sell # ----------------------- # 计算标记位置 buy_marks = [ - df['low'].iloc[i] * 0.995 if buy.iloc[i] else None + df['low'].iloc[i] * 0.995 if buy.iloc[i] else None for i in range(len(df)) ] sell_marks = [ - df['high'].iloc[i] * 1.005 if sell.iloc[i] else None + df['high'].iloc[i] * 1.005 if sell.iloc[i] else None for i in range(len(df)) ] diff --git a/docs/STRATEGY_DEV_GUIDE_JA.md b/docs/STRATEGY_DEV_GUIDE_JA.md index 9a60064..5d2cd14 100644 --- a/docs/STRATEGY_DEV_GUIDE_JA.md +++ b/docs/STRATEGY_DEV_GUIDE_JA.md @@ -98,13 +98,13 @@ df['sell'] = condition_sell.fillna(False) ```python # 買いマーカーを安値の 0.5% 下に配置 buy_marks = [ - df['low'].iloc[i] * 0.995 if df['buy'].iloc[i] else None + df['low'].iloc[i] * 0.995 if df['buy'].iloc[i] else None for i in range(len(df)) ] # 売りマーカーを高値の 0.5% 上に配置 sell_marks = [ - df['high'].iloc[i] * 1.005 if df['sell'].iloc[i] else None + df['high'].iloc[i] * 1.005 if df['sell'].iloc[i] else None for i in range(len(df)) ] ``` @@ -166,12 +166,12 @@ df['sell'] = sell # ----------------------- # マーカー位置を計算 buy_marks = [ - df['low'].iloc[i] * 0.995 if buy.iloc[i] else None + df['low'].iloc[i] * 0.995 if buy.iloc[i] else None for i in range(len(df)) ] sell_marks = [ - df['high'].iloc[i] * 1.005 if sell.iloc[i] else None + df['high'].iloc[i] * 1.005 if sell.iloc[i] else None for i in range(len(df)) ] diff --git a/docs/STRATEGY_DEV_GUIDE_KO.md b/docs/STRATEGY_DEV_GUIDE_KO.md index d0ab614..994ed15 100644 --- a/docs/STRATEGY_DEV_GUIDE_KO.md +++ b/docs/STRATEGY_DEV_GUIDE_KO.md @@ -98,13 +98,13 @@ df['sell'] = condition_sell.fillna(False) ```python # 매수 마커를 저가보다 0.5% 아래에 배치 buy_marks = [ - df['low'].iloc[i] * 0.995 if df['buy'].iloc[i] else None + df['low'].iloc[i] * 0.995 if df['buy'].iloc[i] else None for i in range(len(df)) ] # 매도 마커를 고가보다 0.5% 위에 배치 sell_marks = [ - df['high'].iloc[i] * 1.005 if df['sell'].iloc[i] else None + df['high'].iloc[i] * 1.005 if df['sell'].iloc[i] else None for i in range(len(df)) ] ``` @@ -166,12 +166,12 @@ df['sell'] = sell # ----------------------- # 마커 위치 계산 buy_marks = [ - df['low'].iloc[i] * 0.995 if buy.iloc[i] else None + df['low'].iloc[i] * 0.995 if buy.iloc[i] else None for i in range(len(df)) ] sell_marks = [ - df['high'].iloc[i] * 1.005 if sell.iloc[i] else None + df['high'].iloc[i] * 1.005 if sell.iloc[i] else None for i in range(len(df)) ] diff --git a/docs/STRATEGY_DEV_GUIDE_TW.md b/docs/STRATEGY_DEV_GUIDE_TW.md index 6669763..e51e3b7 100644 --- a/docs/STRATEGY_DEV_GUIDE_TW.md +++ b/docs/STRATEGY_DEV_GUIDE_TW.md @@ -98,13 +98,13 @@ df['sell'] = condition_sell.fillna(False) ```python # 將買入標記放在最低價下方 0.5% 處 buy_marks = [ - df['low'].iloc[i] * 0.995 if df['buy'].iloc[i] else None + df['low'].iloc[i] * 0.995 if df['buy'].iloc[i] else None for i in range(len(df)) ] # 將賣出標記放在最高價上方 0.5% 處 sell_marks = [ - df['high'].iloc[i] * 1.005 if df['sell'].iloc[i] else None + df['high'].iloc[i] * 1.005 if df['sell'].iloc[i] else None for i in range(len(df)) ] ``` @@ -166,12 +166,12 @@ df['sell'] = sell # ----------------------- # 計算標記位置 buy_marks = [ - df['low'].iloc[i] * 0.995 if buy.iloc[i] else None + df['low'].iloc[i] * 0.995 if buy.iloc[i] else None for i in range(len(df)) ] sell_marks = [ - df['high'].iloc[i] * 1.005 if sell.iloc[i] else None + df['high'].iloc[i] * 1.005 if sell.iloc[i] else None for i in range(len(df)) ] diff --git a/docs/examples/cross_sectional_momentum_rsi.py b/docs/examples/cross_sectional_momentum_rsi.py index 30af6bf..30d6a40 100644 --- a/docs/examples/cross_sectional_momentum_rsi.py +++ b/docs/examples/cross_sectional_momentum_rsi.py @@ -3,17 +3,17 @@ # Cross-Sectional Strategy Indicator Example # Momentum + RSI Composite Score # ============================================================ -# +# # 使用方法: # 1. 在交易助手中创建截面策略 # 2. 选择此指标作为策略指标 # 3. 配置标的列表、持仓大小、做多比例等参数 -# +# # 评分逻辑: # - 动量因子 (20周期): 价格变化率,越高越好 # - RSI指标 (14周期): 反转RSI值,越低越好(100 - RSI) # - 综合评分: 70% 动量 + 30% RSI反转值 -# +# # ============================================================ # 截面策略指标 @@ -28,11 +28,11 @@ for symbol, df in data.items(): if len(df) < 20: scores[symbol] = 0 continue - + # === 1. 计算动量因子 (20周期) === # 动量 = (当前价格 / 20周期前价格 - 1) * 100 momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100 - + # === 2. 计算RSI指标 (14周期) === def calculate_rsi(prices, period=14): """计算RSI指标""" @@ -42,18 +42,18 @@ for symbol, df in data.items(): rs = gain / loss rsi = 100 - (100 / (1 + rs)) return rsi.iloc[-1] - + rsi_value = calculate_rsi(df['close'], 14) - + # === 3. 综合评分 === # 动量越高 = 评分越高 # RSI越低(超卖)= 评分越高(100 - RSI) # 权重: 70% 动量 + 30% RSI反转值 momentum_score = momentum rsi_score = 100 - rsi_value # 反转RSI(RSI越低,评分越高) - + composite_score = momentum_score * 0.7 + rsi_score * 0.3 - + scores[symbol] = composite_score # === 可选: 手动指定排序 === diff --git a/docs/examples/dual_ma_with_params.py b/docs/examples/dual_ma_with_params.py index 69bc9ea..94f5ccd 100644 --- a/docs/examples/dual_ma_with_params.py +++ b/docs/examples/dual_ma_with_params.py @@ -2,13 +2,13 @@ # 双均线策略 (支持外部参数配置) # Dual Moving Average Strategy with External Parameters # ============================================================ -# +# # 使用方法: # 1. 在交易助手中选择此指标 # 2. 根据不同币种配置不同参数 # - BTC/USDT: sma_short=5, sma_long=10 # - ETH/USDT: sma_short=5, sma_long=20 -# +# # ============================================================ # === 参数声明 (会在前端表单中显示) === diff --git a/docs/examples/multi_indicator_composite.py b/docs/examples/multi_indicator_composite.py index 2daa538..2e30e42 100644 --- a/docs/examples/multi_indicator_composite.py +++ b/docs/examples/multi_indicator_composite.py @@ -2,12 +2,12 @@ # 多指标组合策略 (均线+RSI+MACD) # Multi-Indicator Composite Strategy # ============================================================ -# +# # 使用方法: # 1. 可配置均线周期、RSI阈值等参数 # 2. 买入条件: RSI超卖 + MACD金叉 + 成交量放大 # 3. 卖出条件: RSI超买 或 MACD死叉 -# +# # ============================================================ # === 参数声明 === @@ -78,7 +78,7 @@ buy = ma_golden | rsi_buy # 均线金叉 或 RSI超卖 if use_macd: buy = buy & (macd > macd_signal) # 需要MACD向上 - + if use_volume: buy = buy & volume_up # 需要成交量放大 diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 6fd0702..35e7fd7 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -9,14 +9,14 @@ position: relative; overflow: hidden; } - + .first-loading-wrp > h2 { font-size: 32px; margin-bottom: 40px; color: #333; font-weight: 600; } - + .first-loading-wrp .loading-wrp { padding: 40px; display: flex; @@ -27,7 +27,7 @@ width: 100%; max-width: 600px; } - + /* 像素风格小猫奔跑动画容器 */ .pixel-cat-container { position: relative; @@ -36,7 +36,7 @@ margin: 0 auto 30px; overflow: hidden; } - + /* 像素小猫主体 */ .pixel-cat { position: absolute; @@ -49,14 +49,14 @@ image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; } - + /* 所有像素元素都使用锐利边缘 */ .pixel-cat * { image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; } - + /* 猫头 - 像素方块组成 */ .cat-head { position: absolute; @@ -64,7 +64,7 @@ top: 0; width: 16px; height: 14px; - background: + background: /* 头部主体白色 */ linear-gradient(#fff, #fff) 2px 4px / 12px 10px no-repeat, /* 左半脸黑色斑块 */ @@ -73,7 +73,7 @@ linear-gradient(#fff, #fff) 4px 2px / 8px 2px no-repeat; animation: headBob 0.4s steps(2) infinite; } - + /* 左耳 - 黑色尖耳朵 */ .cat-ear-left { position: absolute; @@ -81,12 +81,12 @@ top: -2px; width: 4px; height: 6px; - background: + background: linear-gradient(#000, #000) 0px 4px / 4px 2px no-repeat, linear-gradient(#000, #000) 1px 2px / 2px 2px no-repeat, linear-gradient(#000, #000) 1px 0px / 2px 2px no-repeat; } - + /* 右耳 - 白色尖耳朵 */ .cat-ear-right { position: absolute; @@ -94,13 +94,13 @@ top: -2px; width: 4px; height: 6px; - background: + background: linear-gradient(#fff, #fff) 0px 4px / 4px 2px no-repeat, linear-gradient(#fff, #fff) 1px 2px / 2px 2px no-repeat, linear-gradient(#fff, #fff) 1px 0px / 2px 2px no-repeat; box-shadow: inset 0 0 0 1px #000; } - + .cat-ear-right::after { content: ''; position: absolute; @@ -110,7 +110,7 @@ border-width: 0 1px 0 0; box-sizing: border-box; } - + /* 左眼 - 黑底白眼(在黑色区域) */ .cat-eye-left { position: absolute; @@ -120,7 +120,7 @@ height: 4px; background: #fff; } - + .cat-eye-left::after { content: ''; position: absolute; @@ -130,7 +130,7 @@ height: 2px; background: #000; } - + /* 右眼 - 白底黑眼(在白色区域) */ .cat-eye-right { position: absolute; @@ -142,7 +142,7 @@ border: 1px solid #000; box-sizing: border-box; } - + .cat-eye-right::after { content: ''; position: absolute; @@ -152,7 +152,7 @@ height: 2px; background: #000; } - + /* 鼻子 - 小粉色方块 */ .cat-nose { position: absolute; @@ -162,7 +162,7 @@ height: 2px; background: #000; } - + /* 胡须 - 像素线条 */ .cat-whiskers { position: absolute; @@ -171,7 +171,7 @@ width: 16px; height: 4px; } - + .cat-whiskers::before { content: ''; position: absolute; @@ -182,7 +182,7 @@ background: #000; box-shadow: 0 2px 0 #000; } - + .cat-whiskers::after { content: ''; position: absolute; @@ -193,7 +193,7 @@ background: #000; box-shadow: 0 2px 0 #000; } - + /* 身体 - 黑白相间像素块 */ .cat-body { position: absolute; @@ -201,7 +201,7 @@ top: 14px; width: 14px; height: 10px; - background: + background: /* 白色部分 */ linear-gradient(#fff, #fff) 6px 0 / 8px 10px no-repeat, /* 黑色部分 */ @@ -209,7 +209,7 @@ border: 1px solid #000; box-sizing: border-box; } - + /* 前腿 - 左 (黑色) */ .cat-leg-front-left { position: absolute; @@ -220,7 +220,7 @@ background: #000; animation: legFront 0.2s steps(2) infinite; } - + /* 前腿 - 右 (白色带边框) */ .cat-leg-front-right { position: absolute; @@ -233,7 +233,7 @@ box-sizing: border-box; animation: legFront 0.2s steps(2) infinite 0.1s; } - + /* 后腿 - 左 (白色带边框) */ .cat-leg-back-left { position: absolute; @@ -246,7 +246,7 @@ box-sizing: border-box; animation: legBack 0.2s steps(2) infinite 0.1s; } - + /* 后腿 - 右 (黑色) */ .cat-leg-back-right { position: absolute; @@ -257,7 +257,7 @@ background: #000; animation: legBack 0.2s steps(2) infinite; } - + /* 尾巴 - 长且弯曲的像素尾巴 */ .cat-tail { position: absolute; @@ -265,7 +265,7 @@ top: 10px; width: 12px; height: 10px; - background: + background: /* 尾巴根部 */ linear-gradient(#000, #000) 0 6px / 3px 3px no-repeat, /* 尾巴中部 */ @@ -277,43 +277,43 @@ animation: tailWag 0.3s steps(2) infinite alternate; transform-origin: left bottom; } - + /* 奔跑动画 - 轻微上下跳动 */ @keyframes catRun { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } } - + /* 移动动画 - 左右移动 */ @keyframes catMove { 0% { left: -40px; } 100% { left: calc(100% + 40px); } } - + /* 前腿动画 */ @keyframes legFront { 0%, 100% { transform: rotate(-15deg); } 50% { transform: rotate(15deg); } } - + /* 后腿动画 */ @keyframes legBack { 0%, 100% { transform: rotate(15deg); } 50% { transform: rotate(-15deg); } } - + /* 尾巴摆动 */ @keyframes tailWag { 0% { transform: rotate(-10deg); } 100% { transform: rotate(10deg); } } - + /* 头部轻微摆动 */ @keyframes headBob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-1px); } } - + /* 地面效果 - 像素风格 */ .ground { position: absolute; @@ -331,12 +331,12 @@ animation: groundMove 0.3s linear infinite; image-rendering: pixelated; } - + @keyframes groundMove { 0% { background-position: 0 0; } 100% { background-position: 12px 0; } } - + /* 品牌文字 */ .brand-text { display: flex; @@ -348,21 +348,21 @@ margin-top: 20px; letter-spacing: 2px; } - + /* 暗色主题适配 */ @media (prefers-color-scheme: dark) { .first-loading-wrp { background: #141414; } - + .first-loading-wrp > h2 { color: #fff; } - + .brand-text { color: #fff; } - + .ground { background: repeating-linear-gradient( 90deg, @@ -372,32 +372,32 @@ #333 12px ); } - + /* 暗色模式下白色部分改成浅灰 */ .cat-head { - background: + background: linear-gradient(#ddd, #ddd) 2px 4px / 12px 10px no-repeat, linear-gradient(#000, #000) 2px 4px / 6px 10px no-repeat, linear-gradient(#ddd, #ddd) 4px 2px / 8px 2px no-repeat; } - + .cat-body { - background: + background: linear-gradient(#ddd, #ddd) 6px 0 / 8px 10px no-repeat, linear-gradient(#000, #000) 0 0 / 8px 10px no-repeat; } - + .cat-leg-front-right, .cat-leg-back-left { background: #ddd; } - + .cat-eye-left, .cat-eye-right { background: #ddd; } } - + /* 确保像素风格在所有浏览器中正确显示 */ .pixel-cat, .pixel-cat * { @@ -406,18 +406,18 @@ image-rendering: pixelated; image-rendering: crisp-edges; } - + /* 手机端适配 */ @media (max-width: 768px) { .pixel-cat-container { transform: scale(1.5); } - + .first-loading-wrp > h2 { font-size: 24px; margin-bottom: 30px; } - + .brand-text { font-size: 20px; } diff --git a/frontend/dist/js/chunk-vendors-legacy.ad9b5cc2.js b/frontend/dist/js/chunk-vendors-legacy.ad9b5cc2.js index 1fc72c7..35462bc 100644 --- a/frontend/dist/js/chunk-vendors-legacy.ad9b5cc2.js +++ b/frontend/dist/js/chunk-vendors-legacy.ad9b5cc2.js @@ -283,7 +283,7 @@ object-assign @license MIT */var t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,r=Object.prototype.propertyIsEnumerable;function i(e){if(null===e||void 0===e)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}function o(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de","5"===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;n<10;n++)t["_"+String.fromCharCode(n)]=n;var r=Object.getOwnPropertyNames(t).map(function(e){return t[e]});if("0123456789"!==r.join(""))return!1;var i={};return"abcdefghijklmnopqrst".split("").forEach(function(e){i[e]=e}),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},i)).join("")}catch(o){return!1}}e.exports=o()?Object.assign:function(e,o){for(var a,s,c=i(e),l=1;l=0;--o){var a=this.tryEntries[o],s=a.completion;if("root"===a.tryLoc)return i("end");if(a.tryLoc<=this.prev){var c=r.call(a,"catchLoc"),l=r.call(a,"finallyLoc");if(c&&l){if(this.prev=0;--n){var i=this.tryEntries[n];if(i.tryLoc<=this.prev&&r.call(i,"finallyLoc")&&this.prev=0;--t){var n=this.tryEntries[t];if(n.finallyLoc===e)return this.complete(n.completion,n.afterLoc),z(n),v}},catch:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var n=this.tryEntries[t];if(n.tryLoc===e){var r=n.completion;if("throw"===r.type){var i=r.arg;z(n)}return i}}throw new Error("illegal catch attempt")},delegateYield:function(e,n,r){return this.delegate={iterator:O(e),resultName:n,nextLoc:r},"next"===this.method&&(this.arg=t),v}},e}(e.exports);try{regeneratorRuntime=t}catch(n){"object"===typeof globalThis?globalThis.regeneratorRuntime=t:Function("r","regeneratorRuntime = r")(t)}},43591:function(e,t,n){"use strict";var r=function(){if("undefined"!==typeof Map)return Map;function e(e,t){var n=-1;return e.some(function(e,r){return e[0]===t&&(n=r,!0)}),n}return function(){function t(){this.__entries__=[]}return Object.defineProperty(t.prototype,"size",{get:function(){return this.__entries__.length},enumerable:!0,configurable:!0}),t.prototype.get=function(t){var n=e(this.__entries__,t),r=this.__entries__[n];return r&&r[1]},t.prototype.set=function(t,n){var r=e(this.__entries__,t);~r?this.__entries__[r][1]=n:this.__entries__.push([t,n])},t.prototype.delete=function(t){var n=this.__entries__,r=e(n,t);~r&&n.splice(r,1)},t.prototype.has=function(t){return!!~e(this.__entries__,t)},t.prototype.clear=function(){this.__entries__.splice(0)},t.prototype.forEach=function(e,t){void 0===t&&(t=null);for(var n=0,r=this.__entries__;n0},e.prototype.connect_=function(){i&&!this.connected_&&(document.addEventListener("transitionend",this.onTransitionEnd_),window.addEventListener("resize",this.refresh),d?(this.mutationsObserver_=new MutationObserver(this.refresh),this.mutationsObserver_.observe(document,{attributes:!0,childList:!0,characterData:!0,subtree:!0})):(document.addEventListener("DOMSubtreeModified",this.refresh),this.mutationEventsAdded_=!0),this.connected_=!0)},e.prototype.disconnect_=function(){i&&this.connected_&&(document.removeEventListener("transitionend",this.onTransitionEnd_),window.removeEventListener("resize",this.refresh),this.mutationsObserver_&&this.mutationsObserver_.disconnect(),this.mutationEventsAdded_&&document.removeEventListener("DOMSubtreeModified",this.refresh),this.mutationsObserver_=null,this.mutationEventsAdded_=!1,this.connected_=!1)},e.prototype.onTransitionEnd_=function(e){var t=e.propertyName,n=void 0===t?"":t,r=u.some(function(e){return!!~n.indexOf(e)});r&&this.refresh()},e.getInstance=function(){return this.instance_||(this.instance_=new e),this.instance_},e.instance_=null,e}(),f=function(e,t){for(var n=0,r=Object.keys(t);n0},e}(),z="undefined"!==typeof WeakMap?new WeakMap:new r,T=function(){function e(t){if(!(this instanceof e))throw new TypeError("Cannot call a class as a function.");if(!arguments.length)throw new TypeError("1 argument required, but only 0 present.");var n=h.getInstance(),r=new S(t,n,this);z.set(this,r)}return e}();["observe","unobserve","disconnect"].forEach(function(e){T.prototype[e]=function(){var t;return(t=z.get(this))[e].apply(t,arguments)}});var O=function(){return"undefined"!==typeof o.ResizeObserver?o.ResizeObserver:T}();t.A=O},2833:function(e){e.exports=function(e,t,n,r){var i=n?n.call(r,e,t):void 0;if(void 0!==i)return!!i;if(e===t)return!0;if("object"!==typeof e||!e||"object"!==typeof t||!t)return!1;var o=Object.keys(e),a=Object.keys(t);if(o.length!==a.length)return!1;for(var s=Object.prototype.hasOwnProperty.bind(t),c=0;c=0;n--)if(o(t[n])){var r=t[n].split("="),i=unescape(r[0]),s=unescape(r[1]);e(s,i)}}function l(e,t){e&&(a.cookie=escape(e)+"="+escape(t)+"; expires=Tue, 19 Jan 2038 03:14:07 GMT; path=/")}function u(e){e&&h(e)&&(a.cookie=escape(e)+"=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/")}function d(){c(function(e,t){u(t)})}function h(e){return new RegExp("(?:^|;\\s*)"+escape(e).replace(/[\-\.\+\*]/g,"\\$&")+"\\s*\\=").test(a.cookie)}},99653:function(e,t,n){var r=n(16123),i=r.Global;function o(){return i.localStorage}function a(e){return o().getItem(e)}function s(e,t){return o().setItem(e,t)}function c(e){for(var t=o().length-1;t>=0;t--){var n=o().key(t);e(a(n),n)}}function l(e){return o().removeItem(e)}function u(){return o().clear()}e.exports={name:"localStorage",read:a,write:s,each:c,remove:l,clearAll:u}},81529:function(e){e.exports={name:"memoryStorage",read:n,write:r,each:i,remove:o,clearAll:a};var t={};function n(e){return t[e]}function r(e,n){t[e]=n}function i(e){for(var n in t)t.hasOwnProperty(n)&&e(t[n],n)}function o(e){delete t[e]}function a(e){t={}}},27333:function(e,t,n){var r=n(16123),i=r.Global;e.exports={name:"oldFF-globalStorage",read:a,write:s,each:c,remove:l,clearAll:u};var o=i.globalStorage;function a(e){return o[e]}function s(e,t){o[e]=t}function c(e){for(var t=o.length-1;t>=0;t--){var n=o.key(t);e(o[n],n)}}function l(e){return o.removeItem(e)}function u(){c(function(e,t){delete o[e]})}},45991:function(e,t,n){var r=n(16123),i=r.Global;e.exports={name:"oldIE-userDataStorage",write:l,read:u,each:d,remove:h,clearAll:f};var o="storejs",a=i.document,s=v(),c=(i.navigator?i.navigator.userAgent:"").match(/ (MSIE 8|MSIE 9|MSIE 10)\./);function l(e,t){if(!c){var n=m(e);s(function(e){e.setAttribute(n,t),e.save(o)})}}function u(e){if(!c){var t=m(e),n=null;return s(function(e){n=e.getAttribute(t)}),n}}function d(e){s(function(t){for(var n=t.XMLDocument.documentElement.attributes,r=n.length-1;r>=0;r--){var i=n[r];e(t.getAttribute(i.name),i.name)}})}function h(e){var t=m(e);s(function(e){e.removeAttribute(t),e.save(o)})}function f(){s(function(e){var t=e.XMLDocument.documentElement.attributes;e.load(o);for(var n=t.length-1;n>=0;n--)e.removeAttribute(t[n].name);e.save(o)})}var p=new RegExp("[!\"#$%&'()*+,/\\\\:;<=>?@[\\]^`{|}~]","g");function m(e){return e.replace(/^\d/,"___$&").replace(p,"___")}function v(){if(!a||!a.documentElement||!a.documentElement.addBehavior)return null;var e,t,n,r="script";try{t=new ActiveXObject("htmlfile"),t.open(),t.write("<"+r+">document.w=window'),t.close(),e=t.w.frames[0].document,n=e.createElement("div")}catch(i){n=a.createElement("div"),e=a.body}return function(t){var r=[].slice.call(arguments,0);r.unshift(n),e.appendChild(n),n.addBehavior("#default#userData"),n.load(o),t.apply(this,r),e.removeChild(n)}}},65416:function(e,t,n){var r=n(16123),i=r.Global;function o(){return i.sessionStorage}function a(e){return o().getItem(e)}function s(e,t){return o().setItem(e,t)}function c(e){for(var t=o().length-1;t>=0;t--){var n=o().key(t);e(a(n),n)}}function l(e){return o().removeItem(e)}function u(){return o().clear()}e.exports={name:"sessionStorage",read:a,write:s,each:c,remove:l,clearAll:u}},16426:function(e){e.exports=function(){var e=document.getSelection();if(!e.rangeCount)return function(){};for(var t=document.activeElement,n=[],r=0;rc)r.f(e,n=a[c++],t[n]);return e}},"18d2":function(e,t,n){"use strict";var r=n("18e9");e.exports=function(e){e=e||{};var t=e.reporter,n=e.batchProcessor,i=e.stateHandler.getState;if(!t)throw new Error("Missing required dependency: reporter.");function o(e,t){if(!s(e))throw new Error("Element is not detectable by this strategy.");function n(){t(e)}if(r.isIE(8))i(e).object={proxy:n},e.attachEvent("onresize",n);else{var o=s(e);o.contentDocument.defaultView.addEventListener("resize",n)}}function a(e,o,a){a||(a=o,o=e,e=null),e=e||{};e.debug;function s(e,o){var a="display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; padding: 0; margin: 0; opacity: 0; z-index: -1000; pointer-events: none;",s=!1,c=window.getComputedStyle(e),l=e.offsetWidth,u=e.offsetHeight;function d(){function n(){if("static"===c.position){e.style.position="relative";var n=function(e,t,n,r){function i(e){return e.replace(/[^-\d\.]/g,"")}var o=n[r];"auto"!==o&&"0"!==i(o)&&(e.warn("An element that is positioned static has style."+r+"="+o+" which is ignored due to the static positioning. The element will need to be positioned relative, so the style."+r+" will be set to 0. Element: ",t),t.style[r]=0)};n(t,e,c,"top"),n(t,e,c,"right"),n(t,e,c,"bottom"),n(t,e,c,"left")}}function l(){function t(e,n){e.contentDocument?n(e.contentDocument):setTimeout(function(){t(e,n)},100)}s||n();var r=this;t(r,function(t){o(e)})}""!==c.position&&(n(c),s=!0);var u=document.createElement("object");u.style.cssText=a,u.tabIndex=-1,u.type="text/html",u.onload=l,r.isIE()||(u.data="about:blank"),e.appendChild(u),i(e).object=u,r.isIE()&&(u.data="about:blank")}i(e).startSize={width:l,height:u},n?n.add(d):d()}r.isIE(8)?a(o):s(o,a)}function s(e){return i(e).object}function c(e){r.isIE(8)?e.detachEvent("onresize",i(e).object.proxy):e.removeChild(s(e)),delete i(e).object}return{makeDetectable:a,addListener:o,uninstall:c}}},"18e9":function(e,t,n){"use strict";var r=e.exports={};r.isIE=function(e){function t(){var e=navigator.userAgent.toLowerCase();return-1!==e.indexOf("msie")||-1!==e.indexOf("trident")||-1!==e.indexOf(" edge/")}if(!t())return!1;if(!e)return!0;var n=function(){var e,t=3,n=document.createElement("div"),r=n.getElementsByTagName("i");do{n.innerHTML="\x3c!--[if gt IE "+ ++t+"]>4?t:e}();return e===n},r.isLegacyOpera=function(){return!!window.opera}},"1b47":function(e,t,n){"use strict";function r(e){for(var t=[],n=0,r=Object.keys(e);n";t.style.display="none",n("fab2").appendChild(t),t.src="javascript:",e=t.contentWindow.document,e.open(),e.write(i+"script"+a+"document.F=Object"+i+"/script"+a),e.close(),l=e.F;while(r--)delete l[c][o[r]];return l()};e.exports=Object.create||function(e,t){var n;return null!==e?(s[c]=r(e),n=new s,s[c]=null,n[a]=e):n=l(),void 0===t?n:i(n,t)}},"2b4c":function(e,t,n){var r=n("5537")("wks"),i=n("ca5a"),o=n("7726").Symbol,a="function"==typeof o,s=e.exports=function(e){return r[e]||(r[e]=a&&o[e]||(a?o:i)("Symbol."+e))};s.store=r},"2cef":function(e,t,n){"use strict";e.exports=function(){var e=1;function t(){return e++}return{generate:t}}},"2d00":function(e,t){e.exports=!1},"2d95":function(e,t){var n={}.toString;e.exports=function(e){return n.call(e).slice(8,-1)}},"32e9":function(e,t,n){var r=n("86cc"),i=n("4630");e.exports=n("9e1e")?function(e,t,n){return r.f(e,t,i(1,n))}:function(e,t,n){return e[t]=n,e}},"38fd":function(e,t,n){var r=n("69a8"),i=n("4bf8"),o=n("613b")("IE_PROTO"),a=Object.prototype;e.exports=Object.getPrototypeOf||function(e){return e=i(e),r(e,o)?e[o]:"function"==typeof e.constructor&&e instanceof e.constructor?e.constructor.prototype:e instanceof Object?a:null}},"41a0":function(e,t,n){"use strict";var r=n("2aeb"),i=n("4630"),o=n("7f20"),a={};n("32e9")(a,n("2b4c")("iterator"),function(){return this}),e.exports=function(e,t,n){e.prototype=r(a,{next:i(1,n)}),o(e,t+" Iterator")}},"456d":function(e,t,n){var r=n("4bf8"),i=n("0d58");n("5eda")("keys",function(){return function(e){return i(r(e))}})},4588:function(e,t){var n=Math.ceil,r=Math.floor;e.exports=function(e){return isNaN(e=+e)?0:(e>0?r:n)(e)}},4630:function(e,t){e.exports=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}}},"49ad":function(e,t,n){"use strict";e.exports=function(e){var t={};function n(n){var r=e.get(n);return void 0===r?[]:t[r]||[]}function r(n,r){var i=e.get(n);t[i]||(t[i]=[]),t[i].push(r)}function i(e,t){for(var r=n(e),i=0,o=r.length;i0?i(r(e),9007199254740991):0}},"9e1e":function(e,t,n){e.exports=!n("79e5")(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},a307:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n("eec4"),i=function(){function e(e){var t=this;this.handler=e,this.listenedElement=null,this.hasResizeObserver="undefined"!==typeof window.ResizeObserver,this.hasResizeObserver?this.rz=new ResizeObserver(function(e){t.handler(o(e[0].target))}):this.erd=r({strategy:"scroll"})}return e.prototype.observe=function(e){var t=this;this.listenedElement!==e&&(this.listenedElement&&this.disconnect(),e&&(this.hasResizeObserver?this.rz.observe(e):this.erd.listenTo(e,function(e){t.handler(o(e))})),this.listenedElement=e)},e.prototype.disconnect=function(){this.listenedElement&&(this.hasResizeObserver?this.rz.disconnect():this.erd.uninstall(this.listenedElement),this.listenedElement=null)},e}();function o(e){return{width:a(window.getComputedStyle(e)["width"]),height:a(window.getComputedStyle(e)["height"])}}function a(e){var t=/^([0-9\.]+)px$/.exec(e);return t?parseFloat(t[1]):0}t.default=i},abb4:function(e,t,n){"use strict";e.exports=function(e){function t(){}var n={log:t,warn:t,error:t};if(!e&&window.console){var r=function(e,t){e[t]=function(){var e=console[t];if(e.apply)e.apply(console,arguments);else for(var n=0;nn?n=i:iu)if(s=c[u++],s!=s)return!0}else for(;l>u;u++)if((e||u in c)&&c[u]===n)return e||u||0;return!e&&-1}}},c69a:function(e,t,n){e.exports=!n("9e1e")&&!n("79e5")(function(){return 7!=Object.defineProperty(n("230e")("div"),"a",{get:function(){return 7}}).a})},c946:function(e,t,n){"use strict";var r=n("b770").forEach;e.exports=function(e){e=e||{};var t=e.reporter,n=e.batchProcessor,i=e.stateHandler.getState,o=(e.stateHandler.hasState,e.idHandler);if(!n)throw new Error("Missing required dependency: batchProcessor");if(!t)throw new Error("Missing required dependency: reporter.");var a=l(),s="erd_scroll_detection_scrollbar_style",c="erd_scroll_detection_container";function l(){var e=500,t=500,n=document.createElement("div");n.style.cssText="position: absolute; width: "+2*e+"px; height: "+2*t+"px; visibility: hidden; margin: 0; padding: 0;";var r=document.createElement("div");r.style.cssText="position: absolute; width: "+e+"px; height: "+t+"px; overflow: scroll; visibility: none; top: "+3*-e+"px; left: "+3*-t+"px; visibility: hidden; margin: 0; padding: 0;",r.appendChild(n),document.body.insertBefore(r,document.body.firstChild);var i=e-r.clientWidth,o=t-r.clientHeight;return document.body.removeChild(r),{width:i,height:o}}function u(e,t){function n(t,n){n=n||function(e){document.head.appendChild(e)};var r=document.createElement("style");return r.innerHTML=t,r.id=e,n(r),r}if(!document.getElementById(e)){var r=t+"_animation",i=t+"_animation_active",o="/* Created by the element-resize-detector library. */\n";o+="."+t+" > div::-webkit-scrollbar { display: none; }\n\n",o+="."+i+" { -webkit-animation-duration: 0.1s; animation-duration: 0.1s; -webkit-animation-name: "+r+"; animation-name: "+r+"; }\n",o+="@-webkit-keyframes "+r+" { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } }\n",o+="@keyframes "+r+" { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } }",n(o)}}function d(e){e.className+=" "+c+"_animation_active"}function h(e,n,r){if(e.addEventListener)e.addEventListener(n,r);else{if(!e.attachEvent)return t.error("[scroll] Don't know how to add event listeners.");e.attachEvent("on"+n,r)}}function f(e,n,r){if(e.removeEventListener)e.removeEventListener(n,r);else{if(!e.detachEvent)return t.error("[scroll] Don't know how to remove event listeners.");e.detachEvent("on"+n,r)}}function p(e){return i(e).container.childNodes[0].childNodes[0].childNodes[0]}function m(e){return i(e).container.childNodes[0].childNodes[0].childNodes[1]}function v(e,t){var n=i(e).listeners;if(!n.push)throw new Error("Cannot add listener to an element that is not detectable.");i(e).listeners.push(t)}function g(e,s,l){function u(){if(e.debug){var n=Array.prototype.slice.call(arguments);if(n.unshift(o.get(s),"Scroll: "),t.log.apply)t.log.apply(null,n);else for(var r=0;r=e.length?(this._t=void 0,i(1)):i(0,"keys"==t?n:"values"==t?e[n]:[n,e[n]])},"values"),o.Arguments=o.Array,r("keys"),r("values"),r("entries")},cb7c:function(e,t,n){var r=n("d3f4");e.exports=function(e){if(!r(e))throw TypeError(e+" is not an object!");return e}},ce10:function(e,t,n){var r=n("69a8"),i=n("6821"),o=n("c366")(!1),a=n("613b")("IE_PROTO");e.exports=function(e,t){var n,s=i(e),c=0,l=[];for(n in s)n!=a&&r(s,n)&&l.push(n);while(t.length>c)r(s,n=t[c++])&&(~o(l,n)||l.push(n));return l}},d3f4:function(e,t){e.exports=function(e){return"object"===typeof e?null!==e:"function"===typeof e}},d53b:function(e,t){e.exports=function(e,t){return{value:t,done:!!e}}},d6eb:function(e,t,n){"use strict";var r="_erd";function i(e){return e[r]={},o(e)}function o(e){return e[r]}function a(e){delete e[r]}e.exports={initState:i,getState:o,cleanState:a}},d8e8:function(e,t){e.exports=function(e){if("function"!=typeof e)throw TypeError(e+" is not a function!");return e}},e11e:function(e,t){e.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},eec4:function(e,t,n){"use strict";var r=n("b770").forEach,i=n("5be5"),o=n("49ad"),a=n("2cef"),s=n("5058"),c=n("abb4"),l=n("18e9"),u=n("c274"),d=n("d6eb"),h=n("18d2"),f=n("c946");function p(e){return Array.isArray(e)||void 0!==e.length}function m(e){if(Array.isArray(e))return e;var t=[];return r(e,function(e){t.push(e)}),t}function v(e){return e&&1===e.nodeType}function g(e,t,n){var r=e[t];return void 0!==r&&null!==r||void 0===n?r:n}e.exports=function(e){var t;if(e=e||{},e.idHandler)t={get:function(t){return e.idHandler.get(t,!0)},set:e.idHandler.set};else{var n=a(),y=s({idGenerator:n,stateHandler:d});t=y}var _=e.reporter;if(!_){var b=!1===_;_=c(b)}var M=g(e,"batchProcessor",u({reporter:_})),A={};A.callOnAdd=!!g(e,"callOnAdd",!0),A.debug=!!g(e,"debug",!1);var w,x=o(t),k=i({stateHandler:d}),L=g(e,"strategy","object"),C={reporter:_,batchProcessor:M,stateHandler:d,idHandler:t};if("scroll"===L&&(l.isLegacyOpera()?(_.warn("Scroll strategy is not supported on legacy Opera. Changing to object strategy."),L="object"):l.isIE(9)&&(_.warn("Scroll strategy is not supported on IE9. Changing to object strategy."),L="object")),"scroll"===L)w=f(C);else{if("object"!==L)throw new Error("Invalid strategy name: "+L);w=h(C)}var S={};function z(e,n,i){function o(e){var t=x.get(e);r(t,function(t){t(e)})}function a(e,t,n){x.add(t,n),e&&n(t)}if(i||(i=n,n=e,e={}),!n)throw new Error("At least one element required.");if(!i)throw new Error("Listener required.");if(v(n))n=[n];else{if(!p(n))return _.error("Invalid arguments. Must be a DOM element or a collection of DOM elements.");n=m(n)}var s=0,c=g(e,"callOnAdd",A.callOnAdd),l=g(e,"onReady",function(){}),u=g(e,"debug",A.debug);r(n,function(e){d.getState(e)||(d.initState(e),t.set(e));var h=t.get(e);if(u&&_.log("Attaching listener to element",h,e),!k.isDetectable(e))return u&&_.log(h,"Not detectable."),k.isBusy(e)?(u&&_.log(h,"System busy making it detectable"),a(c,e,i),S[h]=S[h]||[],void S[h].push(function(){s++,s===n.length&&l()})):(u&&_.log(h,"Making detectable..."),k.markBusy(e,!0),w.makeDetectable({debug:u},e,function(e){if(u&&_.log(h,"onElementDetectable"),d.getState(e)){k.markAsDetectable(e),k.markBusy(e,!1),w.addListener(e,o),a(c,e,i);var t=d.getState(e);if(t&&t.startSize){var f=e.offsetWidth,p=e.offsetHeight;t.startSize.width===f&&t.startSize.height===p||o(e)}S[h]&&r(S[h],function(e){e()})}else u&&_.log(h,"Element uninstalled before being detectable.");delete S[h],s++,s===n.length&&l()}));u&&_.log(h,"Already detecable, adding listener."),a(c,e,i),s++}),s===n.length&&l()}function T(e){if(!e)return _.error("At least one element is required.");if(v(e))e=[e];else{if(!p(e))return _.error("Invalid arguments. Must be a DOM element or a collection of DOM elements.");e=m(e)}r(e,function(e){x.removeAllListeners(e),w.uninstall(e),d.cleanState(e)})}return{listenTo:z,removeListener:x.removeListener,removeAllListeners:x.removeAllListeners,uninstall:T}}},fab2:function(e,t,n){var r=n("7726").document;e.exports=r&&r.documentElement},fae3:function(e,t,n){"use strict";n.r(t);n("1eb2");var r=n("1b47"),i=n.n(r);function o(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function a(e,t){for(var n=0;n=0&&c.splice(t,1)}function p(e){var t=document.createElement("style");if(void 0===e.attrs.type&&(e.attrs.type="text/css"),void 0===e.attrs.nonce){var r=function(){return n.nc}();r&&(e.attrs.nonce=r)}return m(t,e.attrs),h(e,t),t}function m(e,t){Object.keys(t).forEach(function(n){e.setAttribute(n,t[n])})}function v(e,t){var n,r,i,o;if(t.transform&&e.css){if(!(o="function"==typeof t.transform?t.transform(e.css):t.transform.default(e.css)))return function(){};e.css=o}if(t.singleton){var c=s++;n=a||(a=p(t)),r=y.bind(null,n,c,!1),i=y.bind(null,n,c,!0)}else e.sourceMap&&"function"==typeof URL&&"function"==typeof URL.createObjectURL&&"function"==typeof URL.revokeObjectURL&&"function"==typeof Blob&&"function"==typeof btoa?(n=function(e){var t=document.createElement("link");return void 0===e.attrs.type&&(e.attrs.type="text/css"),e.attrs.rel="stylesheet",m(t,e.attrs),h(e,t),t}(t),r=function(e,t,n){var r=n.css,i=n.sourceMap,o=void 0===t.convertToAbsoluteUrls&&i;(t.convertToAbsoluteUrls||o)&&(r=l(r)),i&&(r+="\n/*# sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(i))))+" */");var a=new Blob([r],{type:"text/css"}),s=e.href;e.href=URL.createObjectURL(a),s&&URL.revokeObjectURL(s)}.bind(null,n,t),i=function(){f(n),n.href&&URL.revokeObjectURL(n.href)}):(n=p(t),r=function(e,t){var n=t.css,r=t.media;if(r&&e.setAttribute("media",r),e.styleSheet)e.styleSheet.cssText=n;else{for(;e.firstChild;)e.removeChild(e.firstChild);e.appendChild(document.createTextNode(n))}}.bind(null,n),i=function(){f(n)});return r(e),function(t){if(t){if(t.css===e.css&&t.media===e.media&&t.sourceMap===e.sourceMap)return;r(e=t)}else i()}}e.exports=function(e,t){if("undefined"!=typeof DEBUG&&DEBUG&&"object"!=typeof document)throw new Error("The style-loader cannot be used in a non-browser environment");(t=t||{}).attrs="object"==typeof t.attrs?t.attrs:{},t.singleton||"boolean"==typeof t.singleton||(t.singleton=i()),t.insertInto||(t.insertInto="head"),t.insertAt||(t.insertAt="bottom");var n=d(e,t);return u(n,t),function(e){for(var i=[],o=0;o=8&&(s=n+r))),s)for(d=u.getUint16(s,i),l=0;l21?"-21px":"0px",e.width=this.cropW>0?this.cropW:0,e.height=this.cropH>0?this.cropH:0,this.infoTrue){var t=1;this.high&&!this.full&&(t=window.devicePixelRatio),1!==this.enlarge&!this.full&&(t=Math.abs(Number(this.enlarge))),e.width=e.width*t,e.height=e.height*t,this.full&&(e.width=e.width/this.scale,e.height=e.height/this.scale)}return e.width=e.width.toFixed(0),e.height=e.height.toFixed(0),e},isIE:function(){var e=navigator.userAgent,t=e.indexOf("compatible")>-1&&e.indexOf("MSIE")>-1;return t}},watch:{img:function(){this.checkedImg()},imgs:function(e){""!==e&&this.reload()},cropW:function(){this.showPreview()},cropH:function(){this.showPreview()},cropOffsertX:function(){this.showPreview()},cropOffsertY:function(){this.showPreview()},scale:function(e,t){this.showPreview()},x:function(){this.showPreview()},y:function(){this.showPreview()},autoCrop:function(e){e&&this.goAutoCrop()},autoCropWidth:function(){this.autoCrop&&this.goAutoCrop()},autoCropHeight:function(){this.autoCrop&&this.goAutoCrop()},mode:function(){this.checkedImg()},rotate:function(){this.showPreview(),(this.autoCrop||this.cropW>0||this.cropH>0)&&this.goAutoCrop(this.cropW,this.cropH)}},methods:{checkOrientationImage:function(e,t,n,r){var i=this,o=document.createElement("canvas"),a=o.getContext("2d");switch(a.save(),t){case 2:o.width=n,o.height=r,a.translate(n,0),a.scale(-1,1);break;case 3:o.width=n,o.height=r,a.translate(n/2,r/2),a.rotate(180*Math.PI/180),a.translate(-n/2,-r/2);break;case 4:o.width=n,o.height=r,a.translate(0,r),a.scale(1,-1);break;case 5:o.height=n,o.width=r,a.rotate(.5*Math.PI),a.scale(1,-1);break;case 6:o.width=r,o.height=n,a.translate(r/2,n/2),a.rotate(90*Math.PI/180),a.translate(-n/2,-r/2);break;case 7:o.height=n,o.width=r,a.rotate(.5*Math.PI),a.translate(n,-r),a.scale(-1,1);break;case 8:o.height=n,o.width=r,a.translate(r/2,n/2),a.rotate(-90*Math.PI/180),a.translate(-n/2,-r/2);break;default:o.width=n,o.height=r}a.drawImage(e,0,0,n,r),a.restore(),o.toBlob(function(e){var t=URL.createObjectURL(e);i.imgs=t},"image/"+this.outputType,1)},checkedImg:function(){var e=this;if(""!==this.img){this.loading=!0,this.scale=1,this.rotate=0,this.clearCrop();var t=new Image;if(t.onload=function(){if(""===e.img)return e.$emit("imgLoad","error"),e.$emit("img-load","error"),!1;var n=t.width,r=t.height;o.getData(t).then(function(i){e.orientation=i.orientation||1;var o=e.maxImgSize;!e.orientation&&no&&(r=r/n*o,n=o),r>o&&(n=n/r*o,r=o),e.checkOrientationImage(t,e.orientation,n,r))})},t.onerror=function(){e.$emit("imgLoad","error"),e.$emit("img-load","error")},"data"!==this.img.substr(0,4)&&(t.crossOrigin=""),this.isIE){var n=new XMLHttpRequest;n.onload=function(){var e=URL.createObjectURL(this.response);t.src=e},n.open("GET",this.img,!0),n.responseType="blob",n.send()}else t.src=this.img}},startMove:function(e){if(e.preventDefault(),this.move&&!this.crop){if(!this.canMove)return!1;this.moveX=(e.clientX?e.clientX:e.touches[0].clientX)-this.x,this.moveY=(e.clientY?e.clientY:e.touches[0].clientY)-this.y,e.touches?(window.addEventListener("touchmove",this.moveImg),window.addEventListener("touchend",this.leaveImg),2==e.touches.length&&(this.touches=e.touches,window.addEventListener("touchmove",this.touchScale),window.addEventListener("touchend",this.cancelTouchScale))):(window.addEventListener("mousemove",this.moveImg),window.addEventListener("mouseup",this.leaveImg)),this.$emit("imgMoving",{moving:!0,axis:this.getImgAxis()}),this.$emit("img-moving",{moving:!0,axis:this.getImgAxis()})}else this.cropping=!0,window.addEventListener("mousemove",this.createCrop),window.addEventListener("mouseup",this.endCrop),window.addEventListener("touchmove",this.createCrop),window.addEventListener("touchend",this.endCrop),this.cropOffsertX=e.offsetX?e.offsetX:e.touches[0].pageX-this.$refs.cropper.offsetLeft,this.cropOffsertY=e.offsetY?e.offsetY:e.touches[0].pageY-this.$refs.cropper.offsetTop,this.cropX=e.clientX?e.clientX:e.touches[0].clientX,this.cropY=e.clientY?e.clientY:e.touches[0].clientY,this.cropChangeX=this.cropOffsertX,this.cropChangeY=this.cropOffsertY,this.cropW=0,this.cropH=0},touchScale:function(e){var t=this;e.preventDefault();var n=this.scale,r=this.touches[0].clientX,i=this.touches[0].clientY,o=e.touches[0].clientX,a=e.touches[0].clientY,s=this.touches[1].clientX,c=this.touches[1].clientY,l=e.touches[1].clientX,u=e.touches[1].clientY,d=Math.sqrt(Math.pow(r-s,2)+Math.pow(i-c,2)),h=Math.sqrt(Math.pow(o-l,2)+Math.pow(a-u,2))-d,f=1,p=(f=(f=f/this.trueWidth>f/this.trueHeight?f/this.trueHeight:f/this.trueWidth)>.1?.1:f)*h;if(!this.touchNow){if(this.touchNow=!0,h>0?n+=Math.abs(p):h<0&&n>Math.abs(p)&&(n-=Math.abs(p)),this.touches=e.touches,setTimeout(function(){t.touchNow=!1},8),!this.checkoutImgAxis(this.x,this.y,n))return!1;this.scale=n}},cancelTouchScale:function(e){window.removeEventListener("touchmove",this.touchScale)},moveImg:function(e){var t=this;if(e.preventDefault(),e.touches&&2===e.touches.length)return this.touches=e.touches,window.addEventListener("touchmove",this.touchScale),window.addEventListener("touchend",this.cancelTouchScale),window.removeEventListener("touchmove",this.moveImg),!1;var n,r,i=e.clientX?e.clientX:e.touches[0].clientX,o=e.clientY?e.clientY:e.touches[0].clientY;n=i-this.moveX,r=o-this.moveY,this.$nextTick(function(){if(t.centerBox){var e,i,o,a,s=t.getImgAxis(n,r,t.scale),c=t.getCropAxis(),l=t.trueHeight*t.scale,u=t.trueWidth*t.scale;switch(t.rotate){case 1:case-1:case 3:case-3:e=t.cropOffsertX-t.trueWidth*(1-t.scale)/2+(l-u)/2,i=t.cropOffsertY-t.trueHeight*(1-t.scale)/2+(u-l)/2,o=e-l+t.cropW,a=i-u+t.cropH;break;default:e=t.cropOffsertX-t.trueWidth*(1-t.scale)/2,i=t.cropOffsertY-t.trueHeight*(1-t.scale)/2,o=e-u+t.cropW,a=i-l+t.cropH}s.x1>=c.x1&&(n=e),s.y1>=c.y1&&(r=i),s.x2<=c.x2&&(n=o),s.y2<=c.y2&&(r=a)}t.x=n,t.y=r,t.$emit("imgMoving",{moving:!0,axis:t.getImgAxis()}),t.$emit("img-moving",{moving:!0,axis:t.getImgAxis()})})},leaveImg:function(e){window.removeEventListener("mousemove",this.moveImg),window.removeEventListener("touchmove",this.moveImg),window.removeEventListener("mouseup",this.leaveImg),window.removeEventListener("touchend",this.leaveImg),this.$emit("imgMoving",{moving:!1,axis:this.getImgAxis()}),this.$emit("img-moving",{moving:!1,axis:this.getImgAxis()})},scaleImg:function(){this.canScale&&window.addEventListener(this.support,this.changeSize,{passive:!1})},cancelScale:function(){this.canScale&&window.removeEventListener(this.support,this.changeSize)},changeSize:function(e){var t=this;e.preventDefault();var n=this.scale,r=e.deltaY||e.wheelDelta;r=navigator.userAgent.indexOf("Firefox")>0?30*r:r,this.isIE&&(r=-r);var i=this.coe,o=(i=i/this.trueWidth>i/this.trueHeight?i/this.trueHeight:i/this.trueWidth)*r;o<0?n+=Math.abs(o):n>Math.abs(o)&&(n-=Math.abs(o));var a=o<0?"add":"reduce";if(a!==this.coeStatus&&(this.coeStatus=a,this.coe=.2),this.scaling||(this.scalingSet=setTimeout(function(){t.scaling=!1,t.coe=t.coe+=.01},50)),this.scaling=!0,!this.checkoutImgAxis(this.x,this.y,n))return!1;this.scale=n},changeScale:function(e){var t=this.scale;e=e||1;var n=20;if((e*=n=n/this.trueWidth>n/this.trueHeight?n/this.trueHeight:n/this.trueWidth)>0?t+=Math.abs(e):t>Math.abs(e)&&(t-=Math.abs(e)),!this.checkoutImgAxis(this.x,this.y,t))return!1;this.scale=t},createCrop:function(e){var t=this;e.preventDefault();var n=e.clientX?e.clientX:e.touches?e.touches[0].clientX:0,r=e.clientY?e.clientY:e.touches?e.touches[0].clientY:0;this.$nextTick(function(){var e=n-t.cropX,i=r-t.cropY;if(e>0?(t.cropW=e+t.cropChangeX>t.w?t.w-t.cropChangeX:e,t.cropOffsertX=t.cropChangeX):(t.cropW=t.w-t.cropChangeX+Math.abs(e)>t.w?t.cropChangeX:Math.abs(e),t.cropOffsertX=t.cropChangeX+e>0?t.cropChangeX+e:0),t.fixed){var o=t.cropW/t.fixedNumber[0]*t.fixedNumber[1];o+t.cropOffsertY>t.h?(t.cropH=t.h-t.cropOffsertY,t.cropW=t.cropH/t.fixedNumber[1]*t.fixedNumber[0],t.cropOffsertX=e>0?t.cropChangeX:t.cropChangeX-t.cropW):t.cropH=o,t.cropOffsertY=t.cropOffsertY}else i>0?(t.cropH=i+t.cropChangeY>t.h?t.h-t.cropChangeY:i,t.cropOffsertY=t.cropChangeY):(t.cropH=t.h-t.cropChangeY+Math.abs(i)>t.h?t.cropChangeY:Math.abs(i),t.cropOffsertY=t.cropChangeY+i>0?t.cropChangeY+i:0)})},changeCropSize:function(e,t,n,r,i){e.preventDefault(),window.addEventListener("mousemove",this.changeCropNow),window.addEventListener("mouseup",this.changeCropEnd),window.addEventListener("touchmove",this.changeCropNow),window.addEventListener("touchend",this.changeCropEnd),this.canChangeX=t,this.canChangeY=n,this.changeCropTypeX=r,this.changeCropTypeY=i,this.cropX=e.clientX?e.clientX:e.touches[0].clientX,this.cropY=e.clientY?e.clientY:e.touches[0].clientY,this.cropOldW=this.cropW,this.cropOldH=this.cropH,this.cropChangeX=this.cropOffsertX,this.cropChangeY=this.cropOffsertY,this.fixed&&this.canChangeX&&this.canChangeY&&(this.canChangeY=0)},changeCropNow:function(e){var t=this;e.preventDefault();var n=e.clientX?e.clientX:e.touches?e.touches[0].clientX:0,r=e.clientY?e.clientY:e.touches?e.touches[0].clientY:0,i=this.w,o=this.h,a=0,s=0;if(this.centerBox){var c=this.getImgAxis(),l=c.x2,u=c.y2;a=c.x1>0?c.x1:0,s=c.y1>0?c.y1:0,i>l&&(i=l),o>u&&(o=u)}this.$nextTick(function(){var e=n-t.cropX,c=r-t.cropY;if(t.canChangeX&&(1===t.changeCropTypeX?t.cropOldW-e>0?(t.cropW=i-t.cropChangeX-e<=i-a?t.cropOldW-e:t.cropOldW+t.cropChangeX-a,t.cropOffsertX=i-t.cropChangeX-e<=i-a?t.cropChangeX+e:a):(t.cropW=Math.abs(e)+t.cropChangeX<=i?Math.abs(e)-t.cropOldW:i-t.cropOldW-t.cropChangeX,t.cropOffsertX=t.cropChangeX+t.cropOldW):2===t.changeCropTypeX&&(t.cropOldW+e>0?(t.cropW=t.cropOldW+e+t.cropOffsertX<=i?t.cropOldW+e:i-t.cropOffsertX,t.cropOffsertX=t.cropChangeX):(t.cropW=i-t.cropChangeX+Math.abs(e+t.cropOldW)<=i-a?Math.abs(e+t.cropOldW):t.cropChangeX-a,t.cropOffsertX=i-t.cropChangeX+Math.abs(e+t.cropOldW)<=i-a?t.cropChangeX-Math.abs(e+t.cropOldW):a))),t.canChangeY&&(1===t.changeCropTypeY?t.cropOldH-c>0?(t.cropH=o-t.cropChangeY-c<=o-s?t.cropOldH-c:t.cropOldH+t.cropChangeY-s,t.cropOffsertY=o-t.cropChangeY-c<=o-s?t.cropChangeY+c:s):(t.cropH=Math.abs(c)+t.cropChangeY<=o?Math.abs(c)-t.cropOldH:o-t.cropOldH-t.cropChangeY,t.cropOffsertY=t.cropChangeY+t.cropOldH):2===t.changeCropTypeY&&(t.cropOldH+c>0?(t.cropH=t.cropOldH+c+t.cropOffsertY<=o?t.cropOldH+c:o-t.cropOffsertY,t.cropOffsertY=t.cropChangeY):(t.cropH=o-t.cropChangeY+Math.abs(c+t.cropOldH)<=o-s?Math.abs(c+t.cropOldH):t.cropChangeY-s,t.cropOffsertY=o-t.cropChangeY+Math.abs(c+t.cropOldH)<=o-s?t.cropChangeY-Math.abs(c+t.cropOldH):s))),t.canChangeX&&t.fixed){var l=t.cropW/t.fixedNumber[0]*t.fixedNumber[1];l+t.cropOffsertY>o?(t.cropH=o-t.cropOffsertY,t.cropW=t.cropH/t.fixedNumber[1]*t.fixedNumber[0]):t.cropH=l}if(t.canChangeY&&t.fixed){var u=t.cropH/t.fixedNumber[1]*t.fixedNumber[0];u+t.cropOffsertX>i?(t.cropW=i-t.cropOffsertX,t.cropH=t.cropW/t.fixedNumber[0]*t.fixedNumber[1]):t.cropW=u}})},changeCropEnd:function(e){window.removeEventListener("mousemove",this.changeCropNow),window.removeEventListener("mouseup",this.changeCropEnd),window.removeEventListener("touchmove",this.changeCropNow),window.removeEventListener("touchend",this.changeCropEnd)},endCrop:function(){0===this.cropW&&0===this.cropH&&(this.cropping=!1),window.removeEventListener("mousemove",this.createCrop),window.removeEventListener("mouseup",this.endCrop),window.removeEventListener("touchmove",this.createCrop),window.removeEventListener("touchend",this.endCrop)},startCrop:function(){this.crop=!0},stopCrop:function(){this.crop=!1},clearCrop:function(){this.cropping=!1,this.cropW=0,this.cropH=0},cropMove:function(e){if(e.preventDefault(),!this.canMoveBox)return this.crop=!1,this.startMove(e),!1;if(e.touches&&2===e.touches.length)return this.crop=!1,this.startMove(e),this.leaveCrop(),!1;window.addEventListener("mousemove",this.moveCrop),window.addEventListener("mouseup",this.leaveCrop),window.addEventListener("touchmove",this.moveCrop),window.addEventListener("touchend",this.leaveCrop);var t,n,r=e.clientX?e.clientX:e.touches[0].clientX,i=e.clientY?e.clientY:e.touches[0].clientY;t=r-this.cropOffsertX,n=i-this.cropOffsertY,this.cropX=t,this.cropY=n,this.$emit("cropMoving",{moving:!0,axis:this.getCropAxis()}),this.$emit("crop-moving",{moving:!0,axis:this.getCropAxis()})},moveCrop:function(e,t){var n=this,r=0,i=0;e&&(e.preventDefault(),r=e.clientX?e.clientX:e.touches[0].clientX,i=e.clientY?e.clientY:e.touches[0].clientY),this.$nextTick(function(){var e,o,a=r-n.cropX,s=i-n.cropY;if(t&&(a=n.cropOffsertX,s=n.cropOffsertY),e=a<=0?0:a+n.cropW>n.w?n.w-n.cropW:a,o=s<=0?0:s+n.cropH>n.h?n.h-n.cropH:s,n.centerBox){var c=n.getImgAxis();e<=c.x1&&(e=c.x1),e+n.cropW>c.x2&&(e=c.x2-n.cropW),o<=c.y1&&(o=c.y1),o+n.cropH>c.y2&&(o=c.y2-n.cropH)}n.cropOffsertX=e,n.cropOffsertY=o,n.$emit("cropMoving",{moving:!0,axis:n.getCropAxis()}),n.$emit("crop-moving",{moving:!0,axis:n.getCropAxis()})})},getImgAxis:function(e,t,n){e=e||this.x,t=t||this.y,n=n||this.scale;var r={x1:0,x2:0,y1:0,y2:0},i=this.trueWidth*n,o=this.trueHeight*n;switch(this.rotate){case 0:r.x1=e+this.trueWidth*(1-n)/2,r.x2=r.x1+this.trueWidth*n,r.y1=t+this.trueHeight*(1-n)/2,r.y2=r.y1+this.trueHeight*n;break;case 1:case-1:case 3:case-3:r.x1=e+this.trueWidth*(1-n)/2+(i-o)/2,r.x2=r.x1+this.trueHeight*n,r.y1=t+this.trueHeight*(1-n)/2+(o-i)/2,r.y2=r.y1+this.trueWidth*n;break;default:r.x1=e+this.trueWidth*(1-n)/2,r.x2=r.x1+this.trueWidth*n,r.y1=t+this.trueHeight*(1-n)/2,r.y2=r.y1+this.trueHeight*n}return r},getCropAxis:function(){var e={x1:0,x2:0,y1:0,y2:0};return e.x1=this.cropOffsertX,e.x2=e.x1+this.cropW,e.y1=this.cropOffsertY,e.y2=e.y1+this.cropH,e},leaveCrop:function(e){window.removeEventListener("mousemove",this.moveCrop),window.removeEventListener("mouseup",this.leaveCrop),window.removeEventListener("touchmove",this.moveCrop),window.removeEventListener("touchend",this.leaveCrop),this.$emit("cropMoving",{moving:!1,axis:this.getCropAxis()}),this.$emit("crop-moving",{moving:!1,axis:this.getCropAxis()})},getCropChecked:function(e){var t=this,n=document.createElement("canvas"),r=new Image,i=this.rotate,o=this.trueWidth,a=this.trueHeight,s=this.cropOffsertX,c=this.cropOffsertY;function l(e,t){n.width=Math.round(e),n.height=Math.round(t)}r.onload=function(){if(0!==t.cropW){var u=n.getContext("2d"),d=1;t.high&!t.full&&(d=window.devicePixelRatio),1!==t.enlarge&!t.full&&(d=Math.abs(Number(t.enlarge)),console.log(d));var h=t.cropW*d,f=t.cropH*d,p=o*t.scale*d,m=a*t.scale*d,v=(t.x-s+t.trueWidth*(1-t.scale)/2)*d,g=(t.y-c+t.trueHeight*(1-t.scale)/2)*d;switch(l(h,f),u.save(),i){case 0:t.full?(l(h/t.scale,f/t.scale),u.drawImage(r,v/t.scale,g/t.scale,p/t.scale,m/t.scale)):u.drawImage(r,v,g,p,m);break;case 1:case-3:t.full?(l(h/t.scale,f/t.scale),v=v/t.scale+(p/t.scale-m/t.scale)/2,g=g/t.scale+(m/t.scale-p/t.scale)/2,u.rotate(90*i*Math.PI/180),u.drawImage(r,g,-v-m/t.scale,p/t.scale,m/t.scale)):(v+=(p-m)/2,g+=(m-p)/2,u.rotate(90*i*Math.PI/180),u.drawImage(r,g,-v-m,p,m));break;case 2:case-2:t.full?(l(h/t.scale,f/t.scale),u.rotate(90*i*Math.PI/180),v/=t.scale,g/=t.scale,u.drawImage(r,-v-p/t.scale,-g-m/t.scale,p/t.scale,m/t.scale)):(u.rotate(90*i*Math.PI/180),u.drawImage(r,-v-p,-g-m,p,m));break;case 3:case-1:t.full?(l(h/t.scale,f/t.scale),v=v/t.scale+(p/t.scale-m/t.scale)/2,g=g/t.scale+(m/t.scale-p/t.scale)/2,u.rotate(90*i*Math.PI/180),u.drawImage(r,-g-p/t.scale,v,p/t.scale,m/t.scale)):(v+=(p-m)/2,g+=(m-p)/2,u.rotate(90*i*Math.PI/180),u.drawImage(r,-g-p,v,p,m));break;default:t.full?(l(h/t.scale,f/t.scale),u.drawImage(r,v/t.scale,g/t.scale,p/t.scale,m/t.scale)):u.drawImage(r,v,g,p,m)}u.restore()}else{var y=o*t.scale,_=a*t.scale,b=n.getContext("2d");switch(b.save(),i){case 0:l(y,_),b.drawImage(r,0,0,y,_);break;case 1:case-3:l(_,y),b.rotate(90*i*Math.PI/180),b.drawImage(r,0,-_,y,_);break;case 2:case-2:l(y,_),b.rotate(90*i*Math.PI/180),b.drawImage(r,-y,-_,y,_);break;case 3:case-1:l(_,y),b.rotate(90*i*Math.PI/180),b.drawImage(r,-y,0,y,_);break;default:l(y,_),b.drawImage(r,0,0,y,_)}b.restore()}e(n)},"data"!==this.img.substr(0,4)&&(r.crossOrigin="Anonymous"),r.src=this.imgs},getCropData:function(e){var t=this;this.getCropChecked(function(n){e(n.toDataURL("image/"+t.outputType,t.outputSize))})},getCropBlob:function(e){var t=this;this.getCropChecked(function(n){n.toBlob(function(t){return e(t)},"image/"+t.outputType,t.outputSize)})},showPreview:function(){var e=this;if(!this.isCanShow)return!1;this.isCanShow=!1,setTimeout(function(){e.isCanShow=!0},16);var t=this.cropW,n=this.cropH,r=this.scale,i={};i.div={width:"".concat(t,"px"),height:"".concat(n,"px")};var o=(this.x-this.cropOffsertX)/r,a=(this.y-this.cropOffsertY)/r;i.w=t,i.h=n,i.url=this.imgs,i.img={width:"".concat(this.trueWidth,"px"),height:"".concat(this.trueHeight,"px"),transform:"scale(".concat(r,")translate3d(").concat(o,"px, ").concat(a,"px, ").concat(0,"px)rotateZ(").concat(90*this.rotate,"deg)")},i.html='\n
\n
\n \n
\n
'),this.$emit("realTime",i),this.$emit("real-time",i)},reload:function(){var e=this,t=new Image;t.onload=function(){e.w=parseFloat(window.getComputedStyle(e.$refs.cropper).width),e.h=parseFloat(window.getComputedStyle(e.$refs.cropper).height),e.trueWidth=t.width,e.trueHeight=t.height,e.original?e.scale=1:e.scale=e.checkedMode(),e.$nextTick(function(){e.x=-(e.trueWidth-e.trueWidth*e.scale)/2+(e.w-e.trueWidth*e.scale)/2,e.y=-(e.trueHeight-e.trueHeight*e.scale)/2+(e.h-e.trueHeight*e.scale)/2,e.loading=!1,e.autoCrop&&e.goAutoCrop(),e.$emit("img-load","success"),e.$emit("imgLoad","success"),setTimeout(function(){e.showPreview()},20)})},t.onerror=function(){e.$emit("imgLoad","error"),e.$emit("img-load","error")},t.src=this.imgs},checkedMode:function(){var e=1,t=(this.trueWidth,this.trueHeight),n=this.mode.split(" ");switch(n[0]){case"contain":this.trueWidth>this.w&&(e=this.w/this.trueWidth),this.trueHeight*e>this.h&&(e=this.h/this.trueHeight);break;case"cover":(t*=e=this.w/this.trueWidth)n?n:a,s=s>r?r:s,this.fixed&&(s=a/this.fixedNumber[0]*this.fixedNumber[1]),s>this.h&&(a=(s=this.h)/this.fixedNumber[1]*this.fixedNumber[0]),this.changeCrop(a,s)},changeCrop:function(e,t){var n=this;if(this.centerBox){var r=this.getImgAxis();e>r.x2-r.x1&&(t=(e=r.x2-r.x1)/this.fixedNumber[0]*this.fixedNumber[1]),t>r.y2-r.y1&&(e=(t=r.y2-r.y1)/this.fixedNumber[1]*this.fixedNumber[0])}this.cropW=e,this.cropH=t,this.cropOffsertX=(this.w-e)/2,this.cropOffsertY=(this.h-t)/2,this.centerBox&&this.$nextTick(function(){n.moveCrop(null,!0)})},refresh:function(){var e=this;this.img,this.imgs="",this.scale=1,this.crop=!1,this.rotate=0,this.w=0,this.h=0,this.trueWidth=0,this.trueHeight=0,this.clearCrop(),this.$nextTick(function(){e.checkedImg()})},rotateLeft:function(){this.rotate=this.rotate<=-3?0:this.rotate-1},rotateRight:function(){this.rotate=this.rotate>=3?0:this.rotate+1},rotateClear:function(){this.rotate=0},checkoutImgAxis:function(e,t,n){e=e||this.x,t=t||this.y,n=n||this.scale;var r=!0;if(this.centerBox){var i=this.getImgAxis(e,t,n),o=this.getCropAxis();i.x1>=o.x1&&(r=!1),i.x2<=o.x2&&(r=!1),i.y1>=o.y1&&(r=!1),i.y2<=o.y2&&(r=!1)}return r}},mounted:function(){this.support="onwheel"in document.createElement("div")?"wheel":void 0!==document.onmousewheel?"mousewheel":"DOMMouseScroll";var e=this,t=navigator.userAgent;this.isIOS=!!t.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/),HTMLCanvasElement.prototype.toBlob||Object.defineProperty(HTMLCanvasElement.prototype,"toBlob",{value:function(t,n,r){for(var i=atob(this.toDataURL(n,r).split(",")[1]),o=i.length,a=new Uint8Array(o),s=0;s/g,">").replace(/"/g,""").replace(/'/g,"'")}function k(e){return null!=e&&Object.keys(e).forEach(function(t){"string"==typeof e[t]&&(e[t]=x(e[t]))}),e}function L(e){e.prototype.hasOwnProperty("$i18n")||Object.defineProperty(e.prototype,"$i18n",{get:function(){return this._i18n}}),e.prototype.$t=function(e){var t=[],n=arguments.length-1;while(n-- >0)t[n]=arguments[n+1];var r=this.$i18n;return r._t.apply(r,[e,r.locale,r._getMessages(),this].concat(t))},e.prototype.$tc=function(e,t){var n=[],r=arguments.length-2;while(r-- >0)n[r]=arguments[r+2];var i=this.$i18n;return i._tc.apply(i,[e,i.locale,i._getMessages(),this,t].concat(n))},e.prototype.$te=function(e,t){var n=this.$i18n;return n._te(e,n.locale,n._getMessages(),t)},e.prototype.$d=function(e){var t,n=[],r=arguments.length-1;while(r-- >0)n[r]=arguments[r+1];return(t=this.$i18n).d.apply(t,[e].concat(n))},e.prototype.$n=function(e){var t,n=[],r=arguments.length-1;while(r-- >0)n[r]=arguments[r+1];return(t=this.$i18n).n.apply(t,[e].concat(n))}}function C(e){function t(){this!==this.$root&&this.$options.__INTLIFY_META__&&this.$el&&this.$el.setAttribute("data-intlify",this.$options.__INTLIFY_META__)}return void 0===e&&(e=!1),e?{mounted:t}:{beforeCreate:function(){var e=this.$options;if(e.i18n=e.i18n||(e.__i18nBridge||e.__i18n?{}:null),e.i18n)if(e.i18n instanceof ke){if(e.__i18nBridge||e.__i18n)try{var t=e.i18n&&e.i18n.messages?e.i18n.messages:{},n=e.__i18nBridge||e.__i18n;n.forEach(function(e){t=A(t,JSON.parse(e))}),Object.keys(t).forEach(function(n){e.i18n.mergeLocaleMessage(n,t[n])})}catch(c){0}this._i18n=e.i18n,this._i18nWatcher=this._i18n.watchI18nData()}else if(h(e.i18n)){var r=this.$root&&this.$root.$i18n&&this.$root.$i18n instanceof ke?this.$root.$i18n:null;if(r&&(e.i18n.root=this.$root,e.i18n.formatter=r.formatter,e.i18n.fallbackLocale=r.fallbackLocale,e.i18n.formatFallbackMessages=r.formatFallbackMessages,e.i18n.silentTranslationWarn=r.silentTranslationWarn,e.i18n.silentFallbackWarn=r.silentFallbackWarn,e.i18n.pluralizationRules=r.pluralizationRules,e.i18n.preserveDirectiveContent=r.preserveDirectiveContent),e.__i18nBridge||e.__i18n)try{var i=e.i18n&&e.i18n.messages?e.i18n.messages:{},o=e.__i18nBridge||e.__i18n;o.forEach(function(e){i=A(i,JSON.parse(e))}),e.i18n.messages=i}catch(c){0}var a=e.i18n,s=a.sharedMessages;s&&h(s)&&(e.i18n.messages=A(e.i18n.messages,s)),this._i18n=new ke(e.i18n),this._i18nWatcher=this._i18n.watchI18nData(),(void 0===e.i18n.sync||e.i18n.sync)&&(this._localeWatcher=this.$i18n.watchLocale()),r&&r.onComponentInstanceCreated(this._i18n)}else 0;else this.$root&&this.$root.$i18n&&this.$root.$i18n instanceof ke?this._i18n=this.$root.$i18n:e.parent&&e.parent.$i18n&&e.parent.$i18n instanceof ke&&(this._i18n=e.parent.$i18n)},beforeMount:function(){var e=this.$options;e.i18n=e.i18n||(e.__i18nBridge||e.__i18n?{}:null),e.i18n?(e.i18n instanceof ke||h(e.i18n))&&(this._i18n.subscribeDataChanging(this),this._subscribing=!0):(this.$root&&this.$root.$i18n&&this.$root.$i18n instanceof ke||e.parent&&e.parent.$i18n&&e.parent.$i18n instanceof ke)&&(this._i18n.subscribeDataChanging(this),this._subscribing=!0)},mounted:t,beforeDestroy:function(){if(this._i18n){var e=this;this.$nextTick(function(){e._subscribing&&(e._i18n.unsubscribeDataChanging(e),delete e._subscribing),e._i18nWatcher&&(e._i18nWatcher(),e._i18n.destroyVM(),delete e._i18nWatcher),e._localeWatcher&&(e._localeWatcher(),delete e._localeWatcher)})}}}}var S={name:"i18n",functional:!0,props:{tag:{type:[String,Boolean,Object],default:"span"},path:{type:String,required:!0},locale:{type:String},places:{type:[Array,Object]}},render:function(e,t){var n=t.data,r=t.parent,i=t.props,o=t.slots,a=r.$i18n;if(a){var s=i.path,c=i.locale,l=i.places,u=o(),d=a.i(s,c,z(u)||l?T(u.default,l):u),h=i.tag&&!0!==i.tag||!1===i.tag?i.tag:"span";return h?e(h,n,d):d}}};function z(e){var t;for(t in e)if("default"!==t)return!1;return Boolean(t)}function T(e,t){var n=t?O(t):{};if(!e)return n;e=e.filter(function(e){return e.tag||""!==e.text.trim()});var r=e.every(Y);return e.reduce(r?H:D,n)}function O(e){return Array.isArray(e)?e.reduce(D,{}):Object.assign({},e)}function H(e,t){return t.data&&t.data.attrs&&t.data.attrs.place&&(e[t.data.attrs.place]=t),e}function D(e,t,n){return e[n]=t,e}function Y(e){return Boolean(e.data&&e.data.attrs&&e.data.attrs.place)}var V,P={name:"i18n-n",functional:!0,props:{tag:{type:[String,Boolean,Object],default:"span"},value:{type:Number,required:!0},format:{type:[String,Object]},locale:{type:String}},render:function(e,t){var r=t.props,i=t.parent,o=t.data,a=i.$i18n;if(!a)return null;var c=null,u=null;l(r.format)?c=r.format:s(r.format)&&(r.format.key&&(c=r.format.key),u=Object.keys(r.format).reduce(function(e,t){var i;return _(n,t)?Object.assign({},e,(i={},i[t]=r.format[t],i)):e},null));var d=r.locale||a.locale,h=a._ntp(r.value,d,c,u),f=h.map(function(e,t){var n,r=o.scopedSlots&&o.scopedSlots[e.type];return r?r((n={},n[e.type]=e.value,n.index=t,n.parts=h,n)):e.value}),p=r.tag&&!0!==r.tag||!1===r.tag?r.tag:"span";return p?e(p,{attrs:o.attrs,class:o["class"],staticClass:o.staticClass},f):f}};function E(e,t,n){I(e,n)&&R(e,t,n)}function j(e,t,n,r){if(I(e,n)){var i=n.context.$i18n;$(e,n)&&w(t.value,t.oldValue)&&w(e._localeMessage,i.getLocaleMessage(i.locale))||R(e,t,n)}}function F(e,t,n,r){var o=n.context;if(o){var a=n.context.$i18n||{};t.modifiers.preserve||a.preserveDirectiveContent||(e.textContent=""),e._vt=void 0,delete e["_vt"],e._locale=void 0,delete e["_locale"],e._localeMessage=void 0,delete e["_localeMessage"]}else i("Vue instance does not exists in VNode context")}function I(e,t){var n=t.context;return n?!!n.$i18n||(i("VueI18n instance does not exists in Vue instance"),!1):(i("Vue instance does not exists in VNode context"),!1)}function $(e,t){var n=t.context;return e._locale===n.$i18n.locale}function R(e,t,n){var r,o,a=t.value,s=N(a),c=s.path,l=s.locale,u=s.args,d=s.choice;if(c||l||u)if(c){var h=n.context;e._vt=e.textContent=null!=d?(r=h.$i18n).tc.apply(r,[c,d].concat(W(l,u))):(o=h.$i18n).t.apply(o,[c].concat(W(l,u))),e._locale=h.$i18n.locale,e._localeMessage=h.$i18n.getLocaleMessage(h.$i18n.locale)}else i("`path` is required in v-t directive");else i("value type not supported")}function N(e){var t,n,r,i;return l(e)?t=e:h(e)&&(t=e.path,n=e.locale,r=e.args,i=e.choice),{path:t,locale:n,args:r,choice:i}}function W(e,t){var n=[];return e&&n.push(e),t&&(Array.isArray(t)||h(t))&&n.push(t),n}function B(e,t){void 0===t&&(t={bridge:!1}),B.installed=!0,V=e;V.version&&Number(V.version.split(".")[0]);L(V),V.mixin(C(t.bridge)),V.directive("t",{bind:E,update:j,unbind:F}),V.component(S.name,S),V.component(P.name,P);var n=V.config.optionMergeStrategies;n.i18n=function(e,t){return void 0===t?e:t}}var K=function(){this._caches=Object.create(null)};K.prototype.interpolate=function(e,t){if(!t)return[e];var n=this._caches[e];return n||(n=G(e),this._caches[e]=n),J(n,t)};var U=/^(?:\d)+/,q=/^(?:\w)+/;function G(e){var t=[],n=0,r="";while(n0)d--,u=oe,h[X]();else{if(d=0,void 0===n)return!1;if(n=me(n),!1===n)return!1;h[Z]()}};while(null!==u)if(l++,t=e[l],"\\"!==t||!f()){if(i=pe(t),s=ue[u],o=s[i]||s["else"]||le,o===le)return;if(u=o[0],a=h[o[1]],a&&(r=o[2],r=void 0===r?t:r,!1===a()))return;if(u===ce)return c}}var ge=function(){this._cache=Object.create(null)};ge.prototype.parsePath=function(e){var t=this._cache[e];return t||(t=ve(e),t&&(this._cache[e]=t)),t||[]},ge.prototype.getPathValue=function(e,t){if(!s(e))return null;var n=this.parsePath(t);if(0===n.length)return null;var r=n.length,i=e,o=0;while(o/,be=/(?:@(?:\.[a-zA-Z]+)?:(?:[\w\-_|./]+|\([\w\-_:|./]+\)))/g,Me=/^@(?:\.([a-zA-Z]+))?:/,Ae=/[()]/g,we={upper:function(e){return e.toLocaleUpperCase()},lower:function(e){return e.toLocaleLowerCase()},capitalize:function(e){return""+e.charAt(0).toLocaleUpperCase()+e.substr(1)}},xe=new K,ke=function(e){var t=this;void 0===e&&(e={}),!V&&"undefined"!==typeof window&&window.Vue&&B(window.Vue);var n=e.locale||"en-US",r=!1!==e.fallbackLocale&&(e.fallbackLocale||"en-US"),i=e.messages||{},o=e.dateTimeFormats||e.datetimeFormats||{},a=e.numberFormats||{};this._vm=null,this._formatter=e.formatter||xe,this._modifiers=e.modifiers||{},this._missing=e.missing||null,this._root=e.root||null,this._sync=void 0===e.sync||!!e.sync,this._fallbackRoot=void 0===e.fallbackRoot||!!e.fallbackRoot,this._fallbackRootWithEmptyString=void 0===e.fallbackRootWithEmptyString||!!e.fallbackRootWithEmptyString,this._formatFallbackMessages=void 0!==e.formatFallbackMessages&&!!e.formatFallbackMessages,this._silentTranslationWarn=void 0!==e.silentTranslationWarn&&e.silentTranslationWarn,this._silentFallbackWarn=void 0!==e.silentFallbackWarn&&!!e.silentFallbackWarn,this._dateTimeFormatters={},this._numberFormatters={},this._path=new ge,this._dataListeners=new Set,this._componentInstanceCreatedListener=e.componentInstanceCreatedListener||null,this._preserveDirectiveContent=void 0!==e.preserveDirectiveContent&&!!e.preserveDirectiveContent,this.pluralizationRules=e.pluralizationRules||{},this._warnHtmlInMessage=e.warnHtmlInMessage||"off",this._postTranslation=e.postTranslation||null,this._escapeParameterHtml=e.escapeParameterHtml||!1,"__VUE_I18N_BRIDGE__"in e&&(this.__VUE_I18N_BRIDGE__=e.__VUE_I18N_BRIDGE__),this.getChoiceIndex=function(e,n){var r=Object.getPrototypeOf(t);if(r&&r.getChoiceIndex){var i=r.getChoiceIndex;return i.call(t,e,n)}var o=function(e,t){return e=Math.abs(e),2===t?e?e>1?1:0:1:e?Math.min(e,2):0};return t.locale in t.pluralizationRules?t.pluralizationRules[t.locale].apply(t,[e,n]):o(e,n)},this._exist=function(e,n){return!(!e||!n)&&(!f(t._path.getPathValue(e,n))||!!e[n])},"warn"!==this._warnHtmlInMessage&&"error"!==this._warnHtmlInMessage||Object.keys(i).forEach(function(e){t._checkLocaleMessage(e,t._warnHtmlInMessage,i[e])}),this._initVM({locale:n,fallbackLocale:r,messages:i,dateTimeFormats:o,numberFormats:a})},Le={vm:{configurable:!0},messages:{configurable:!0},dateTimeFormats:{configurable:!0},numberFormats:{configurable:!0},availableLocales:{configurable:!0},locale:{configurable:!0},fallbackLocale:{configurable:!0},formatFallbackMessages:{configurable:!0},missing:{configurable:!0},formatter:{configurable:!0},silentTranslationWarn:{configurable:!0},silentFallbackWarn:{configurable:!0},preserveDirectiveContent:{configurable:!0},warnHtmlInMessage:{configurable:!0},postTranslation:{configurable:!0},sync:{configurable:!0}};ke.prototype._checkLocaleMessage=function(e,t,n){var r=[],s=function(e,t,n,r){if(h(n))Object.keys(n).forEach(function(i){var o=n[i];h(o)?(r.push(i),r.push("."),s(e,t,o,r),r.pop(),r.pop()):(r.push(i),s(e,t,o,r),r.pop())});else if(a(n))n.forEach(function(n,i){h(n)?(r.push("["+i+"]"),r.push("."),s(e,t,n,r),r.pop(),r.pop()):(r.push("["+i+"]"),s(e,t,n,r),r.pop())});else if(l(n)){var c=_e.test(n);if(c){var u="Detected HTML in message '"+n+"' of keypath '"+r.join("")+"' at '"+t+"'. Consider component interpolation with '' to avoid XSS. See https://bit.ly/2ZqJzkp";"warn"===e?i(u):"error"===e&&o(u)}}};s(t,e,n,r)},ke.prototype._initVM=function(e){var t=V.config.silent;V.config.silent=!0,this._vm=new V({data:e,__VUE18N__INSTANCE__:!0}),V.config.silent=t},ke.prototype.destroyVM=function(){this._vm.$destroy()},ke.prototype.subscribeDataChanging=function(e){this._dataListeners.add(e)},ke.prototype.unsubscribeDataChanging=function(e){g(this._dataListeners,e)},ke.prototype.watchI18nData=function(){var e=this;return this._vm.$watch("$data",function(){var t=y(e._dataListeners),n=t.length;while(n--)V.nextTick(function(){t[n]&&t[n].$forceUpdate()})},{deep:!0})},ke.prototype.watchLocale=function(e){if(e){if(!this.__VUE_I18N_BRIDGE__)return null;var t=this,n=this._vm;return this.vm.$watch("locale",function(r){n.$set(n,"locale",r),t.__VUE_I18N_BRIDGE__&&e&&(e.locale.value=r),n.$forceUpdate()},{immediate:!0})}if(!this._sync||!this._root)return null;var r=this._vm;return this._root.$i18n.vm.$watch("locale",function(e){r.$set(r,"locale",e),r.$forceUpdate()},{immediate:!0})},ke.prototype.onComponentInstanceCreated=function(e){this._componentInstanceCreatedListener&&this._componentInstanceCreatedListener(e,this)},Le.vm.get=function(){return this._vm},Le.messages.get=function(){return v(this._getMessages())},Le.dateTimeFormats.get=function(){return v(this._getDateTimeFormats())},Le.numberFormats.get=function(){return v(this._getNumberFormats())},Le.availableLocales.get=function(){return Object.keys(this.messages).sort()},Le.locale.get=function(){return this._vm.locale},Le.locale.set=function(e){this._vm.$set(this._vm,"locale",e)},Le.fallbackLocale.get=function(){return this._vm.fallbackLocale},Le.fallbackLocale.set=function(e){this._localeChainCache={},this._vm.$set(this._vm,"fallbackLocale",e)},Le.formatFallbackMessages.get=function(){return this._formatFallbackMessages},Le.formatFallbackMessages.set=function(e){this._formatFallbackMessages=e},Le.missing.get=function(){return this._missing},Le.missing.set=function(e){this._missing=e},Le.formatter.get=function(){return this._formatter},Le.formatter.set=function(e){this._formatter=e},Le.silentTranslationWarn.get=function(){return this._silentTranslationWarn},Le.silentTranslationWarn.set=function(e){this._silentTranslationWarn=e},Le.silentFallbackWarn.get=function(){return this._silentFallbackWarn},Le.silentFallbackWarn.set=function(e){this._silentFallbackWarn=e},Le.preserveDirectiveContent.get=function(){return this._preserveDirectiveContent},Le.preserveDirectiveContent.set=function(e){this._preserveDirectiveContent=e},Le.warnHtmlInMessage.get=function(){return this._warnHtmlInMessage},Le.warnHtmlInMessage.set=function(e){var t=this,n=this._warnHtmlInMessage;if(this._warnHtmlInMessage=e,n!==e&&("warn"===e||"error"===e)){var r=this._getMessages();Object.keys(r).forEach(function(e){t._checkLocaleMessage(e,t._warnHtmlInMessage,r[e])})}},Le.postTranslation.get=function(){return this._postTranslation},Le.postTranslation.set=function(e){this._postTranslation=e},Le.sync.get=function(){return this._sync},Le.sync.set=function(e){this._sync=e},ke.prototype._getMessages=function(){return this._vm.messages},ke.prototype._getDateTimeFormats=function(){return this._vm.dateTimeFormats},ke.prototype._getNumberFormats=function(){return this._vm.numberFormats},ke.prototype._warnDefault=function(e,t,n,r,i,o){if(!f(n))return n;if(this._missing){var a=this._missing.apply(null,[e,t,r,i]);if(l(a))return a}else 0;if(this._formatFallbackMessages){var s=m.apply(void 0,i);return this._render(t,o,s.params,t)}return t},ke.prototype._isFallbackRoot=function(e){return(this._fallbackRootWithEmptyString?!e:f(e))&&!f(this._root)&&this._fallbackRoot},ke.prototype._isSilentFallbackWarn=function(e){return this._silentFallbackWarn instanceof RegExp?this._silentFallbackWarn.test(e):this._silentFallbackWarn},ke.prototype._isSilentFallback=function(e,t){return this._isSilentFallbackWarn(t)&&(this._isFallbackRoot()||e!==this.fallbackLocale)},ke.prototype._isSilentTranslationWarn=function(e){return this._silentTranslationWarn instanceof RegExp?this._silentTranslationWarn.test(e):this._silentTranslationWarn},ke.prototype._interpolate=function(e,t,n,r,i,o,s){if(!t)return null;var c,u=this._path.getPathValue(t,n);if(a(u)||h(u))return u;if(f(u)){if(!h(t))return null;if(c=t[n],!l(c)&&!p(c))return null}else{if(!l(u)&&!p(u))return null;c=u}return l(c)&&(c.indexOf("@:")>=0||c.indexOf("@.")>=0)&&(c=this._link(e,t,c,r,"raw",o,s)),this._render(c,i,o,n)},ke.prototype._link=function(e,t,n,r,i,o,s){var c=n,l=c.match(be);for(var u in l)if(l.hasOwnProperty(u)){var d=l[u],h=d.match(Me),f=h[0],p=h[1],m=d.replace(f,"").replace(Ae,"");if(_(s,m))return c;s.push(m);var v=this._interpolate(e,t,m,r,"raw"===i?"string":i,"raw"===i?void 0:o,s);if(this._isFallbackRoot(v)){if(!this._root)throw Error("unexpected error");var g=this._root.$i18n;v=g._translate(g._getMessages(),g.locale,g.fallbackLocale,m,r,i,o)}v=this._warnDefault(e,m,v,r,a(o)?o:[o],i),this._modifiers.hasOwnProperty(p)?v=this._modifiers[p](v):we.hasOwnProperty(p)&&(v=we[p](v)),s.pop(),c=v?c.replace(d,v):c}return c},ke.prototype._createMessageContext=function(e,t,n,r){var i=this,o=a(e)?e:[],c=s(e)?e:{},l=function(e){return o[e]},u=function(e){return c[e]},d=this._getMessages(),h=this.locale;return{list:l,named:u,values:e,formatter:t,path:n,messages:d,locale:h,linked:function(e){return i._interpolate(h,d[h]||{},e,null,r,void 0,[e])}}},ke.prototype._render=function(e,t,n,r){if(p(e))return e(this._createMessageContext(n,this._formatter||xe,r,t));var i=this._formatter.interpolate(e,n,r);return i||(i=xe.interpolate(e,n,r)),"string"!==t||l(i)?i:i.join("")},ke.prototype._appendItemToChain=function(e,t,n){var r=!1;return _(e,t)||(r=!0,t&&(r="!"!==t[t.length-1],t=t.replace(/!/g,""),e.push(t),n&&n[t]&&(r=n[t]))),r},ke.prototype._appendLocaleToChain=function(e,t,n){var r,i=t.split("-");do{var o=i.join("-");r=this._appendItemToChain(e,o,n),i.splice(-1,1)}while(i.length&&!0===r);return r},ke.prototype._appendBlockToChain=function(e,t,n){for(var r=!0,i=0;i0)o[a]=arguments[a+4];if(!e)return"";var s=m.apply(void 0,o);this._escapeParameterHtml&&(s.params=k(s.params));var c=s.locale||t,l=this._translate(n,c,this.fallbackLocale,e,r,"string",s.params);if(this._isFallbackRoot(l)){if(!this._root)throw Error("unexpected error");return(i=this._root).$t.apply(i,[e].concat(o))}return l=this._warnDefault(c,e,l,r,o,"string"),this._postTranslation&&null!==l&&void 0!==l&&(l=this._postTranslation(l,e)),l},ke.prototype.t=function(e){var t,n=[],r=arguments.length-1;while(r-- >0)n[r]=arguments[r+1];return(t=this)._t.apply(t,[e,this.locale,this._getMessages(),null].concat(n))},ke.prototype._i=function(e,t,n,r,i){var o=this._translate(n,t,this.fallbackLocale,e,r,"raw",i);if(this._isFallbackRoot(o)){if(!this._root)throw Error("unexpected error");return this._root.$i18n.i(e,t,i)}return this._warnDefault(t,e,o,r,[i],"raw")},ke.prototype.i=function(e,t,n){return e?(l(t)||(t=this.locale),this._i(e,t,this._getMessages(),null,n)):""},ke.prototype._tc=function(e,t,n,r,i){var o,a=[],s=arguments.length-5;while(s-- >0)a[s]=arguments[s+5];if(!e)return"";void 0===i&&(i=1);var c={count:i,n:i},l=m.apply(void 0,a);return l.params=Object.assign(c,l.params),a=null===l.locale?[l.params]:[l.locale,l.params],this.fetchChoice((o=this)._t.apply(o,[e,t,n,r].concat(a)),i)},ke.prototype.fetchChoice=function(e,t){if(!e||!l(e))return null;var n=e.split("|");return t=this.getChoiceIndex(t,n.length),n[t]?n[t].trim():e},ke.prototype.tc=function(e,t){var n,r=[],i=arguments.length-2;while(i-- >0)r[i]=arguments[i+2];return(n=this)._tc.apply(n,[e,this.locale,this._getMessages(),null,t].concat(r))},ke.prototype._te=function(e,t,n){var r=[],i=arguments.length-3;while(i-- >0)r[i]=arguments[i+3];var o=m.apply(void 0,r).locale||t;return this._exist(n[o],e)},ke.prototype.te=function(e,t){return this._te(e,this.locale,this._getMessages(),t)},ke.prototype.getLocaleMessage=function(e){return v(this._vm.messages[e]||{})},ke.prototype.setLocaleMessage=function(e,t){"warn"!==this._warnHtmlInMessage&&"error"!==this._warnHtmlInMessage||this._checkLocaleMessage(e,this._warnHtmlInMessage,t),this._vm.$set(this._vm.messages,e,t)},ke.prototype.mergeLocaleMessage=function(e,t){"warn"!==this._warnHtmlInMessage&&"error"!==this._warnHtmlInMessage||this._checkLocaleMessage(e,this._warnHtmlInMessage,t),this._vm.$set(this._vm.messages,e,A("undefined"!==typeof this._vm.messages[e]&&Object.keys(this._vm.messages[e]).length?Object.assign({},this._vm.messages[e]):{},t))},ke.prototype.getDateTimeFormat=function(e){return v(this._vm.dateTimeFormats[e]||{})},ke.prototype.setDateTimeFormat=function(e,t){this._vm.$set(this._vm.dateTimeFormats,e,t),this._clearDateTimeFormat(e,t)},ke.prototype.mergeDateTimeFormat=function(e,t){this._vm.$set(this._vm.dateTimeFormats,e,A(this._vm.dateTimeFormats[e]||{},t)),this._clearDateTimeFormat(e,t)},ke.prototype._clearDateTimeFormat=function(e,t){for(var n in t){var r=e+"__"+n;this._dateTimeFormatters.hasOwnProperty(r)&&delete this._dateTimeFormatters[r]}},ke.prototype._localizeDateTime=function(e,t,n,r,i,o){for(var a=t,s=r[a],c=this._getLocaleChain(t,n),l=0;l0)t[n]=arguments[n+1];var i=this.locale,o=null,a=null;return 1===t.length?(l(t[0])?o=t[0]:s(t[0])&&(t[0].locale&&(i=t[0].locale),t[0].key&&(o=t[0].key)),a=Object.keys(t[0]).reduce(function(e,n){var i;return _(r,n)?Object.assign({},e,(i={},i[n]=t[0][n],i)):e},null)):2===t.length&&(l(t[0])&&(o=t[0]),l(t[1])&&(i=t[1])),this._d(e,i,o,a)},ke.prototype.getNumberFormat=function(e){return v(this._vm.numberFormats[e]||{})},ke.prototype.setNumberFormat=function(e,t){this._vm.$set(this._vm.numberFormats,e,t),this._clearNumberFormat(e,t)},ke.prototype.mergeNumberFormat=function(e,t){this._vm.$set(this._vm.numberFormats,e,A(this._vm.numberFormats[e]||{},t)),this._clearNumberFormat(e,t)},ke.prototype._clearNumberFormat=function(e,t){for(var n in t){var r=e+"__"+n;this._numberFormatters.hasOwnProperty(r)&&delete this._numberFormatters[r]}},ke.prototype._getNumberFormatter=function(e,t,n,r,i,o){for(var a=t,s=r[a],c=this._getLocaleChain(t,n),l=0;l0)t[r]=arguments[r+1];var i=this.locale,o=null,a=null;return 1===t.length?l(t[0])?o=t[0]:s(t[0])&&(t[0].locale&&(i=t[0].locale),t[0].key&&(o=t[0].key),a=Object.keys(t[0]).reduce(function(e,r){var i;return _(n,r)?Object.assign({},e,(i={},i[r]=t[0][r],i)):e},null)):2===t.length&&(l(t[0])&&(o=t[0]),l(t[1])&&(i=t[1])),this._n(e,i,o,a)},ke.prototype._ntp=function(e,t,n,r){if(!ke.availabilities.numberFormat)return[];if(!n){var i=r?new Intl.NumberFormat(t,r):new Intl.NumberFormat(t);return i.formatToParts(e)}var o=this._getNumberFormatter(e,t,this.fallbackLocale,this._getNumberFormats(),n,r),a=o&&o.formatToParts(e);if(this._isFallbackRoot(a)){if(!this._root)throw Error("unexpected error");return this._root.$i18n._ntp(e,t,n,r)}return a||[]},Object.defineProperties(ke.prototype,Le),Object.defineProperty(ke,"availabilities",{get:function(){if(!ye){var e="undefined"!==typeof Intl;ye={dateTimeFormat:e&&"undefined"!==typeof Intl.DateTimeFormat,numberFormat:e&&"undefined"!==typeof Intl.NumberFormat}}return ye}}),ke.install=B,ke.version="8.28.2",t.A=ke},24415:function(e,t){"use strict";t.A={install:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.name||"ref";e.directive(n,{bind:function(t,n,r){e.nextTick(function(){n.value(r.componentInstance||t,r.key)}),n.value(r.componentInstance||t,r.key)},update:function(e,t,r,i){if(i.data&&i.data.directives){var o=i.data.directives.find(function(e){var t=e.name;return t===n});if(o&&o.value!==t.value)return o&&o.value(null,i.key),void t.value(r.componentInstance||e,r.key)}r.componentInstance===i.componentInstance&&r.elm===i.elm||t.value(r.componentInstance||e,r.key)},unbind:function(e,t,n){t.value(null,n.key)}})}}},40173:function(e,t,n){"use strict";function r(e,t){for(var n in t)e[n]=t[n];return e}n.d(t,{Ay:function(){return At}});var i=/[!'()*]/g,o=function(e){return"%"+e.charCodeAt(0).toString(16)},a=/%2C/g,s=function(e){return encodeURIComponent(e).replace(i,o).replace(a,",")};function c(e){try{return decodeURIComponent(e)}catch(t){0}return e}function l(e,t,n){void 0===t&&(t={});var r,i=n||d;try{r=i(e||"")}catch(s){r={}}for(var o in t){var a=t[o];r[o]=Array.isArray(a)?a.map(u):u(a)}return r}var u=function(e){return null==e||"object"===typeof e?e:String(e)};function d(e){var t={};return e=e.trim().replace(/^(\?|#|&)/,""),e?(e.split("&").forEach(function(e){var n=e.replace(/\+/g," ").split("="),r=c(n.shift()),i=n.length>0?c(n.join("=")):null;void 0===t[r]?t[r]=i:Array.isArray(t[r])?t[r].push(i):t[r]=[t[r],i]}),t):t}function h(e){var t=e?Object.keys(e).map(function(t){var n=e[t];if(void 0===n)return"";if(null===n)return s(t);if(Array.isArray(n)){var r=[];return n.forEach(function(e){void 0!==e&&(null===e?r.push(s(t)):r.push(s(t)+"="+s(e)))}),r.join("&")}return s(t)+"="+s(n)}).filter(function(e){return e.length>0}).join("&"):null;return t?"?"+t:""}var f=/\/?$/;function p(e,t,n,r){var i=r&&r.options.stringifyQuery,o=t.query||{};try{o=m(o)}catch(s){}var a={name:t.name||e&&e.name,meta:e&&e.meta||{},path:t.path||"/",hash:t.hash||"",query:o,params:t.params||{},fullPath:y(t,i),matched:e?g(e):[]};return n&&(a.redirectedFrom=y(n,i)),Object.freeze(a)}function m(e){if(Array.isArray(e))return e.map(m);if(e&&"object"===typeof e){var t={};for(var n in e)t[n]=m(e[n]);return t}return e}var v=p(null,{path:"/"});function g(e){var t=[];while(e)t.unshift(e),e=e.parent;return t}function y(e,t){var n=e.path,r=e.query;void 0===r&&(r={});var i=e.hash;void 0===i&&(i="");var o=t||h;return(n||"/")+o(r)+i}function _(e,t,n){return t===v?e===t:!!t&&(e.path&&t.path?e.path.replace(f,"")===t.path.replace(f,"")&&(n||e.hash===t.hash&&b(e.query,t.query)):!(!e.name||!t.name)&&(e.name===t.name&&(n||e.hash===t.hash&&b(e.query,t.query)&&b(e.params,t.params))))}function b(e,t){if(void 0===e&&(e={}),void 0===t&&(t={}),!e||!t)return e===t;var n=Object.keys(e).sort(),r=Object.keys(t).sort();return n.length===r.length&&n.every(function(n,i){var o=e[n],a=r[i];if(a!==n)return!1;var s=t[n];return null==o||null==s?o===s:"object"===typeof o&&"object"===typeof s?b(o,s):String(o)===String(s)})}function M(e,t){return 0===e.path.replace(f,"/").indexOf(t.path.replace(f,"/"))&&(!t.hash||e.hash===t.hash)&&A(e.query,t.query)}function A(e,t){for(var n in t)if(!(n in e))return!1;return!0}function w(e){for(var t=0;t=0&&(t=e.slice(r),e=e.slice(0,r));var i=e.indexOf("?");return i>=0&&(n=e.slice(i+1),e=e.slice(0,i)),{path:e,query:n,hash:t}}function z(e){return e.replace(/\/(?:\s*\/)+/g,"/")}var T=Array.isArray||function(e){return"[object Array]"==Object.prototype.toString.call(e)},O=J,H=E,D=j,Y=$,V=G,P=new RegExp(["(\\\\.)","([\\/.])?(?:(?:\\:(\\w+)(?:\\(((?:\\\\.|[^\\\\()])+)\\))?|\\(((?:\\\\.|[^\\\\()])+)\\))([+*?])?|(\\*))"].join("|"),"g");function E(e,t){var n,r=[],i=0,o=0,a="",s=t&&t.delimiter||"/";while(null!=(n=P.exec(e))){var c=n[0],l=n[1],u=n.index;if(a+=e.slice(o,u),o=u+c.length,l)a+=l[1];else{var d=e[o],h=n[2],f=n[3],p=n[4],m=n[5],v=n[6],g=n[7];a&&(r.push(a),a="");var y=null!=h&&null!=d&&d!==h,_="+"===v||"*"===v,b="?"===v||"*"===v,M=n[2]||s,A=p||m;r.push({name:f||i++,prefix:h||"",delimiter:M,optional:b,repeat:_,partial:y,asterisk:!!g,pattern:A?N(A):g?".*":"[^"+R(M)+"]+?"})}}return o1||!x.length)return 0===x.length?e():e("span",{},x)}if("a"===this.tag)w.on=A,w.attrs={href:c,"aria-current":y};else{var k=ae(this.$slots.default);if(k){k.isStatic=!1;var L=k.data=r({},k.data);for(var C in L.on=L.on||{},L.on){var S=L.on[C];C in A&&(L.on[C]=Array.isArray(S)?S:[S])}for(var z in A)z in L.on?L.on[z].push(A[z]):L.on[z]=b;var T=k.data.attrs=r({},k.data.attrs);T.href=c,T["aria-current"]=y}else w.on=A}return e(this.tag,w,this.$slots.default)}};function oe(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&(void 0===e.button||0===e.button)){if(e.currentTarget&&e.currentTarget.getAttribute){var t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function ae(e){if(e)for(var t,n=0;n-1&&(s.params[d]=n.params[d]);return s.path=Z(l.path,s.params,'named route "'+c+'"'),h(l,s,a)}if(s.path){s.params={};for(var f=0;f-1}function Ke(e,t){return Be(e)&&e._isRouter&&(null==t||e.type===t)}function Ue(e,t,n){var r=function(i){i>=e.length?n():e[i]?t(e[i],function(){r(i+1)}):r(i+1)};r(0)}function qe(e){return function(t,n,r){var i=!1,o=0,a=null;Ge(e,function(e,t,n,s){if("function"===typeof e&&void 0===e.cid){i=!0,o++;var c,l=Qe(function(t){Ze(t)&&(t=t.default),e.resolved="function"===typeof t?t:ee.extend(t),n.components[s]=t,o--,o<=0&&r()}),u=Qe(function(e){var t="Failed to resolve async component "+s+": "+e;a||(a=Be(e)?e:new Error(t),r(a))});try{c=e(l,u)}catch(h){u(h)}if(c)if("function"===typeof c.then)c.then(l,u);else{var d=c.component;d&&"function"===typeof d.then&&d.then(l,u)}}}),i||r()}}function Ge(e,t){return Je(e.map(function(e){return Object.keys(e.components).map(function(n){return t(e.components[n],e.instances[n],e,n)})}))}function Je(e){return Array.prototype.concat.apply([],e)}var Xe="function"===typeof Symbol&&"symbol"===typeof Symbol.toStringTag;function Ze(e){return e.__esModule||Xe&&"Module"===e[Symbol.toStringTag]}function Qe(e){var t=!1;return function(){var n=[],r=arguments.length;while(r--)n[r]=arguments[r];if(!t)return t=!0,e.apply(this,n)}}var et=function(e,t){this.router=e,this.base=tt(t),this.current=v,this.pending=null,this.ready=!1,this.readyCbs=[],this.readyErrorCbs=[],this.errorCbs=[],this.listeners=[]};function tt(e){if(!e)if(ce){var t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^https?:\/\/[^\/]+/,"")}else e="/";return"/"!==e.charAt(0)&&(e="/"+e),e.replace(/\/$/,"")}function nt(e,t){var n,r=Math.max(e.length,t.length);for(n=0;n0)){var t=this.router,n=t.options.scrollBehavior,r=Ye&&n;r&&this.listeners.push(Ae());var i=function(){var n=e.current,i=dt(e.base);e.current===v&&i===e._startLocation||e.transitionTo(i,function(e){r&&we(t,e,n,!0)})};window.addEventListener("popstate",i),this.listeners.push(function(){window.removeEventListener("popstate",i)})}},t.prototype.go=function(e){window.history.go(e)},t.prototype.push=function(e,t,n){var r=this,i=this,o=i.current;this.transitionTo(e,function(e){Ve(z(r.base+e.fullPath)),we(r.router,e,o,!1),t&&t(e)},n)},t.prototype.replace=function(e,t,n){var r=this,i=this,o=i.current;this.transitionTo(e,function(e){Pe(z(r.base+e.fullPath)),we(r.router,e,o,!1),t&&t(e)},n)},t.prototype.ensureURL=function(e){if(dt(this.base)!==this.current.fullPath){var t=z(this.base+this.current.fullPath);e?Ve(t):Pe(t)}},t.prototype.getCurrentLocation=function(){return dt(this.base)},t}(et);function dt(e){var t=window.location.pathname,n=t.toLowerCase(),r=e.toLowerCase();return!e||n!==r&&0!==n.indexOf(z(r+"/"))||(t=t.slice(e.length)),(t||"/")+window.location.search+window.location.hash}var ht=function(e){function t(t,n,r){e.call(this,t,n),r&&ft(this.base)||pt()}return e&&(t.__proto__=e),t.prototype=Object.create(e&&e.prototype),t.prototype.constructor=t,t.prototype.setupListeners=function(){var e=this;if(!(this.listeners.length>0)){var t=this.router,n=t.options.scrollBehavior,r=Ye&&n;r&&this.listeners.push(Ae());var i=function(){var t=e.current;pt()&&e.transitionTo(mt(),function(n){r&&we(e.router,n,t,!0),Ye||yt(n.fullPath)})},o=Ye?"popstate":"hashchange";window.addEventListener(o,i),this.listeners.push(function(){window.removeEventListener(o,i)})}},t.prototype.push=function(e,t,n){var r=this,i=this,o=i.current;this.transitionTo(e,function(e){gt(e.fullPath),we(r.router,e,o,!1),t&&t(e)},n)},t.prototype.replace=function(e,t,n){var r=this,i=this,o=i.current;this.transitionTo(e,function(e){yt(e.fullPath),we(r.router,e,o,!1),t&&t(e)},n)},t.prototype.go=function(e){window.history.go(e)},t.prototype.ensureURL=function(e){var t=this.current.fullPath;mt()!==t&&(e?gt(t):yt(t))},t.prototype.getCurrentLocation=function(){return mt()},t}(et);function ft(e){var t=dt(e);if(!/^\/#/.test(t))return window.location.replace(z(e+"/#"+t)),!0}function pt(){var e=mt();return"/"===e.charAt(0)||(yt("/"+e),!1)}function mt(){var e=window.location.href,t=e.indexOf("#");return t<0?"":(e=e.slice(t+1),e)}function vt(e){var t=window.location.href,n=t.indexOf("#"),r=n>=0?t.slice(0,n):t;return r+"#"+e}function gt(e){Ye?Ve(vt(e)):window.location.hash=e}function yt(e){Ye?Pe(vt(e)):window.location.replace(vt(e))}var _t=function(e){function t(t,n){e.call(this,t,n),this.stack=[],this.index=-1}return e&&(t.__proto__=e),t.prototype=Object.create(e&&e.prototype),t.prototype.constructor=t,t.prototype.push=function(e,t,n){var r=this;this.transitionTo(e,function(e){r.stack=r.stack.slice(0,r.index+1).concat(e),r.index++,t&&t(e)},n)},t.prototype.replace=function(e,t,n){var r=this;this.transitionTo(e,function(e){r.stack=r.stack.slice(0,r.index).concat(e),t&&t(e)},n)},t.prototype.go=function(e){var t=this,n=this.index+e;if(!(n<0||n>=this.stack.length)){var r=this.stack[n];this.confirmTransition(r,function(){var e=t.current;t.index=n,t.updateRoute(r),t.router.afterHooks.forEach(function(t){t&&t(r,e)})},function(e){Ke(e,Ee.duplicated)&&(t.index=n)})}},t.prototype.getCurrentLocation=function(){var e=this.stack[this.stack.length-1];return e?e.fullPath:"/"},t.prototype.ensureURL=function(){},t}(et),bt=function(e){void 0===e&&(e={}),this.app=null,this.apps=[],this.options=e,this.beforeHooks=[],this.resolveHooks=[],this.afterHooks=[],this.matcher=fe(e.routes||[],this);var t=e.mode||"hash";switch(this.fallback="history"===t&&!Ye&&!1!==e.fallback,this.fallback&&(t="hash"),ce||(t="abstract"),this.mode=t,t){case"history":this.history=new ut(this,e.base);break;case"hash":this.history=new ht(this,e.base,this.fallback);break;case"abstract":this.history=new _t(this,e.base);break;default:0}},Mt={currentRoute:{configurable:!0}};bt.prototype.match=function(e,t,n){return this.matcher.match(e,t,n)},Mt.currentRoute.get=function(){return this.history&&this.history.current},bt.prototype.init=function(e){var t=this;if(this.apps.push(e),e.$once("hook:destroyed",function(){var n=t.apps.indexOf(e);n>-1&&t.apps.splice(n,1),t.app===e&&(t.app=t.apps[0]||null),t.app||t.history.teardown()}),!this.app){this.app=e;var n=this.history;if(n instanceof ut||n instanceof ht){var r=function(e){var r=n.current,i=t.options.scrollBehavior,o=Ye&&i;o&&"fullPath"in e&&we(t,e,r,!1)},i=function(e){n.setupListeners(),r(e)};n.transitionTo(n.getCurrentLocation(),i,i)}n.listen(function(e){t.apps.forEach(function(t){t._route=e})})}},bt.prototype.beforeEach=function(e){return wt(this.beforeHooks,e)},bt.prototype.beforeResolve=function(e){return wt(this.resolveHooks,e)},bt.prototype.afterEach=function(e){return wt(this.afterHooks,e)},bt.prototype.onReady=function(e,t){this.history.onReady(e,t)},bt.prototype.onError=function(e){this.history.onError(e)},bt.prototype.push=function(e,t,n){var r=this;if(!t&&!n&&"undefined"!==typeof Promise)return new Promise(function(t,n){r.history.push(e,t,n)});this.history.push(e,t,n)},bt.prototype.replace=function(e,t,n){var r=this;if(!t&&!n&&"undefined"!==typeof Promise)return new Promise(function(t,n){r.history.replace(e,t,n)});this.history.replace(e,t,n)},bt.prototype.go=function(e){this.history.go(e)},bt.prototype.back=function(){this.go(-1)},bt.prototype.forward=function(){this.go(1)},bt.prototype.getMatchedComponents=function(e){var t=e?e.matched?e:this.resolve(e).route:this.currentRoute;return t?[].concat.apply([],t.matched.map(function(e){return Object.keys(e.components).map(function(t){return e.components[t]})})):[]},bt.prototype.resolve=function(e,t,n){t=t||this.history.current;var r=Q(e,t,n,this),i=this.match(r,t),o=i.redirectedFrom||i.fullPath,a=this.history.base,s=xt(a,o,this.mode);return{location:r,route:i,href:s,normalizedTo:r,resolved:i}},bt.prototype.getRoutes=function(){return this.matcher.getRoutes()},bt.prototype.addRoute=function(e,t){this.matcher.addRoute(e,t),this.history.current!==v&&this.history.transitionTo(this.history.getCurrentLocation())},bt.prototype.addRoutes=function(e){this.matcher.addRoutes(e),this.history.current!==v&&this.history.transitionTo(this.history.getCurrentLocation())},Object.defineProperties(bt.prototype,Mt);var At=bt;function wt(e,t){return e.push(t),function(){var n=e.indexOf(t);n>-1&&e.splice(n,1)}}function xt(e,t,n){var r="hash"===n?"#"+t:t;return e?z(e+"/"+r):r}bt.install=se,bt.version="3.6.5",bt.isNavigationFailure=Ke,bt.NavigationFailureType=Ee,bt.START_LOCATION=v,ce&&window.Vue&&window.Vue.use(bt)},85471:function(e,t,n){"use strict";n.d(t,{Ay:function(){return pi},EW:function(){return tt},IJ:function(){return Ze},KR:function(){return Xe},dY:function(){return xn},nI:function(){return ge},sV:function(){return Cn},wB:function(){return ct},xo:function(){return Sn}}); diff --git a/frontend/dist/js/chunk-vendors.ac6f37ff.js b/frontend/dist/js/chunk-vendors.ac6f37ff.js index d0dd1ec..2a3dad7 100644 --- a/frontend/dist/js/chunk-vendors.ac6f37ff.js +++ b/frontend/dist/js/chunk-vendors.ac6f37ff.js @@ -283,7 +283,7 @@ object-assign @license MIT */var t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,r=Object.prototype.propertyIsEnumerable;function i(e){if(null===e||void 0===e)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}function o(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de","5"===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;n<10;n++)t["_"+String.fromCharCode(n)]=n;var r=Object.getOwnPropertyNames(t).map(function(e){return t[e]});if("0123456789"!==r.join(""))return!1;var i={};return"abcdefghijklmnopqrst".split("").forEach(function(e){i[e]=e}),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},i)).join("")}catch(o){return!1}}e.exports=o()?Object.assign:function(e,o){for(var a,s,c=i(e),l=1;l=0;--o){var a=this.tryEntries[o],s=a.completion;if("root"===a.tryLoc)return i("end");if(a.tryLoc<=this.prev){var c=r.call(a,"catchLoc"),l=r.call(a,"finallyLoc");if(c&&l){if(this.prev=0;--n){var i=this.tryEntries[n];if(i.tryLoc<=this.prev&&r.call(i,"finallyLoc")&&this.prev=0;--t){var n=this.tryEntries[t];if(n.finallyLoc===e)return this.complete(n.completion,n.afterLoc),z(n),v}},catch:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var n=this.tryEntries[t];if(n.tryLoc===e){var r=n.completion;if("throw"===r.type){var i=r.arg;z(n)}return i}}throw new Error("illegal catch attempt")},delegateYield:function(e,n,r){return this.delegate={iterator:O(e),resultName:n,nextLoc:r},"next"===this.method&&(this.arg=t),v}},e}(e.exports);try{regeneratorRuntime=t}catch(n){"object"===typeof globalThis?globalThis.regeneratorRuntime=t:Function("r","regeneratorRuntime = r")(t)}},43591:function(e,t,n){"use strict";var r=function(){if("undefined"!==typeof Map)return Map;function e(e,t){var n=-1;return e.some(function(e,r){return e[0]===t&&(n=r,!0)}),n}return function(){function t(){this.__entries__=[]}return Object.defineProperty(t.prototype,"size",{get:function(){return this.__entries__.length},enumerable:!0,configurable:!0}),t.prototype.get=function(t){var n=e(this.__entries__,t),r=this.__entries__[n];return r&&r[1]},t.prototype.set=function(t,n){var r=e(this.__entries__,t);~r?this.__entries__[r][1]=n:this.__entries__.push([t,n])},t.prototype.delete=function(t){var n=this.__entries__,r=e(n,t);~r&&n.splice(r,1)},t.prototype.has=function(t){return!!~e(this.__entries__,t)},t.prototype.clear=function(){this.__entries__.splice(0)},t.prototype.forEach=function(e,t){void 0===t&&(t=null);for(var n=0,r=this.__entries__;n0},e.prototype.connect_=function(){i&&!this.connected_&&(document.addEventListener("transitionend",this.onTransitionEnd_),window.addEventListener("resize",this.refresh),d?(this.mutationsObserver_=new MutationObserver(this.refresh),this.mutationsObserver_.observe(document,{attributes:!0,childList:!0,characterData:!0,subtree:!0})):(document.addEventListener("DOMSubtreeModified",this.refresh),this.mutationEventsAdded_=!0),this.connected_=!0)},e.prototype.disconnect_=function(){i&&this.connected_&&(document.removeEventListener("transitionend",this.onTransitionEnd_),window.removeEventListener("resize",this.refresh),this.mutationsObserver_&&this.mutationsObserver_.disconnect(),this.mutationEventsAdded_&&document.removeEventListener("DOMSubtreeModified",this.refresh),this.mutationsObserver_=null,this.mutationEventsAdded_=!1,this.connected_=!1)},e.prototype.onTransitionEnd_=function(e){var t=e.propertyName,n=void 0===t?"":t,r=u.some(function(e){return!!~n.indexOf(e)});r&&this.refresh()},e.getInstance=function(){return this.instance_||(this.instance_=new e),this.instance_},e.instance_=null,e}(),f=function(e,t){for(var n=0,r=Object.keys(t);n0},e}(),z="undefined"!==typeof WeakMap?new WeakMap:new r,T=function(){function e(t){if(!(this instanceof e))throw new TypeError("Cannot call a class as a function.");if(!arguments.length)throw new TypeError("1 argument required, but only 0 present.");var n=h.getInstance(),r=new S(t,n,this);z.set(this,r)}return e}();["observe","unobserve","disconnect"].forEach(function(e){T.prototype[e]=function(){var t;return(t=z.get(this))[e].apply(t,arguments)}});var O=function(){return"undefined"!==typeof o.ResizeObserver?o.ResizeObserver:T}();t.A=O},2833:function(e){e.exports=function(e,t,n,r){var i=n?n.call(r,e,t):void 0;if(void 0!==i)return!!i;if(e===t)return!0;if("object"!==typeof e||!e||"object"!==typeof t||!t)return!1;var o=Object.keys(e),a=Object.keys(t);if(o.length!==a.length)return!1;for(var s=Object.prototype.hasOwnProperty.bind(t),c=0;c=0;n--)if(o(t[n])){var r=t[n].split("="),i=unescape(r[0]),s=unescape(r[1]);e(s,i)}}function l(e,t){e&&(a.cookie=escape(e)+"="+escape(t)+"; expires=Tue, 19 Jan 2038 03:14:07 GMT; path=/")}function u(e){e&&h(e)&&(a.cookie=escape(e)+"=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/")}function d(){c(function(e,t){u(t)})}function h(e){return new RegExp("(?:^|;\\s*)"+escape(e).replace(/[\-\.\+\*]/g,"\\$&")+"\\s*\\=").test(a.cookie)}},99653:function(e,t,n){var r=n(16123),i=r.Global;function o(){return i.localStorage}function a(e){return o().getItem(e)}function s(e,t){return o().setItem(e,t)}function c(e){for(var t=o().length-1;t>=0;t--){var n=o().key(t);e(a(n),n)}}function l(e){return o().removeItem(e)}function u(){return o().clear()}e.exports={name:"localStorage",read:a,write:s,each:c,remove:l,clearAll:u}},81529:function(e){e.exports={name:"memoryStorage",read:n,write:r,each:i,remove:o,clearAll:a};var t={};function n(e){return t[e]}function r(e,n){t[e]=n}function i(e){for(var n in t)t.hasOwnProperty(n)&&e(t[n],n)}function o(e){delete t[e]}function a(e){t={}}},27333:function(e,t,n){var r=n(16123),i=r.Global;e.exports={name:"oldFF-globalStorage",read:a,write:s,each:c,remove:l,clearAll:u};var o=i.globalStorage;function a(e){return o[e]}function s(e,t){o[e]=t}function c(e){for(var t=o.length-1;t>=0;t--){var n=o.key(t);e(o[n],n)}}function l(e){return o.removeItem(e)}function u(){c(function(e,t){delete o[e]})}},45991:function(e,t,n){var r=n(16123),i=r.Global;e.exports={name:"oldIE-userDataStorage",write:l,read:u,each:d,remove:h,clearAll:f};var o="storejs",a=i.document,s=v(),c=(i.navigator?i.navigator.userAgent:"").match(/ (MSIE 8|MSIE 9|MSIE 10)\./);function l(e,t){if(!c){var n=m(e);s(function(e){e.setAttribute(n,t),e.save(o)})}}function u(e){if(!c){var t=m(e),n=null;return s(function(e){n=e.getAttribute(t)}),n}}function d(e){s(function(t){for(var n=t.XMLDocument.documentElement.attributes,r=n.length-1;r>=0;r--){var i=n[r];e(t.getAttribute(i.name),i.name)}})}function h(e){var t=m(e);s(function(e){e.removeAttribute(t),e.save(o)})}function f(){s(function(e){var t=e.XMLDocument.documentElement.attributes;e.load(o);for(var n=t.length-1;n>=0;n--)e.removeAttribute(t[n].name);e.save(o)})}var p=new RegExp("[!\"#$%&'()*+,/\\\\:;<=>?@[\\]^`{|}~]","g");function m(e){return e.replace(/^\d/,"___$&").replace(p,"___")}function v(){if(!a||!a.documentElement||!a.documentElement.addBehavior)return null;var e,t,n,r="script";try{t=new ActiveXObject("htmlfile"),t.open(),t.write("<"+r+">document.w=window'),t.close(),e=t.w.frames[0].document,n=e.createElement("div")}catch(i){n=a.createElement("div"),e=a.body}return function(t){var r=[].slice.call(arguments,0);r.unshift(n),e.appendChild(n),n.addBehavior("#default#userData"),n.load(o),t.apply(this,r),e.removeChild(n)}}},65416:function(e,t,n){var r=n(16123),i=r.Global;function o(){return i.sessionStorage}function a(e){return o().getItem(e)}function s(e,t){return o().setItem(e,t)}function c(e){for(var t=o().length-1;t>=0;t--){var n=o().key(t);e(a(n),n)}}function l(e){return o().removeItem(e)}function u(){return o().clear()}e.exports={name:"sessionStorage",read:a,write:s,each:c,remove:l,clearAll:u}},16426:function(e){e.exports=function(){var e=document.getSelection();if(!e.rangeCount)return function(){};for(var t=document.activeElement,n=[],r=0;rc)r.f(e,n=a[c++],t[n]);return e}},"18d2":function(e,t,n){"use strict";var r=n("18e9");e.exports=function(e){e=e||{};var t=e.reporter,n=e.batchProcessor,i=e.stateHandler.getState;if(!t)throw new Error("Missing required dependency: reporter.");function o(e,t){if(!s(e))throw new Error("Element is not detectable by this strategy.");function n(){t(e)}if(r.isIE(8))i(e).object={proxy:n},e.attachEvent("onresize",n);else{var o=s(e);o.contentDocument.defaultView.addEventListener("resize",n)}}function a(e,o,a){a||(a=o,o=e,e=null),e=e||{};e.debug;function s(e,o){var a="display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; padding: 0; margin: 0; opacity: 0; z-index: -1000; pointer-events: none;",s=!1,c=window.getComputedStyle(e),l=e.offsetWidth,u=e.offsetHeight;function d(){function n(){if("static"===c.position){e.style.position="relative";var n=function(e,t,n,r){function i(e){return e.replace(/[^-\d\.]/g,"")}var o=n[r];"auto"!==o&&"0"!==i(o)&&(e.warn("An element that is positioned static has style."+r+"="+o+" which is ignored due to the static positioning. The element will need to be positioned relative, so the style."+r+" will be set to 0. Element: ",t),t.style[r]=0)};n(t,e,c,"top"),n(t,e,c,"right"),n(t,e,c,"bottom"),n(t,e,c,"left")}}function l(){function t(e,n){e.contentDocument?n(e.contentDocument):setTimeout(function(){t(e,n)},100)}s||n();var r=this;t(r,function(t){o(e)})}""!==c.position&&(n(c),s=!0);var u=document.createElement("object");u.style.cssText=a,u.tabIndex=-1,u.type="text/html",u.onload=l,r.isIE()||(u.data="about:blank"),e.appendChild(u),i(e).object=u,r.isIE()&&(u.data="about:blank")}i(e).startSize={width:l,height:u},n?n.add(d):d()}r.isIE(8)?a(o):s(o,a)}function s(e){return i(e).object}function c(e){r.isIE(8)?e.detachEvent("onresize",i(e).object.proxy):e.removeChild(s(e)),delete i(e).object}return{makeDetectable:a,addListener:o,uninstall:c}}},"18e9":function(e,t,n){"use strict";var r=e.exports={};r.isIE=function(e){function t(){var e=navigator.userAgent.toLowerCase();return-1!==e.indexOf("msie")||-1!==e.indexOf("trident")||-1!==e.indexOf(" edge/")}if(!t())return!1;if(!e)return!0;var n=function(){var e,t=3,n=document.createElement("div"),r=n.getElementsByTagName("i");do{n.innerHTML="\x3c!--[if gt IE "+ ++t+"]>4?t:e}();return e===n},r.isLegacyOpera=function(){return!!window.opera}},"1b47":function(e,t,n){"use strict";function r(e){for(var t=[],n=0,r=Object.keys(e);n";t.style.display="none",n("fab2").appendChild(t),t.src="javascript:",e=t.contentWindow.document,e.open(),e.write(i+"script"+a+"document.F=Object"+i+"/script"+a),e.close(),l=e.F;while(r--)delete l[c][o[r]];return l()};e.exports=Object.create||function(e,t){var n;return null!==e?(s[c]=r(e),n=new s,s[c]=null,n[a]=e):n=l(),void 0===t?n:i(n,t)}},"2b4c":function(e,t,n){var r=n("5537")("wks"),i=n("ca5a"),o=n("7726").Symbol,a="function"==typeof o,s=e.exports=function(e){return r[e]||(r[e]=a&&o[e]||(a?o:i)("Symbol."+e))};s.store=r},"2cef":function(e,t,n){"use strict";e.exports=function(){var e=1;function t(){return e++}return{generate:t}}},"2d00":function(e,t){e.exports=!1},"2d95":function(e,t){var n={}.toString;e.exports=function(e){return n.call(e).slice(8,-1)}},"32e9":function(e,t,n){var r=n("86cc"),i=n("4630");e.exports=n("9e1e")?function(e,t,n){return r.f(e,t,i(1,n))}:function(e,t,n){return e[t]=n,e}},"38fd":function(e,t,n){var r=n("69a8"),i=n("4bf8"),o=n("613b")("IE_PROTO"),a=Object.prototype;e.exports=Object.getPrototypeOf||function(e){return e=i(e),r(e,o)?e[o]:"function"==typeof e.constructor&&e instanceof e.constructor?e.constructor.prototype:e instanceof Object?a:null}},"41a0":function(e,t,n){"use strict";var r=n("2aeb"),i=n("4630"),o=n("7f20"),a={};n("32e9")(a,n("2b4c")("iterator"),function(){return this}),e.exports=function(e,t,n){e.prototype=r(a,{next:i(1,n)}),o(e,t+" Iterator")}},"456d":function(e,t,n){var r=n("4bf8"),i=n("0d58");n("5eda")("keys",function(){return function(e){return i(r(e))}})},4588:function(e,t){var n=Math.ceil,r=Math.floor;e.exports=function(e){return isNaN(e=+e)?0:(e>0?r:n)(e)}},4630:function(e,t){e.exports=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}}},"49ad":function(e,t,n){"use strict";e.exports=function(e){var t={};function n(n){var r=e.get(n);return void 0===r?[]:t[r]||[]}function r(n,r){var i=e.get(n);t[i]||(t[i]=[]),t[i].push(r)}function i(e,t){for(var r=n(e),i=0,o=r.length;i0?i(r(e),9007199254740991):0}},"9e1e":function(e,t,n){e.exports=!n("79e5")(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},a307:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n("eec4"),i=function(){function e(e){var t=this;this.handler=e,this.listenedElement=null,this.hasResizeObserver="undefined"!==typeof window.ResizeObserver,this.hasResizeObserver?this.rz=new ResizeObserver(function(e){t.handler(o(e[0].target))}):this.erd=r({strategy:"scroll"})}return e.prototype.observe=function(e){var t=this;this.listenedElement!==e&&(this.listenedElement&&this.disconnect(),e&&(this.hasResizeObserver?this.rz.observe(e):this.erd.listenTo(e,function(e){t.handler(o(e))})),this.listenedElement=e)},e.prototype.disconnect=function(){this.listenedElement&&(this.hasResizeObserver?this.rz.disconnect():this.erd.uninstall(this.listenedElement),this.listenedElement=null)},e}();function o(e){return{width:a(window.getComputedStyle(e)["width"]),height:a(window.getComputedStyle(e)["height"])}}function a(e){var t=/^([0-9\.]+)px$/.exec(e);return t?parseFloat(t[1]):0}t.default=i},abb4:function(e,t,n){"use strict";e.exports=function(e){function t(){}var n={log:t,warn:t,error:t};if(!e&&window.console){var r=function(e,t){e[t]=function(){var e=console[t];if(e.apply)e.apply(console,arguments);else for(var n=0;nn?n=i:iu)if(s=c[u++],s!=s)return!0}else for(;l>u;u++)if((e||u in c)&&c[u]===n)return e||u||0;return!e&&-1}}},c69a:function(e,t,n){e.exports=!n("9e1e")&&!n("79e5")(function(){return 7!=Object.defineProperty(n("230e")("div"),"a",{get:function(){return 7}}).a})},c946:function(e,t,n){"use strict";var r=n("b770").forEach;e.exports=function(e){e=e||{};var t=e.reporter,n=e.batchProcessor,i=e.stateHandler.getState,o=(e.stateHandler.hasState,e.idHandler);if(!n)throw new Error("Missing required dependency: batchProcessor");if(!t)throw new Error("Missing required dependency: reporter.");var a=l(),s="erd_scroll_detection_scrollbar_style",c="erd_scroll_detection_container";function l(){var e=500,t=500,n=document.createElement("div");n.style.cssText="position: absolute; width: "+2*e+"px; height: "+2*t+"px; visibility: hidden; margin: 0; padding: 0;";var r=document.createElement("div");r.style.cssText="position: absolute; width: "+e+"px; height: "+t+"px; overflow: scroll; visibility: none; top: "+3*-e+"px; left: "+3*-t+"px; visibility: hidden; margin: 0; padding: 0;",r.appendChild(n),document.body.insertBefore(r,document.body.firstChild);var i=e-r.clientWidth,o=t-r.clientHeight;return document.body.removeChild(r),{width:i,height:o}}function u(e,t){function n(t,n){n=n||function(e){document.head.appendChild(e)};var r=document.createElement("style");return r.innerHTML=t,r.id=e,n(r),r}if(!document.getElementById(e)){var r=t+"_animation",i=t+"_animation_active",o="/* Created by the element-resize-detector library. */\n";o+="."+t+" > div::-webkit-scrollbar { display: none; }\n\n",o+="."+i+" { -webkit-animation-duration: 0.1s; animation-duration: 0.1s; -webkit-animation-name: "+r+"; animation-name: "+r+"; }\n",o+="@-webkit-keyframes "+r+" { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } }\n",o+="@keyframes "+r+" { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } }",n(o)}}function d(e){e.className+=" "+c+"_animation_active"}function h(e,n,r){if(e.addEventListener)e.addEventListener(n,r);else{if(!e.attachEvent)return t.error("[scroll] Don't know how to add event listeners.");e.attachEvent("on"+n,r)}}function f(e,n,r){if(e.removeEventListener)e.removeEventListener(n,r);else{if(!e.detachEvent)return t.error("[scroll] Don't know how to remove event listeners.");e.detachEvent("on"+n,r)}}function p(e){return i(e).container.childNodes[0].childNodes[0].childNodes[0]}function m(e){return i(e).container.childNodes[0].childNodes[0].childNodes[1]}function v(e,t){var n=i(e).listeners;if(!n.push)throw new Error("Cannot add listener to an element that is not detectable.");i(e).listeners.push(t)}function g(e,s,l){function u(){if(e.debug){var n=Array.prototype.slice.call(arguments);if(n.unshift(o.get(s),"Scroll: "),t.log.apply)t.log.apply(null,n);else for(var r=0;r=e.length?(this._t=void 0,i(1)):i(0,"keys"==t?n:"values"==t?e[n]:[n,e[n]])},"values"),o.Arguments=o.Array,r("keys"),r("values"),r("entries")},cb7c:function(e,t,n){var r=n("d3f4");e.exports=function(e){if(!r(e))throw TypeError(e+" is not an object!");return e}},ce10:function(e,t,n){var r=n("69a8"),i=n("6821"),o=n("c366")(!1),a=n("613b")("IE_PROTO");e.exports=function(e,t){var n,s=i(e),c=0,l=[];for(n in s)n!=a&&r(s,n)&&l.push(n);while(t.length>c)r(s,n=t[c++])&&(~o(l,n)||l.push(n));return l}},d3f4:function(e,t){e.exports=function(e){return"object"===typeof e?null!==e:"function"===typeof e}},d53b:function(e,t){e.exports=function(e,t){return{value:t,done:!!e}}},d6eb:function(e,t,n){"use strict";var r="_erd";function i(e){return e[r]={},o(e)}function o(e){return e[r]}function a(e){delete e[r]}e.exports={initState:i,getState:o,cleanState:a}},d8e8:function(e,t){e.exports=function(e){if("function"!=typeof e)throw TypeError(e+" is not a function!");return e}},e11e:function(e,t){e.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},eec4:function(e,t,n){"use strict";var r=n("b770").forEach,i=n("5be5"),o=n("49ad"),a=n("2cef"),s=n("5058"),c=n("abb4"),l=n("18e9"),u=n("c274"),d=n("d6eb"),h=n("18d2"),f=n("c946");function p(e){return Array.isArray(e)||void 0!==e.length}function m(e){if(Array.isArray(e))return e;var t=[];return r(e,function(e){t.push(e)}),t}function v(e){return e&&1===e.nodeType}function g(e,t,n){var r=e[t];return void 0!==r&&null!==r||void 0===n?r:n}e.exports=function(e){var t;if(e=e||{},e.idHandler)t={get:function(t){return e.idHandler.get(t,!0)},set:e.idHandler.set};else{var n=a(),y=s({idGenerator:n,stateHandler:d});t=y}var _=e.reporter;if(!_){var b=!1===_;_=c(b)}var M=g(e,"batchProcessor",u({reporter:_})),A={};A.callOnAdd=!!g(e,"callOnAdd",!0),A.debug=!!g(e,"debug",!1);var w,x=o(t),k=i({stateHandler:d}),L=g(e,"strategy","object"),C={reporter:_,batchProcessor:M,stateHandler:d,idHandler:t};if("scroll"===L&&(l.isLegacyOpera()?(_.warn("Scroll strategy is not supported on legacy Opera. Changing to object strategy."),L="object"):l.isIE(9)&&(_.warn("Scroll strategy is not supported on IE9. Changing to object strategy."),L="object")),"scroll"===L)w=f(C);else{if("object"!==L)throw new Error("Invalid strategy name: "+L);w=h(C)}var S={};function z(e,n,i){function o(e){var t=x.get(e);r(t,function(t){t(e)})}function a(e,t,n){x.add(t,n),e&&n(t)}if(i||(i=n,n=e,e={}),!n)throw new Error("At least one element required.");if(!i)throw new Error("Listener required.");if(v(n))n=[n];else{if(!p(n))return _.error("Invalid arguments. Must be a DOM element or a collection of DOM elements.");n=m(n)}var s=0,c=g(e,"callOnAdd",A.callOnAdd),l=g(e,"onReady",function(){}),u=g(e,"debug",A.debug);r(n,function(e){d.getState(e)||(d.initState(e),t.set(e));var h=t.get(e);if(u&&_.log("Attaching listener to element",h,e),!k.isDetectable(e))return u&&_.log(h,"Not detectable."),k.isBusy(e)?(u&&_.log(h,"System busy making it detectable"),a(c,e,i),S[h]=S[h]||[],void S[h].push(function(){s++,s===n.length&&l()})):(u&&_.log(h,"Making detectable..."),k.markBusy(e,!0),w.makeDetectable({debug:u},e,function(e){if(u&&_.log(h,"onElementDetectable"),d.getState(e)){k.markAsDetectable(e),k.markBusy(e,!1),w.addListener(e,o),a(c,e,i);var t=d.getState(e);if(t&&t.startSize){var f=e.offsetWidth,p=e.offsetHeight;t.startSize.width===f&&t.startSize.height===p||o(e)}S[h]&&r(S[h],function(e){e()})}else u&&_.log(h,"Element uninstalled before being detectable.");delete S[h],s++,s===n.length&&l()}));u&&_.log(h,"Already detecable, adding listener."),a(c,e,i),s++}),s===n.length&&l()}function T(e){if(!e)return _.error("At least one element is required.");if(v(e))e=[e];else{if(!p(e))return _.error("Invalid arguments. Must be a DOM element or a collection of DOM elements.");e=m(e)}r(e,function(e){x.removeAllListeners(e),w.uninstall(e),d.cleanState(e)})}return{listenTo:z,removeListener:x.removeListener,removeAllListeners:x.removeAllListeners,uninstall:T}}},fab2:function(e,t,n){var r=n("7726").document;e.exports=r&&r.documentElement},fae3:function(e,t,n){"use strict";n.r(t);n("1eb2");var r=n("1b47"),i=n.n(r);function o(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function a(e,t){for(var n=0;n=0&&c.splice(t,1)}function p(e){var t=document.createElement("style");if(void 0===e.attrs.type&&(e.attrs.type="text/css"),void 0===e.attrs.nonce){var r=function(){return n.nc}();r&&(e.attrs.nonce=r)}return m(t,e.attrs),h(e,t),t}function m(e,t){Object.keys(t).forEach(function(n){e.setAttribute(n,t[n])})}function v(e,t){var n,r,i,o;if(t.transform&&e.css){if(!(o="function"==typeof t.transform?t.transform(e.css):t.transform.default(e.css)))return function(){};e.css=o}if(t.singleton){var c=s++;n=a||(a=p(t)),r=y.bind(null,n,c,!1),i=y.bind(null,n,c,!0)}else e.sourceMap&&"function"==typeof URL&&"function"==typeof URL.createObjectURL&&"function"==typeof URL.revokeObjectURL&&"function"==typeof Blob&&"function"==typeof btoa?(n=function(e){var t=document.createElement("link");return void 0===e.attrs.type&&(e.attrs.type="text/css"),e.attrs.rel="stylesheet",m(t,e.attrs),h(e,t),t}(t),r=function(e,t,n){var r=n.css,i=n.sourceMap,o=void 0===t.convertToAbsoluteUrls&&i;(t.convertToAbsoluteUrls||o)&&(r=l(r)),i&&(r+="\n/*# sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(i))))+" */");var a=new Blob([r],{type:"text/css"}),s=e.href;e.href=URL.createObjectURL(a),s&&URL.revokeObjectURL(s)}.bind(null,n,t),i=function(){f(n),n.href&&URL.revokeObjectURL(n.href)}):(n=p(t),r=function(e,t){var n=t.css,r=t.media;if(r&&e.setAttribute("media",r),e.styleSheet)e.styleSheet.cssText=n;else{for(;e.firstChild;)e.removeChild(e.firstChild);e.appendChild(document.createTextNode(n))}}.bind(null,n),i=function(){f(n)});return r(e),function(t){if(t){if(t.css===e.css&&t.media===e.media&&t.sourceMap===e.sourceMap)return;r(e=t)}else i()}}e.exports=function(e,t){if("undefined"!=typeof DEBUG&&DEBUG&&"object"!=typeof document)throw new Error("The style-loader cannot be used in a non-browser environment");(t=t||{}).attrs="object"==typeof t.attrs?t.attrs:{},t.singleton||"boolean"==typeof t.singleton||(t.singleton=i()),t.insertInto||(t.insertInto="head"),t.insertAt||(t.insertAt="bottom");var n=d(e,t);return u(n,t),function(e){for(var i=[],o=0;o=8&&(s=n+r))),s)for(d=u.getUint16(s,i),l=0;l21?"-21px":"0px",e.width=this.cropW>0?this.cropW:0,e.height=this.cropH>0?this.cropH:0,this.infoTrue){var t=1;this.high&&!this.full&&(t=window.devicePixelRatio),1!==this.enlarge&!this.full&&(t=Math.abs(Number(this.enlarge))),e.width=e.width*t,e.height=e.height*t,this.full&&(e.width=e.width/this.scale,e.height=e.height/this.scale)}return e.width=e.width.toFixed(0),e.height=e.height.toFixed(0),e},isIE:function(){var e=navigator.userAgent,t=e.indexOf("compatible")>-1&&e.indexOf("MSIE")>-1;return t}},watch:{img:function(){this.checkedImg()},imgs:function(e){""!==e&&this.reload()},cropW:function(){this.showPreview()},cropH:function(){this.showPreview()},cropOffsertX:function(){this.showPreview()},cropOffsertY:function(){this.showPreview()},scale:function(e,t){this.showPreview()},x:function(){this.showPreview()},y:function(){this.showPreview()},autoCrop:function(e){e&&this.goAutoCrop()},autoCropWidth:function(){this.autoCrop&&this.goAutoCrop()},autoCropHeight:function(){this.autoCrop&&this.goAutoCrop()},mode:function(){this.checkedImg()},rotate:function(){this.showPreview(),(this.autoCrop||this.cropW>0||this.cropH>0)&&this.goAutoCrop(this.cropW,this.cropH)}},methods:{checkOrientationImage:function(e,t,n,r){var i=this,o=document.createElement("canvas"),a=o.getContext("2d");switch(a.save(),t){case 2:o.width=n,o.height=r,a.translate(n,0),a.scale(-1,1);break;case 3:o.width=n,o.height=r,a.translate(n/2,r/2),a.rotate(180*Math.PI/180),a.translate(-n/2,-r/2);break;case 4:o.width=n,o.height=r,a.translate(0,r),a.scale(1,-1);break;case 5:o.height=n,o.width=r,a.rotate(.5*Math.PI),a.scale(1,-1);break;case 6:o.width=r,o.height=n,a.translate(r/2,n/2),a.rotate(90*Math.PI/180),a.translate(-n/2,-r/2);break;case 7:o.height=n,o.width=r,a.rotate(.5*Math.PI),a.translate(n,-r),a.scale(-1,1);break;case 8:o.height=n,o.width=r,a.translate(r/2,n/2),a.rotate(-90*Math.PI/180),a.translate(-n/2,-r/2);break;default:o.width=n,o.height=r}a.drawImage(e,0,0,n,r),a.restore(),o.toBlob(function(e){var t=URL.createObjectURL(e);i.imgs=t},"image/"+this.outputType,1)},checkedImg:function(){var e=this;if(""!==this.img){this.loading=!0,this.scale=1,this.rotate=0,this.clearCrop();var t=new Image;if(t.onload=function(){if(""===e.img)return e.$emit("imgLoad","error"),e.$emit("img-load","error"),!1;var n=t.width,r=t.height;o.getData(t).then(function(i){e.orientation=i.orientation||1;var o=e.maxImgSize;!e.orientation&&no&&(r=r/n*o,n=o),r>o&&(n=n/r*o,r=o),e.checkOrientationImage(t,e.orientation,n,r))})},t.onerror=function(){e.$emit("imgLoad","error"),e.$emit("img-load","error")},"data"!==this.img.substr(0,4)&&(t.crossOrigin=""),this.isIE){var n=new XMLHttpRequest;n.onload=function(){var e=URL.createObjectURL(this.response);t.src=e},n.open("GET",this.img,!0),n.responseType="blob",n.send()}else t.src=this.img}},startMove:function(e){if(e.preventDefault(),this.move&&!this.crop){if(!this.canMove)return!1;this.moveX=(e.clientX?e.clientX:e.touches[0].clientX)-this.x,this.moveY=(e.clientY?e.clientY:e.touches[0].clientY)-this.y,e.touches?(window.addEventListener("touchmove",this.moveImg),window.addEventListener("touchend",this.leaveImg),2==e.touches.length&&(this.touches=e.touches,window.addEventListener("touchmove",this.touchScale),window.addEventListener("touchend",this.cancelTouchScale))):(window.addEventListener("mousemove",this.moveImg),window.addEventListener("mouseup",this.leaveImg)),this.$emit("imgMoving",{moving:!0,axis:this.getImgAxis()}),this.$emit("img-moving",{moving:!0,axis:this.getImgAxis()})}else this.cropping=!0,window.addEventListener("mousemove",this.createCrop),window.addEventListener("mouseup",this.endCrop),window.addEventListener("touchmove",this.createCrop),window.addEventListener("touchend",this.endCrop),this.cropOffsertX=e.offsetX?e.offsetX:e.touches[0].pageX-this.$refs.cropper.offsetLeft,this.cropOffsertY=e.offsetY?e.offsetY:e.touches[0].pageY-this.$refs.cropper.offsetTop,this.cropX=e.clientX?e.clientX:e.touches[0].clientX,this.cropY=e.clientY?e.clientY:e.touches[0].clientY,this.cropChangeX=this.cropOffsertX,this.cropChangeY=this.cropOffsertY,this.cropW=0,this.cropH=0},touchScale:function(e){var t=this;e.preventDefault();var n=this.scale,r=this.touches[0].clientX,i=this.touches[0].clientY,o=e.touches[0].clientX,a=e.touches[0].clientY,s=this.touches[1].clientX,c=this.touches[1].clientY,l=e.touches[1].clientX,u=e.touches[1].clientY,d=Math.sqrt(Math.pow(r-s,2)+Math.pow(i-c,2)),h=Math.sqrt(Math.pow(o-l,2)+Math.pow(a-u,2))-d,f=1,p=(f=(f=f/this.trueWidth>f/this.trueHeight?f/this.trueHeight:f/this.trueWidth)>.1?.1:f)*h;if(!this.touchNow){if(this.touchNow=!0,h>0?n+=Math.abs(p):h<0&&n>Math.abs(p)&&(n-=Math.abs(p)),this.touches=e.touches,setTimeout(function(){t.touchNow=!1},8),!this.checkoutImgAxis(this.x,this.y,n))return!1;this.scale=n}},cancelTouchScale:function(e){window.removeEventListener("touchmove",this.touchScale)},moveImg:function(e){var t=this;if(e.preventDefault(),e.touches&&2===e.touches.length)return this.touches=e.touches,window.addEventListener("touchmove",this.touchScale),window.addEventListener("touchend",this.cancelTouchScale),window.removeEventListener("touchmove",this.moveImg),!1;var n,r,i=e.clientX?e.clientX:e.touches[0].clientX,o=e.clientY?e.clientY:e.touches[0].clientY;n=i-this.moveX,r=o-this.moveY,this.$nextTick(function(){if(t.centerBox){var e,i,o,a,s=t.getImgAxis(n,r,t.scale),c=t.getCropAxis(),l=t.trueHeight*t.scale,u=t.trueWidth*t.scale;switch(t.rotate){case 1:case-1:case 3:case-3:e=t.cropOffsertX-t.trueWidth*(1-t.scale)/2+(l-u)/2,i=t.cropOffsertY-t.trueHeight*(1-t.scale)/2+(u-l)/2,o=e-l+t.cropW,a=i-u+t.cropH;break;default:e=t.cropOffsertX-t.trueWidth*(1-t.scale)/2,i=t.cropOffsertY-t.trueHeight*(1-t.scale)/2,o=e-u+t.cropW,a=i-l+t.cropH}s.x1>=c.x1&&(n=e),s.y1>=c.y1&&(r=i),s.x2<=c.x2&&(n=o),s.y2<=c.y2&&(r=a)}t.x=n,t.y=r,t.$emit("imgMoving",{moving:!0,axis:t.getImgAxis()}),t.$emit("img-moving",{moving:!0,axis:t.getImgAxis()})})},leaveImg:function(e){window.removeEventListener("mousemove",this.moveImg),window.removeEventListener("touchmove",this.moveImg),window.removeEventListener("mouseup",this.leaveImg),window.removeEventListener("touchend",this.leaveImg),this.$emit("imgMoving",{moving:!1,axis:this.getImgAxis()}),this.$emit("img-moving",{moving:!1,axis:this.getImgAxis()})},scaleImg:function(){this.canScale&&window.addEventListener(this.support,this.changeSize,{passive:!1})},cancelScale:function(){this.canScale&&window.removeEventListener(this.support,this.changeSize)},changeSize:function(e){var t=this;e.preventDefault();var n=this.scale,r=e.deltaY||e.wheelDelta;r=navigator.userAgent.indexOf("Firefox")>0?30*r:r,this.isIE&&(r=-r);var i=this.coe,o=(i=i/this.trueWidth>i/this.trueHeight?i/this.trueHeight:i/this.trueWidth)*r;o<0?n+=Math.abs(o):n>Math.abs(o)&&(n-=Math.abs(o));var a=o<0?"add":"reduce";if(a!==this.coeStatus&&(this.coeStatus=a,this.coe=.2),this.scaling||(this.scalingSet=setTimeout(function(){t.scaling=!1,t.coe=t.coe+=.01},50)),this.scaling=!0,!this.checkoutImgAxis(this.x,this.y,n))return!1;this.scale=n},changeScale:function(e){var t=this.scale;e=e||1;var n=20;if((e*=n=n/this.trueWidth>n/this.trueHeight?n/this.trueHeight:n/this.trueWidth)>0?t+=Math.abs(e):t>Math.abs(e)&&(t-=Math.abs(e)),!this.checkoutImgAxis(this.x,this.y,t))return!1;this.scale=t},createCrop:function(e){var t=this;e.preventDefault();var n=e.clientX?e.clientX:e.touches?e.touches[0].clientX:0,r=e.clientY?e.clientY:e.touches?e.touches[0].clientY:0;this.$nextTick(function(){var e=n-t.cropX,i=r-t.cropY;if(e>0?(t.cropW=e+t.cropChangeX>t.w?t.w-t.cropChangeX:e,t.cropOffsertX=t.cropChangeX):(t.cropW=t.w-t.cropChangeX+Math.abs(e)>t.w?t.cropChangeX:Math.abs(e),t.cropOffsertX=t.cropChangeX+e>0?t.cropChangeX+e:0),t.fixed){var o=t.cropW/t.fixedNumber[0]*t.fixedNumber[1];o+t.cropOffsertY>t.h?(t.cropH=t.h-t.cropOffsertY,t.cropW=t.cropH/t.fixedNumber[1]*t.fixedNumber[0],t.cropOffsertX=e>0?t.cropChangeX:t.cropChangeX-t.cropW):t.cropH=o,t.cropOffsertY=t.cropOffsertY}else i>0?(t.cropH=i+t.cropChangeY>t.h?t.h-t.cropChangeY:i,t.cropOffsertY=t.cropChangeY):(t.cropH=t.h-t.cropChangeY+Math.abs(i)>t.h?t.cropChangeY:Math.abs(i),t.cropOffsertY=t.cropChangeY+i>0?t.cropChangeY+i:0)})},changeCropSize:function(e,t,n,r,i){e.preventDefault(),window.addEventListener("mousemove",this.changeCropNow),window.addEventListener("mouseup",this.changeCropEnd),window.addEventListener("touchmove",this.changeCropNow),window.addEventListener("touchend",this.changeCropEnd),this.canChangeX=t,this.canChangeY=n,this.changeCropTypeX=r,this.changeCropTypeY=i,this.cropX=e.clientX?e.clientX:e.touches[0].clientX,this.cropY=e.clientY?e.clientY:e.touches[0].clientY,this.cropOldW=this.cropW,this.cropOldH=this.cropH,this.cropChangeX=this.cropOffsertX,this.cropChangeY=this.cropOffsertY,this.fixed&&this.canChangeX&&this.canChangeY&&(this.canChangeY=0)},changeCropNow:function(e){var t=this;e.preventDefault();var n=e.clientX?e.clientX:e.touches?e.touches[0].clientX:0,r=e.clientY?e.clientY:e.touches?e.touches[0].clientY:0,i=this.w,o=this.h,a=0,s=0;if(this.centerBox){var c=this.getImgAxis(),l=c.x2,u=c.y2;a=c.x1>0?c.x1:0,s=c.y1>0?c.y1:0,i>l&&(i=l),o>u&&(o=u)}this.$nextTick(function(){var e=n-t.cropX,c=r-t.cropY;if(t.canChangeX&&(1===t.changeCropTypeX?t.cropOldW-e>0?(t.cropW=i-t.cropChangeX-e<=i-a?t.cropOldW-e:t.cropOldW+t.cropChangeX-a,t.cropOffsertX=i-t.cropChangeX-e<=i-a?t.cropChangeX+e:a):(t.cropW=Math.abs(e)+t.cropChangeX<=i?Math.abs(e)-t.cropOldW:i-t.cropOldW-t.cropChangeX,t.cropOffsertX=t.cropChangeX+t.cropOldW):2===t.changeCropTypeX&&(t.cropOldW+e>0?(t.cropW=t.cropOldW+e+t.cropOffsertX<=i?t.cropOldW+e:i-t.cropOffsertX,t.cropOffsertX=t.cropChangeX):(t.cropW=i-t.cropChangeX+Math.abs(e+t.cropOldW)<=i-a?Math.abs(e+t.cropOldW):t.cropChangeX-a,t.cropOffsertX=i-t.cropChangeX+Math.abs(e+t.cropOldW)<=i-a?t.cropChangeX-Math.abs(e+t.cropOldW):a))),t.canChangeY&&(1===t.changeCropTypeY?t.cropOldH-c>0?(t.cropH=o-t.cropChangeY-c<=o-s?t.cropOldH-c:t.cropOldH+t.cropChangeY-s,t.cropOffsertY=o-t.cropChangeY-c<=o-s?t.cropChangeY+c:s):(t.cropH=Math.abs(c)+t.cropChangeY<=o?Math.abs(c)-t.cropOldH:o-t.cropOldH-t.cropChangeY,t.cropOffsertY=t.cropChangeY+t.cropOldH):2===t.changeCropTypeY&&(t.cropOldH+c>0?(t.cropH=t.cropOldH+c+t.cropOffsertY<=o?t.cropOldH+c:o-t.cropOffsertY,t.cropOffsertY=t.cropChangeY):(t.cropH=o-t.cropChangeY+Math.abs(c+t.cropOldH)<=o-s?Math.abs(c+t.cropOldH):t.cropChangeY-s,t.cropOffsertY=o-t.cropChangeY+Math.abs(c+t.cropOldH)<=o-s?t.cropChangeY-Math.abs(c+t.cropOldH):s))),t.canChangeX&&t.fixed){var l=t.cropW/t.fixedNumber[0]*t.fixedNumber[1];l+t.cropOffsertY>o?(t.cropH=o-t.cropOffsertY,t.cropW=t.cropH/t.fixedNumber[1]*t.fixedNumber[0]):t.cropH=l}if(t.canChangeY&&t.fixed){var u=t.cropH/t.fixedNumber[1]*t.fixedNumber[0];u+t.cropOffsertX>i?(t.cropW=i-t.cropOffsertX,t.cropH=t.cropW/t.fixedNumber[0]*t.fixedNumber[1]):t.cropW=u}})},changeCropEnd:function(e){window.removeEventListener("mousemove",this.changeCropNow),window.removeEventListener("mouseup",this.changeCropEnd),window.removeEventListener("touchmove",this.changeCropNow),window.removeEventListener("touchend",this.changeCropEnd)},endCrop:function(){0===this.cropW&&0===this.cropH&&(this.cropping=!1),window.removeEventListener("mousemove",this.createCrop),window.removeEventListener("mouseup",this.endCrop),window.removeEventListener("touchmove",this.createCrop),window.removeEventListener("touchend",this.endCrop)},startCrop:function(){this.crop=!0},stopCrop:function(){this.crop=!1},clearCrop:function(){this.cropping=!1,this.cropW=0,this.cropH=0},cropMove:function(e){if(e.preventDefault(),!this.canMoveBox)return this.crop=!1,this.startMove(e),!1;if(e.touches&&2===e.touches.length)return this.crop=!1,this.startMove(e),this.leaveCrop(),!1;window.addEventListener("mousemove",this.moveCrop),window.addEventListener("mouseup",this.leaveCrop),window.addEventListener("touchmove",this.moveCrop),window.addEventListener("touchend",this.leaveCrop);var t,n,r=e.clientX?e.clientX:e.touches[0].clientX,i=e.clientY?e.clientY:e.touches[0].clientY;t=r-this.cropOffsertX,n=i-this.cropOffsertY,this.cropX=t,this.cropY=n,this.$emit("cropMoving",{moving:!0,axis:this.getCropAxis()}),this.$emit("crop-moving",{moving:!0,axis:this.getCropAxis()})},moveCrop:function(e,t){var n=this,r=0,i=0;e&&(e.preventDefault(),r=e.clientX?e.clientX:e.touches[0].clientX,i=e.clientY?e.clientY:e.touches[0].clientY),this.$nextTick(function(){var e,o,a=r-n.cropX,s=i-n.cropY;if(t&&(a=n.cropOffsertX,s=n.cropOffsertY),e=a<=0?0:a+n.cropW>n.w?n.w-n.cropW:a,o=s<=0?0:s+n.cropH>n.h?n.h-n.cropH:s,n.centerBox){var c=n.getImgAxis();e<=c.x1&&(e=c.x1),e+n.cropW>c.x2&&(e=c.x2-n.cropW),o<=c.y1&&(o=c.y1),o+n.cropH>c.y2&&(o=c.y2-n.cropH)}n.cropOffsertX=e,n.cropOffsertY=o,n.$emit("cropMoving",{moving:!0,axis:n.getCropAxis()}),n.$emit("crop-moving",{moving:!0,axis:n.getCropAxis()})})},getImgAxis:function(e,t,n){e=e||this.x,t=t||this.y,n=n||this.scale;var r={x1:0,x2:0,y1:0,y2:0},i=this.trueWidth*n,o=this.trueHeight*n;switch(this.rotate){case 0:r.x1=e+this.trueWidth*(1-n)/2,r.x2=r.x1+this.trueWidth*n,r.y1=t+this.trueHeight*(1-n)/2,r.y2=r.y1+this.trueHeight*n;break;case 1:case-1:case 3:case-3:r.x1=e+this.trueWidth*(1-n)/2+(i-o)/2,r.x2=r.x1+this.trueHeight*n,r.y1=t+this.trueHeight*(1-n)/2+(o-i)/2,r.y2=r.y1+this.trueWidth*n;break;default:r.x1=e+this.trueWidth*(1-n)/2,r.x2=r.x1+this.trueWidth*n,r.y1=t+this.trueHeight*(1-n)/2,r.y2=r.y1+this.trueHeight*n}return r},getCropAxis:function(){var e={x1:0,x2:0,y1:0,y2:0};return e.x1=this.cropOffsertX,e.x2=e.x1+this.cropW,e.y1=this.cropOffsertY,e.y2=e.y1+this.cropH,e},leaveCrop:function(e){window.removeEventListener("mousemove",this.moveCrop),window.removeEventListener("mouseup",this.leaveCrop),window.removeEventListener("touchmove",this.moveCrop),window.removeEventListener("touchend",this.leaveCrop),this.$emit("cropMoving",{moving:!1,axis:this.getCropAxis()}),this.$emit("crop-moving",{moving:!1,axis:this.getCropAxis()})},getCropChecked:function(e){var t=this,n=document.createElement("canvas"),r=new Image,i=this.rotate,o=this.trueWidth,a=this.trueHeight,s=this.cropOffsertX,c=this.cropOffsertY;function l(e,t){n.width=Math.round(e),n.height=Math.round(t)}r.onload=function(){if(0!==t.cropW){var u=n.getContext("2d"),d=1;t.high&!t.full&&(d=window.devicePixelRatio),1!==t.enlarge&!t.full&&(d=Math.abs(Number(t.enlarge)),console.log(d));var h=t.cropW*d,f=t.cropH*d,p=o*t.scale*d,m=a*t.scale*d,v=(t.x-s+t.trueWidth*(1-t.scale)/2)*d,g=(t.y-c+t.trueHeight*(1-t.scale)/2)*d;switch(l(h,f),u.save(),i){case 0:t.full?(l(h/t.scale,f/t.scale),u.drawImage(r,v/t.scale,g/t.scale,p/t.scale,m/t.scale)):u.drawImage(r,v,g,p,m);break;case 1:case-3:t.full?(l(h/t.scale,f/t.scale),v=v/t.scale+(p/t.scale-m/t.scale)/2,g=g/t.scale+(m/t.scale-p/t.scale)/2,u.rotate(90*i*Math.PI/180),u.drawImage(r,g,-v-m/t.scale,p/t.scale,m/t.scale)):(v+=(p-m)/2,g+=(m-p)/2,u.rotate(90*i*Math.PI/180),u.drawImage(r,g,-v-m,p,m));break;case 2:case-2:t.full?(l(h/t.scale,f/t.scale),u.rotate(90*i*Math.PI/180),v/=t.scale,g/=t.scale,u.drawImage(r,-v-p/t.scale,-g-m/t.scale,p/t.scale,m/t.scale)):(u.rotate(90*i*Math.PI/180),u.drawImage(r,-v-p,-g-m,p,m));break;case 3:case-1:t.full?(l(h/t.scale,f/t.scale),v=v/t.scale+(p/t.scale-m/t.scale)/2,g=g/t.scale+(m/t.scale-p/t.scale)/2,u.rotate(90*i*Math.PI/180),u.drawImage(r,-g-p/t.scale,v,p/t.scale,m/t.scale)):(v+=(p-m)/2,g+=(m-p)/2,u.rotate(90*i*Math.PI/180),u.drawImage(r,-g-p,v,p,m));break;default:t.full?(l(h/t.scale,f/t.scale),u.drawImage(r,v/t.scale,g/t.scale,p/t.scale,m/t.scale)):u.drawImage(r,v,g,p,m)}u.restore()}else{var y=o*t.scale,_=a*t.scale,b=n.getContext("2d");switch(b.save(),i){case 0:l(y,_),b.drawImage(r,0,0,y,_);break;case 1:case-3:l(_,y),b.rotate(90*i*Math.PI/180),b.drawImage(r,0,-_,y,_);break;case 2:case-2:l(y,_),b.rotate(90*i*Math.PI/180),b.drawImage(r,-y,-_,y,_);break;case 3:case-1:l(_,y),b.rotate(90*i*Math.PI/180),b.drawImage(r,-y,0,y,_);break;default:l(y,_),b.drawImage(r,0,0,y,_)}b.restore()}e(n)},"data"!==this.img.substr(0,4)&&(r.crossOrigin="Anonymous"),r.src=this.imgs},getCropData:function(e){var t=this;this.getCropChecked(function(n){e(n.toDataURL("image/"+t.outputType,t.outputSize))})},getCropBlob:function(e){var t=this;this.getCropChecked(function(n){n.toBlob(function(t){return e(t)},"image/"+t.outputType,t.outputSize)})},showPreview:function(){var e=this;if(!this.isCanShow)return!1;this.isCanShow=!1,setTimeout(function(){e.isCanShow=!0},16);var t=this.cropW,n=this.cropH,r=this.scale,i={};i.div={width:"".concat(t,"px"),height:"".concat(n,"px")};var o=(this.x-this.cropOffsertX)/r,a=(this.y-this.cropOffsertY)/r;i.w=t,i.h=n,i.url=this.imgs,i.img={width:"".concat(this.trueWidth,"px"),height:"".concat(this.trueHeight,"px"),transform:"scale(".concat(r,")translate3d(").concat(o,"px, ").concat(a,"px, ").concat(0,"px)rotateZ(").concat(90*this.rotate,"deg)")},i.html='\n
\n
\n \n
\n
'),this.$emit("realTime",i),this.$emit("real-time",i)},reload:function(){var e=this,t=new Image;t.onload=function(){e.w=parseFloat(window.getComputedStyle(e.$refs.cropper).width),e.h=parseFloat(window.getComputedStyle(e.$refs.cropper).height),e.trueWidth=t.width,e.trueHeight=t.height,e.original?e.scale=1:e.scale=e.checkedMode(),e.$nextTick(function(){e.x=-(e.trueWidth-e.trueWidth*e.scale)/2+(e.w-e.trueWidth*e.scale)/2,e.y=-(e.trueHeight-e.trueHeight*e.scale)/2+(e.h-e.trueHeight*e.scale)/2,e.loading=!1,e.autoCrop&&e.goAutoCrop(),e.$emit("img-load","success"),e.$emit("imgLoad","success"),setTimeout(function(){e.showPreview()},20)})},t.onerror=function(){e.$emit("imgLoad","error"),e.$emit("img-load","error")},t.src=this.imgs},checkedMode:function(){var e=1,t=(this.trueWidth,this.trueHeight),n=this.mode.split(" ");switch(n[0]){case"contain":this.trueWidth>this.w&&(e=this.w/this.trueWidth),this.trueHeight*e>this.h&&(e=this.h/this.trueHeight);break;case"cover":(t*=e=this.w/this.trueWidth)n?n:a,s=s>r?r:s,this.fixed&&(s=a/this.fixedNumber[0]*this.fixedNumber[1]),s>this.h&&(a=(s=this.h)/this.fixedNumber[1]*this.fixedNumber[0]),this.changeCrop(a,s)},changeCrop:function(e,t){var n=this;if(this.centerBox){var r=this.getImgAxis();e>r.x2-r.x1&&(t=(e=r.x2-r.x1)/this.fixedNumber[0]*this.fixedNumber[1]),t>r.y2-r.y1&&(e=(t=r.y2-r.y1)/this.fixedNumber[1]*this.fixedNumber[0])}this.cropW=e,this.cropH=t,this.cropOffsertX=(this.w-e)/2,this.cropOffsertY=(this.h-t)/2,this.centerBox&&this.$nextTick(function(){n.moveCrop(null,!0)})},refresh:function(){var e=this;this.img,this.imgs="",this.scale=1,this.crop=!1,this.rotate=0,this.w=0,this.h=0,this.trueWidth=0,this.trueHeight=0,this.clearCrop(),this.$nextTick(function(){e.checkedImg()})},rotateLeft:function(){this.rotate=this.rotate<=-3?0:this.rotate-1},rotateRight:function(){this.rotate=this.rotate>=3?0:this.rotate+1},rotateClear:function(){this.rotate=0},checkoutImgAxis:function(e,t,n){e=e||this.x,t=t||this.y,n=n||this.scale;var r=!0;if(this.centerBox){var i=this.getImgAxis(e,t,n),o=this.getCropAxis();i.x1>=o.x1&&(r=!1),i.x2<=o.x2&&(r=!1),i.y1>=o.y1&&(r=!1),i.y2<=o.y2&&(r=!1)}return r}},mounted:function(){this.support="onwheel"in document.createElement("div")?"wheel":void 0!==document.onmousewheel?"mousewheel":"DOMMouseScroll";var e=this,t=navigator.userAgent;this.isIOS=!!t.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/),HTMLCanvasElement.prototype.toBlob||Object.defineProperty(HTMLCanvasElement.prototype,"toBlob",{value:function(t,n,r){for(var i=atob(this.toDataURL(n,r).split(",")[1]),o=i.length,a=new Uint8Array(o),s=0;s/g,">").replace(/"/g,""").replace(/'/g,"'")}function k(e){return null!=e&&Object.keys(e).forEach(function(t){"string"==typeof e[t]&&(e[t]=x(e[t]))}),e}function L(e){e.prototype.hasOwnProperty("$i18n")||Object.defineProperty(e.prototype,"$i18n",{get:function(){return this._i18n}}),e.prototype.$t=function(e){var t=[],n=arguments.length-1;while(n-- >0)t[n]=arguments[n+1];var r=this.$i18n;return r._t.apply(r,[e,r.locale,r._getMessages(),this].concat(t))},e.prototype.$tc=function(e,t){var n=[],r=arguments.length-2;while(r-- >0)n[r]=arguments[r+2];var i=this.$i18n;return i._tc.apply(i,[e,i.locale,i._getMessages(),this,t].concat(n))},e.prototype.$te=function(e,t){var n=this.$i18n;return n._te(e,n.locale,n._getMessages(),t)},e.prototype.$d=function(e){var t,n=[],r=arguments.length-1;while(r-- >0)n[r]=arguments[r+1];return(t=this.$i18n).d.apply(t,[e].concat(n))},e.prototype.$n=function(e){var t,n=[],r=arguments.length-1;while(r-- >0)n[r]=arguments[r+1];return(t=this.$i18n).n.apply(t,[e].concat(n))}}function C(e){function t(){this!==this.$root&&this.$options.__INTLIFY_META__&&this.$el&&this.$el.setAttribute("data-intlify",this.$options.__INTLIFY_META__)}return void 0===e&&(e=!1),e?{mounted:t}:{beforeCreate:function(){var e=this.$options;if(e.i18n=e.i18n||(e.__i18nBridge||e.__i18n?{}:null),e.i18n)if(e.i18n instanceof ke){if(e.__i18nBridge||e.__i18n)try{var t=e.i18n&&e.i18n.messages?e.i18n.messages:{},n=e.__i18nBridge||e.__i18n;n.forEach(function(e){t=A(t,JSON.parse(e))}),Object.keys(t).forEach(function(n){e.i18n.mergeLocaleMessage(n,t[n])})}catch(c){0}this._i18n=e.i18n,this._i18nWatcher=this._i18n.watchI18nData()}else if(h(e.i18n)){var r=this.$root&&this.$root.$i18n&&this.$root.$i18n instanceof ke?this.$root.$i18n:null;if(r&&(e.i18n.root=this.$root,e.i18n.formatter=r.formatter,e.i18n.fallbackLocale=r.fallbackLocale,e.i18n.formatFallbackMessages=r.formatFallbackMessages,e.i18n.silentTranslationWarn=r.silentTranslationWarn,e.i18n.silentFallbackWarn=r.silentFallbackWarn,e.i18n.pluralizationRules=r.pluralizationRules,e.i18n.preserveDirectiveContent=r.preserveDirectiveContent),e.__i18nBridge||e.__i18n)try{var i=e.i18n&&e.i18n.messages?e.i18n.messages:{},o=e.__i18nBridge||e.__i18n;o.forEach(function(e){i=A(i,JSON.parse(e))}),e.i18n.messages=i}catch(c){0}var a=e.i18n,s=a.sharedMessages;s&&h(s)&&(e.i18n.messages=A(e.i18n.messages,s)),this._i18n=new ke(e.i18n),this._i18nWatcher=this._i18n.watchI18nData(),(void 0===e.i18n.sync||e.i18n.sync)&&(this._localeWatcher=this.$i18n.watchLocale()),r&&r.onComponentInstanceCreated(this._i18n)}else 0;else this.$root&&this.$root.$i18n&&this.$root.$i18n instanceof ke?this._i18n=this.$root.$i18n:e.parent&&e.parent.$i18n&&e.parent.$i18n instanceof ke&&(this._i18n=e.parent.$i18n)},beforeMount:function(){var e=this.$options;e.i18n=e.i18n||(e.__i18nBridge||e.__i18n?{}:null),e.i18n?(e.i18n instanceof ke||h(e.i18n))&&(this._i18n.subscribeDataChanging(this),this._subscribing=!0):(this.$root&&this.$root.$i18n&&this.$root.$i18n instanceof ke||e.parent&&e.parent.$i18n&&e.parent.$i18n instanceof ke)&&(this._i18n.subscribeDataChanging(this),this._subscribing=!0)},mounted:t,beforeDestroy:function(){if(this._i18n){var e=this;this.$nextTick(function(){e._subscribing&&(e._i18n.unsubscribeDataChanging(e),delete e._subscribing),e._i18nWatcher&&(e._i18nWatcher(),e._i18n.destroyVM(),delete e._i18nWatcher),e._localeWatcher&&(e._localeWatcher(),delete e._localeWatcher)})}}}}var S={name:"i18n",functional:!0,props:{tag:{type:[String,Boolean,Object],default:"span"},path:{type:String,required:!0},locale:{type:String},places:{type:[Array,Object]}},render:function(e,t){var n=t.data,r=t.parent,i=t.props,o=t.slots,a=r.$i18n;if(a){var s=i.path,c=i.locale,l=i.places,u=o(),d=a.i(s,c,z(u)||l?T(u.default,l):u),h=i.tag&&!0!==i.tag||!1===i.tag?i.tag:"span";return h?e(h,n,d):d}}};function z(e){var t;for(t in e)if("default"!==t)return!1;return Boolean(t)}function T(e,t){var n=t?O(t):{};if(!e)return n;e=e.filter(function(e){return e.tag||""!==e.text.trim()});var r=e.every(Y);return e.reduce(r?H:D,n)}function O(e){return Array.isArray(e)?e.reduce(D,{}):Object.assign({},e)}function H(e,t){return t.data&&t.data.attrs&&t.data.attrs.place&&(e[t.data.attrs.place]=t),e}function D(e,t,n){return e[n]=t,e}function Y(e){return Boolean(e.data&&e.data.attrs&&e.data.attrs.place)}var V,P={name:"i18n-n",functional:!0,props:{tag:{type:[String,Boolean,Object],default:"span"},value:{type:Number,required:!0},format:{type:[String,Object]},locale:{type:String}},render:function(e,t){var r=t.props,i=t.parent,o=t.data,a=i.$i18n;if(!a)return null;var c=null,u=null;l(r.format)?c=r.format:s(r.format)&&(r.format.key&&(c=r.format.key),u=Object.keys(r.format).reduce(function(e,t){var i;return _(n,t)?Object.assign({},e,(i={},i[t]=r.format[t],i)):e},null));var d=r.locale||a.locale,h=a._ntp(r.value,d,c,u),f=h.map(function(e,t){var n,r=o.scopedSlots&&o.scopedSlots[e.type];return r?r((n={},n[e.type]=e.value,n.index=t,n.parts=h,n)):e.value}),p=r.tag&&!0!==r.tag||!1===r.tag?r.tag:"span";return p?e(p,{attrs:o.attrs,class:o["class"],staticClass:o.staticClass},f):f}};function E(e,t,n){I(e,n)&&R(e,t,n)}function j(e,t,n,r){if(I(e,n)){var i=n.context.$i18n;$(e,n)&&w(t.value,t.oldValue)&&w(e._localeMessage,i.getLocaleMessage(i.locale))||R(e,t,n)}}function F(e,t,n,r){var o=n.context;if(o){var a=n.context.$i18n||{};t.modifiers.preserve||a.preserveDirectiveContent||(e.textContent=""),e._vt=void 0,delete e["_vt"],e._locale=void 0,delete e["_locale"],e._localeMessage=void 0,delete e["_localeMessage"]}else i("Vue instance does not exists in VNode context")}function I(e,t){var n=t.context;return n?!!n.$i18n||(i("VueI18n instance does not exists in Vue instance"),!1):(i("Vue instance does not exists in VNode context"),!1)}function $(e,t){var n=t.context;return e._locale===n.$i18n.locale}function R(e,t,n){var r,o,a=t.value,s=N(a),c=s.path,l=s.locale,u=s.args,d=s.choice;if(c||l||u)if(c){var h=n.context;e._vt=e.textContent=null!=d?(r=h.$i18n).tc.apply(r,[c,d].concat(W(l,u))):(o=h.$i18n).t.apply(o,[c].concat(W(l,u))),e._locale=h.$i18n.locale,e._localeMessage=h.$i18n.getLocaleMessage(h.$i18n.locale)}else i("`path` is required in v-t directive");else i("value type not supported")}function N(e){var t,n,r,i;return l(e)?t=e:h(e)&&(t=e.path,n=e.locale,r=e.args,i=e.choice),{path:t,locale:n,args:r,choice:i}}function W(e,t){var n=[];return e&&n.push(e),t&&(Array.isArray(t)||h(t))&&n.push(t),n}function B(e,t){void 0===t&&(t={bridge:!1}),B.installed=!0,V=e;V.version&&Number(V.version.split(".")[0]);L(V),V.mixin(C(t.bridge)),V.directive("t",{bind:E,update:j,unbind:F}),V.component(S.name,S),V.component(P.name,P);var n=V.config.optionMergeStrategies;n.i18n=function(e,t){return void 0===t?e:t}}var K=function(){this._caches=Object.create(null)};K.prototype.interpolate=function(e,t){if(!t)return[e];var n=this._caches[e];return n||(n=G(e),this._caches[e]=n),J(n,t)};var U=/^(?:\d)+/,q=/^(?:\w)+/;function G(e){var t=[],n=0,r="";while(n0)d--,u=oe,h[X]();else{if(d=0,void 0===n)return!1;if(n=me(n),!1===n)return!1;h[Z]()}};while(null!==u)if(l++,t=e[l],"\\"!==t||!f()){if(i=pe(t),s=ue[u],o=s[i]||s["else"]||le,o===le)return;if(u=o[0],a=h[o[1]],a&&(r=o[2],r=void 0===r?t:r,!1===a()))return;if(u===ce)return c}}var ge=function(){this._cache=Object.create(null)};ge.prototype.parsePath=function(e){var t=this._cache[e];return t||(t=ve(e),t&&(this._cache[e]=t)),t||[]},ge.prototype.getPathValue=function(e,t){if(!s(e))return null;var n=this.parsePath(t);if(0===n.length)return null;var r=n.length,i=e,o=0;while(o/,be=/(?:@(?:\.[a-zA-Z]+)?:(?:[\w\-_|./]+|\([\w\-_:|./]+\)))/g,Me=/^@(?:\.([a-zA-Z]+))?:/,Ae=/[()]/g,we={upper:function(e){return e.toLocaleUpperCase()},lower:function(e){return e.toLocaleLowerCase()},capitalize:function(e){return""+e.charAt(0).toLocaleUpperCase()+e.substr(1)}},xe=new K,ke=function(e){var t=this;void 0===e&&(e={}),!V&&"undefined"!==typeof window&&window.Vue&&B(window.Vue);var n=e.locale||"en-US",r=!1!==e.fallbackLocale&&(e.fallbackLocale||"en-US"),i=e.messages||{},o=e.dateTimeFormats||e.datetimeFormats||{},a=e.numberFormats||{};this._vm=null,this._formatter=e.formatter||xe,this._modifiers=e.modifiers||{},this._missing=e.missing||null,this._root=e.root||null,this._sync=void 0===e.sync||!!e.sync,this._fallbackRoot=void 0===e.fallbackRoot||!!e.fallbackRoot,this._fallbackRootWithEmptyString=void 0===e.fallbackRootWithEmptyString||!!e.fallbackRootWithEmptyString,this._formatFallbackMessages=void 0!==e.formatFallbackMessages&&!!e.formatFallbackMessages,this._silentTranslationWarn=void 0!==e.silentTranslationWarn&&e.silentTranslationWarn,this._silentFallbackWarn=void 0!==e.silentFallbackWarn&&!!e.silentFallbackWarn,this._dateTimeFormatters={},this._numberFormatters={},this._path=new ge,this._dataListeners=new Set,this._componentInstanceCreatedListener=e.componentInstanceCreatedListener||null,this._preserveDirectiveContent=void 0!==e.preserveDirectiveContent&&!!e.preserveDirectiveContent,this.pluralizationRules=e.pluralizationRules||{},this._warnHtmlInMessage=e.warnHtmlInMessage||"off",this._postTranslation=e.postTranslation||null,this._escapeParameterHtml=e.escapeParameterHtml||!1,"__VUE_I18N_BRIDGE__"in e&&(this.__VUE_I18N_BRIDGE__=e.__VUE_I18N_BRIDGE__),this.getChoiceIndex=function(e,n){var r=Object.getPrototypeOf(t);if(r&&r.getChoiceIndex){var i=r.getChoiceIndex;return i.call(t,e,n)}var o=function(e,t){return e=Math.abs(e),2===t?e?e>1?1:0:1:e?Math.min(e,2):0};return t.locale in t.pluralizationRules?t.pluralizationRules[t.locale].apply(t,[e,n]):o(e,n)},this._exist=function(e,n){return!(!e||!n)&&(!f(t._path.getPathValue(e,n))||!!e[n])},"warn"!==this._warnHtmlInMessage&&"error"!==this._warnHtmlInMessage||Object.keys(i).forEach(function(e){t._checkLocaleMessage(e,t._warnHtmlInMessage,i[e])}),this._initVM({locale:n,fallbackLocale:r,messages:i,dateTimeFormats:o,numberFormats:a})},Le={vm:{configurable:!0},messages:{configurable:!0},dateTimeFormats:{configurable:!0},numberFormats:{configurable:!0},availableLocales:{configurable:!0},locale:{configurable:!0},fallbackLocale:{configurable:!0},formatFallbackMessages:{configurable:!0},missing:{configurable:!0},formatter:{configurable:!0},silentTranslationWarn:{configurable:!0},silentFallbackWarn:{configurable:!0},preserveDirectiveContent:{configurable:!0},warnHtmlInMessage:{configurable:!0},postTranslation:{configurable:!0},sync:{configurable:!0}};ke.prototype._checkLocaleMessage=function(e,t,n){var r=[],s=function(e,t,n,r){if(h(n))Object.keys(n).forEach(function(i){var o=n[i];h(o)?(r.push(i),r.push("."),s(e,t,o,r),r.pop(),r.pop()):(r.push(i),s(e,t,o,r),r.pop())});else if(a(n))n.forEach(function(n,i){h(n)?(r.push("["+i+"]"),r.push("."),s(e,t,n,r),r.pop(),r.pop()):(r.push("["+i+"]"),s(e,t,n,r),r.pop())});else if(l(n)){var c=_e.test(n);if(c){var u="Detected HTML in message '"+n+"' of keypath '"+r.join("")+"' at '"+t+"'. Consider component interpolation with '' to avoid XSS. See https://bit.ly/2ZqJzkp";"warn"===e?i(u):"error"===e&&o(u)}}};s(t,e,n,r)},ke.prototype._initVM=function(e){var t=V.config.silent;V.config.silent=!0,this._vm=new V({data:e,__VUE18N__INSTANCE__:!0}),V.config.silent=t},ke.prototype.destroyVM=function(){this._vm.$destroy()},ke.prototype.subscribeDataChanging=function(e){this._dataListeners.add(e)},ke.prototype.unsubscribeDataChanging=function(e){g(this._dataListeners,e)},ke.prototype.watchI18nData=function(){var e=this;return this._vm.$watch("$data",function(){var t=y(e._dataListeners),n=t.length;while(n--)V.nextTick(function(){t[n]&&t[n].$forceUpdate()})},{deep:!0})},ke.prototype.watchLocale=function(e){if(e){if(!this.__VUE_I18N_BRIDGE__)return null;var t=this,n=this._vm;return this.vm.$watch("locale",function(r){n.$set(n,"locale",r),t.__VUE_I18N_BRIDGE__&&e&&(e.locale.value=r),n.$forceUpdate()},{immediate:!0})}if(!this._sync||!this._root)return null;var r=this._vm;return this._root.$i18n.vm.$watch("locale",function(e){r.$set(r,"locale",e),r.$forceUpdate()},{immediate:!0})},ke.prototype.onComponentInstanceCreated=function(e){this._componentInstanceCreatedListener&&this._componentInstanceCreatedListener(e,this)},Le.vm.get=function(){return this._vm},Le.messages.get=function(){return v(this._getMessages())},Le.dateTimeFormats.get=function(){return v(this._getDateTimeFormats())},Le.numberFormats.get=function(){return v(this._getNumberFormats())},Le.availableLocales.get=function(){return Object.keys(this.messages).sort()},Le.locale.get=function(){return this._vm.locale},Le.locale.set=function(e){this._vm.$set(this._vm,"locale",e)},Le.fallbackLocale.get=function(){return this._vm.fallbackLocale},Le.fallbackLocale.set=function(e){this._localeChainCache={},this._vm.$set(this._vm,"fallbackLocale",e)},Le.formatFallbackMessages.get=function(){return this._formatFallbackMessages},Le.formatFallbackMessages.set=function(e){this._formatFallbackMessages=e},Le.missing.get=function(){return this._missing},Le.missing.set=function(e){this._missing=e},Le.formatter.get=function(){return this._formatter},Le.formatter.set=function(e){this._formatter=e},Le.silentTranslationWarn.get=function(){return this._silentTranslationWarn},Le.silentTranslationWarn.set=function(e){this._silentTranslationWarn=e},Le.silentFallbackWarn.get=function(){return this._silentFallbackWarn},Le.silentFallbackWarn.set=function(e){this._silentFallbackWarn=e},Le.preserveDirectiveContent.get=function(){return this._preserveDirectiveContent},Le.preserveDirectiveContent.set=function(e){this._preserveDirectiveContent=e},Le.warnHtmlInMessage.get=function(){return this._warnHtmlInMessage},Le.warnHtmlInMessage.set=function(e){var t=this,n=this._warnHtmlInMessage;if(this._warnHtmlInMessage=e,n!==e&&("warn"===e||"error"===e)){var r=this._getMessages();Object.keys(r).forEach(function(e){t._checkLocaleMessage(e,t._warnHtmlInMessage,r[e])})}},Le.postTranslation.get=function(){return this._postTranslation},Le.postTranslation.set=function(e){this._postTranslation=e},Le.sync.get=function(){return this._sync},Le.sync.set=function(e){this._sync=e},ke.prototype._getMessages=function(){return this._vm.messages},ke.prototype._getDateTimeFormats=function(){return this._vm.dateTimeFormats},ke.prototype._getNumberFormats=function(){return this._vm.numberFormats},ke.prototype._warnDefault=function(e,t,n,r,i,o){if(!f(n))return n;if(this._missing){var a=this._missing.apply(null,[e,t,r,i]);if(l(a))return a}else 0;if(this._formatFallbackMessages){var s=m.apply(void 0,i);return this._render(t,o,s.params,t)}return t},ke.prototype._isFallbackRoot=function(e){return(this._fallbackRootWithEmptyString?!e:f(e))&&!f(this._root)&&this._fallbackRoot},ke.prototype._isSilentFallbackWarn=function(e){return this._silentFallbackWarn instanceof RegExp?this._silentFallbackWarn.test(e):this._silentFallbackWarn},ke.prototype._isSilentFallback=function(e,t){return this._isSilentFallbackWarn(t)&&(this._isFallbackRoot()||e!==this.fallbackLocale)},ke.prototype._isSilentTranslationWarn=function(e){return this._silentTranslationWarn instanceof RegExp?this._silentTranslationWarn.test(e):this._silentTranslationWarn},ke.prototype._interpolate=function(e,t,n,r,i,o,s){if(!t)return null;var c,u=this._path.getPathValue(t,n);if(a(u)||h(u))return u;if(f(u)){if(!h(t))return null;if(c=t[n],!l(c)&&!p(c))return null}else{if(!l(u)&&!p(u))return null;c=u}return l(c)&&(c.indexOf("@:")>=0||c.indexOf("@.")>=0)&&(c=this._link(e,t,c,r,"raw",o,s)),this._render(c,i,o,n)},ke.prototype._link=function(e,t,n,r,i,o,s){var c=n,l=c.match(be);for(var u in l)if(l.hasOwnProperty(u)){var d=l[u],h=d.match(Me),f=h[0],p=h[1],m=d.replace(f,"").replace(Ae,"");if(_(s,m))return c;s.push(m);var v=this._interpolate(e,t,m,r,"raw"===i?"string":i,"raw"===i?void 0:o,s);if(this._isFallbackRoot(v)){if(!this._root)throw Error("unexpected error");var g=this._root.$i18n;v=g._translate(g._getMessages(),g.locale,g.fallbackLocale,m,r,i,o)}v=this._warnDefault(e,m,v,r,a(o)?o:[o],i),this._modifiers.hasOwnProperty(p)?v=this._modifiers[p](v):we.hasOwnProperty(p)&&(v=we[p](v)),s.pop(),c=v?c.replace(d,v):c}return c},ke.prototype._createMessageContext=function(e,t,n,r){var i=this,o=a(e)?e:[],c=s(e)?e:{},l=function(e){return o[e]},u=function(e){return c[e]},d=this._getMessages(),h=this.locale;return{list:l,named:u,values:e,formatter:t,path:n,messages:d,locale:h,linked:function(e){return i._interpolate(h,d[h]||{},e,null,r,void 0,[e])}}},ke.prototype._render=function(e,t,n,r){if(p(e))return e(this._createMessageContext(n,this._formatter||xe,r,t));var i=this._formatter.interpolate(e,n,r);return i||(i=xe.interpolate(e,n,r)),"string"!==t||l(i)?i:i.join("")},ke.prototype._appendItemToChain=function(e,t,n){var r=!1;return _(e,t)||(r=!0,t&&(r="!"!==t[t.length-1],t=t.replace(/!/g,""),e.push(t),n&&n[t]&&(r=n[t]))),r},ke.prototype._appendLocaleToChain=function(e,t,n){var r,i=t.split("-");do{var o=i.join("-");r=this._appendItemToChain(e,o,n),i.splice(-1,1)}while(i.length&&!0===r);return r},ke.prototype._appendBlockToChain=function(e,t,n){for(var r=!0,i=0;i0)o[a]=arguments[a+4];if(!e)return"";var s=m.apply(void 0,o);this._escapeParameterHtml&&(s.params=k(s.params));var c=s.locale||t,l=this._translate(n,c,this.fallbackLocale,e,r,"string",s.params);if(this._isFallbackRoot(l)){if(!this._root)throw Error("unexpected error");return(i=this._root).$t.apply(i,[e].concat(o))}return l=this._warnDefault(c,e,l,r,o,"string"),this._postTranslation&&null!==l&&void 0!==l&&(l=this._postTranslation(l,e)),l},ke.prototype.t=function(e){var t,n=[],r=arguments.length-1;while(r-- >0)n[r]=arguments[r+1];return(t=this)._t.apply(t,[e,this.locale,this._getMessages(),null].concat(n))},ke.prototype._i=function(e,t,n,r,i){var o=this._translate(n,t,this.fallbackLocale,e,r,"raw",i);if(this._isFallbackRoot(o)){if(!this._root)throw Error("unexpected error");return this._root.$i18n.i(e,t,i)}return this._warnDefault(t,e,o,r,[i],"raw")},ke.prototype.i=function(e,t,n){return e?(l(t)||(t=this.locale),this._i(e,t,this._getMessages(),null,n)):""},ke.prototype._tc=function(e,t,n,r,i){var o,a=[],s=arguments.length-5;while(s-- >0)a[s]=arguments[s+5];if(!e)return"";void 0===i&&(i=1);var c={count:i,n:i},l=m.apply(void 0,a);return l.params=Object.assign(c,l.params),a=null===l.locale?[l.params]:[l.locale,l.params],this.fetchChoice((o=this)._t.apply(o,[e,t,n,r].concat(a)),i)},ke.prototype.fetchChoice=function(e,t){if(!e||!l(e))return null;var n=e.split("|");return t=this.getChoiceIndex(t,n.length),n[t]?n[t].trim():e},ke.prototype.tc=function(e,t){var n,r=[],i=arguments.length-2;while(i-- >0)r[i]=arguments[i+2];return(n=this)._tc.apply(n,[e,this.locale,this._getMessages(),null,t].concat(r))},ke.prototype._te=function(e,t,n){var r=[],i=arguments.length-3;while(i-- >0)r[i]=arguments[i+3];var o=m.apply(void 0,r).locale||t;return this._exist(n[o],e)},ke.prototype.te=function(e,t){return this._te(e,this.locale,this._getMessages(),t)},ke.prototype.getLocaleMessage=function(e){return v(this._vm.messages[e]||{})},ke.prototype.setLocaleMessage=function(e,t){"warn"!==this._warnHtmlInMessage&&"error"!==this._warnHtmlInMessage||this._checkLocaleMessage(e,this._warnHtmlInMessage,t),this._vm.$set(this._vm.messages,e,t)},ke.prototype.mergeLocaleMessage=function(e,t){"warn"!==this._warnHtmlInMessage&&"error"!==this._warnHtmlInMessage||this._checkLocaleMessage(e,this._warnHtmlInMessage,t),this._vm.$set(this._vm.messages,e,A("undefined"!==typeof this._vm.messages[e]&&Object.keys(this._vm.messages[e]).length?Object.assign({},this._vm.messages[e]):{},t))},ke.prototype.getDateTimeFormat=function(e){return v(this._vm.dateTimeFormats[e]||{})},ke.prototype.setDateTimeFormat=function(e,t){this._vm.$set(this._vm.dateTimeFormats,e,t),this._clearDateTimeFormat(e,t)},ke.prototype.mergeDateTimeFormat=function(e,t){this._vm.$set(this._vm.dateTimeFormats,e,A(this._vm.dateTimeFormats[e]||{},t)),this._clearDateTimeFormat(e,t)},ke.prototype._clearDateTimeFormat=function(e,t){for(var n in t){var r=e+"__"+n;this._dateTimeFormatters.hasOwnProperty(r)&&delete this._dateTimeFormatters[r]}},ke.prototype._localizeDateTime=function(e,t,n,r,i,o){for(var a=t,s=r[a],c=this._getLocaleChain(t,n),l=0;l0)t[n]=arguments[n+1];var i=this.locale,o=null,a=null;return 1===t.length?(l(t[0])?o=t[0]:s(t[0])&&(t[0].locale&&(i=t[0].locale),t[0].key&&(o=t[0].key)),a=Object.keys(t[0]).reduce(function(e,n){var i;return _(r,n)?Object.assign({},e,(i={},i[n]=t[0][n],i)):e},null)):2===t.length&&(l(t[0])&&(o=t[0]),l(t[1])&&(i=t[1])),this._d(e,i,o,a)},ke.prototype.getNumberFormat=function(e){return v(this._vm.numberFormats[e]||{})},ke.prototype.setNumberFormat=function(e,t){this._vm.$set(this._vm.numberFormats,e,t),this._clearNumberFormat(e,t)},ke.prototype.mergeNumberFormat=function(e,t){this._vm.$set(this._vm.numberFormats,e,A(this._vm.numberFormats[e]||{},t)),this._clearNumberFormat(e,t)},ke.prototype._clearNumberFormat=function(e,t){for(var n in t){var r=e+"__"+n;this._numberFormatters.hasOwnProperty(r)&&delete this._numberFormatters[r]}},ke.prototype._getNumberFormatter=function(e,t,n,r,i,o){for(var a=t,s=r[a],c=this._getLocaleChain(t,n),l=0;l0)t[r]=arguments[r+1];var i=this.locale,o=null,a=null;return 1===t.length?l(t[0])?o=t[0]:s(t[0])&&(t[0].locale&&(i=t[0].locale),t[0].key&&(o=t[0].key),a=Object.keys(t[0]).reduce(function(e,r){var i;return _(n,r)?Object.assign({},e,(i={},i[r]=t[0][r],i)):e},null)):2===t.length&&(l(t[0])&&(o=t[0]),l(t[1])&&(i=t[1])),this._n(e,i,o,a)},ke.prototype._ntp=function(e,t,n,r){if(!ke.availabilities.numberFormat)return[];if(!n){var i=r?new Intl.NumberFormat(t,r):new Intl.NumberFormat(t);return i.formatToParts(e)}var o=this._getNumberFormatter(e,t,this.fallbackLocale,this._getNumberFormats(),n,r),a=o&&o.formatToParts(e);if(this._isFallbackRoot(a)){if(!this._root)throw Error("unexpected error");return this._root.$i18n._ntp(e,t,n,r)}return a||[]},Object.defineProperties(ke.prototype,Le),Object.defineProperty(ke,"availabilities",{get:function(){if(!ye){var e="undefined"!==typeof Intl;ye={dateTimeFormat:e&&"undefined"!==typeof Intl.DateTimeFormat,numberFormat:e&&"undefined"!==typeof Intl.NumberFormat}}return ye}}),ke.install=B,ke.version="8.28.2",t.A=ke},24415:function(e,t){"use strict";t.A={install:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.name||"ref";e.directive(n,{bind:function(t,n,r){e.nextTick(function(){n.value(r.componentInstance||t,r.key)}),n.value(r.componentInstance||t,r.key)},update:function(e,t,r,i){if(i.data&&i.data.directives){var o=i.data.directives.find(function(e){var t=e.name;return t===n});if(o&&o.value!==t.value)return o&&o.value(null,i.key),void t.value(r.componentInstance||e,r.key)}r.componentInstance===i.componentInstance&&r.elm===i.elm||t.value(r.componentInstance||e,r.key)},unbind:function(e,t,n){t.value(null,n.key)}})}}},40173:function(e,t,n){"use strict";function r(e,t){for(var n in t)e[n]=t[n];return e}n.d(t,{Ay:function(){return At}});var i=/[!'()*]/g,o=function(e){return"%"+e.charCodeAt(0).toString(16)},a=/%2C/g,s=function(e){return encodeURIComponent(e).replace(i,o).replace(a,",")};function c(e){try{return decodeURIComponent(e)}catch(t){0}return e}function l(e,t,n){void 0===t&&(t={});var r,i=n||d;try{r=i(e||"")}catch(s){r={}}for(var o in t){var a=t[o];r[o]=Array.isArray(a)?a.map(u):u(a)}return r}var u=function(e){return null==e||"object"===typeof e?e:String(e)};function d(e){var t={};return e=e.trim().replace(/^(\?|#|&)/,""),e?(e.split("&").forEach(function(e){var n=e.replace(/\+/g," ").split("="),r=c(n.shift()),i=n.length>0?c(n.join("=")):null;void 0===t[r]?t[r]=i:Array.isArray(t[r])?t[r].push(i):t[r]=[t[r],i]}),t):t}function h(e){var t=e?Object.keys(e).map(function(t){var n=e[t];if(void 0===n)return"";if(null===n)return s(t);if(Array.isArray(n)){var r=[];return n.forEach(function(e){void 0!==e&&(null===e?r.push(s(t)):r.push(s(t)+"="+s(e)))}),r.join("&")}return s(t)+"="+s(n)}).filter(function(e){return e.length>0}).join("&"):null;return t?"?"+t:""}var f=/\/?$/;function p(e,t,n,r){var i=r&&r.options.stringifyQuery,o=t.query||{};try{o=m(o)}catch(s){}var a={name:t.name||e&&e.name,meta:e&&e.meta||{},path:t.path||"/",hash:t.hash||"",query:o,params:t.params||{},fullPath:y(t,i),matched:e?g(e):[]};return n&&(a.redirectedFrom=y(n,i)),Object.freeze(a)}function m(e){if(Array.isArray(e))return e.map(m);if(e&&"object"===typeof e){var t={};for(var n in e)t[n]=m(e[n]);return t}return e}var v=p(null,{path:"/"});function g(e){var t=[];while(e)t.unshift(e),e=e.parent;return t}function y(e,t){var n=e.path,r=e.query;void 0===r&&(r={});var i=e.hash;void 0===i&&(i="");var o=t||h;return(n||"/")+o(r)+i}function _(e,t,n){return t===v?e===t:!!t&&(e.path&&t.path?e.path.replace(f,"")===t.path.replace(f,"")&&(n||e.hash===t.hash&&b(e.query,t.query)):!(!e.name||!t.name)&&(e.name===t.name&&(n||e.hash===t.hash&&b(e.query,t.query)&&b(e.params,t.params))))}function b(e,t){if(void 0===e&&(e={}),void 0===t&&(t={}),!e||!t)return e===t;var n=Object.keys(e).sort(),r=Object.keys(t).sort();return n.length===r.length&&n.every(function(n,i){var o=e[n],a=r[i];if(a!==n)return!1;var s=t[n];return null==o||null==s?o===s:"object"===typeof o&&"object"===typeof s?b(o,s):String(o)===String(s)})}function M(e,t){return 0===e.path.replace(f,"/").indexOf(t.path.replace(f,"/"))&&(!t.hash||e.hash===t.hash)&&A(e.query,t.query)}function A(e,t){for(var n in t)if(!(n in e))return!1;return!0}function w(e){for(var t=0;t=0&&(t=e.slice(r),e=e.slice(0,r));var i=e.indexOf("?");return i>=0&&(n=e.slice(i+1),e=e.slice(0,i)),{path:e,query:n,hash:t}}function z(e){return e.replace(/\/(?:\s*\/)+/g,"/")}var T=Array.isArray||function(e){return"[object Array]"==Object.prototype.toString.call(e)},O=J,H=E,D=j,Y=$,V=G,P=new RegExp(["(\\\\.)","([\\/.])?(?:(?:\\:(\\w+)(?:\\(((?:\\\\.|[^\\\\()])+)\\))?|\\(((?:\\\\.|[^\\\\()])+)\\))([+*?])?|(\\*))"].join("|"),"g");function E(e,t){var n,r=[],i=0,o=0,a="",s=t&&t.delimiter||"/";while(null!=(n=P.exec(e))){var c=n[0],l=n[1],u=n.index;if(a+=e.slice(o,u),o=u+c.length,l)a+=l[1];else{var d=e[o],h=n[2],f=n[3],p=n[4],m=n[5],v=n[6],g=n[7];a&&(r.push(a),a="");var y=null!=h&&null!=d&&d!==h,_="+"===v||"*"===v,b="?"===v||"*"===v,M=n[2]||s,A=p||m;r.push({name:f||i++,prefix:h||"",delimiter:M,optional:b,repeat:_,partial:y,asterisk:!!g,pattern:A?N(A):g?".*":"[^"+R(M)+"]+?"})}}return o1||!x.length)return 0===x.length?e():e("span",{},x)}if("a"===this.tag)w.on=A,w.attrs={href:c,"aria-current":y};else{var k=ae(this.$slots.default);if(k){k.isStatic=!1;var L=k.data=r({},k.data);for(var C in L.on=L.on||{},L.on){var S=L.on[C];C in A&&(L.on[C]=Array.isArray(S)?S:[S])}for(var z in A)z in L.on?L.on[z].push(A[z]):L.on[z]=b;var T=k.data.attrs=r({},k.data.attrs);T.href=c,T["aria-current"]=y}else w.on=A}return e(this.tag,w,this.$slots.default)}};function oe(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&(void 0===e.button||0===e.button)){if(e.currentTarget&&e.currentTarget.getAttribute){var t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function ae(e){if(e)for(var t,n=0;n-1&&(s.params[d]=n.params[d]);return s.path=Z(l.path,s.params,'named route "'+c+'"'),h(l,s,a)}if(s.path){s.params={};for(var f=0;f-1}function Ke(e,t){return Be(e)&&e._isRouter&&(null==t||e.type===t)}function Ue(e,t,n){var r=function(i){i>=e.length?n():e[i]?t(e[i],function(){r(i+1)}):r(i+1)};r(0)}function qe(e){return function(t,n,r){var i=!1,o=0,a=null;Ge(e,function(e,t,n,s){if("function"===typeof e&&void 0===e.cid){i=!0,o++;var c,l=Qe(function(t){Ze(t)&&(t=t.default),e.resolved="function"===typeof t?t:ee.extend(t),n.components[s]=t,o--,o<=0&&r()}),u=Qe(function(e){var t="Failed to resolve async component "+s+": "+e;a||(a=Be(e)?e:new Error(t),r(a))});try{c=e(l,u)}catch(h){u(h)}if(c)if("function"===typeof c.then)c.then(l,u);else{var d=c.component;d&&"function"===typeof d.then&&d.then(l,u)}}}),i||r()}}function Ge(e,t){return Je(e.map(function(e){return Object.keys(e.components).map(function(n){return t(e.components[n],e.instances[n],e,n)})}))}function Je(e){return Array.prototype.concat.apply([],e)}var Xe="function"===typeof Symbol&&"symbol"===typeof Symbol.toStringTag;function Ze(e){return e.__esModule||Xe&&"Module"===e[Symbol.toStringTag]}function Qe(e){var t=!1;return function(){var n=[],r=arguments.length;while(r--)n[r]=arguments[r];if(!t)return t=!0,e.apply(this,n)}}var et=function(e,t){this.router=e,this.base=tt(t),this.current=v,this.pending=null,this.ready=!1,this.readyCbs=[],this.readyErrorCbs=[],this.errorCbs=[],this.listeners=[]};function tt(e){if(!e)if(ce){var t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^https?:\/\/[^\/]+/,"")}else e="/";return"/"!==e.charAt(0)&&(e="/"+e),e.replace(/\/$/,"")}function nt(e,t){var n,r=Math.max(e.length,t.length);for(n=0;n0)){var t=this.router,n=t.options.scrollBehavior,r=Ye&&n;r&&this.listeners.push(Ae());var i=function(){var n=e.current,i=dt(e.base);e.current===v&&i===e._startLocation||e.transitionTo(i,function(e){r&&we(t,e,n,!0)})};window.addEventListener("popstate",i),this.listeners.push(function(){window.removeEventListener("popstate",i)})}},t.prototype.go=function(e){window.history.go(e)},t.prototype.push=function(e,t,n){var r=this,i=this,o=i.current;this.transitionTo(e,function(e){Ve(z(r.base+e.fullPath)),we(r.router,e,o,!1),t&&t(e)},n)},t.prototype.replace=function(e,t,n){var r=this,i=this,o=i.current;this.transitionTo(e,function(e){Pe(z(r.base+e.fullPath)),we(r.router,e,o,!1),t&&t(e)},n)},t.prototype.ensureURL=function(e){if(dt(this.base)!==this.current.fullPath){var t=z(this.base+this.current.fullPath);e?Ve(t):Pe(t)}},t.prototype.getCurrentLocation=function(){return dt(this.base)},t}(et);function dt(e){var t=window.location.pathname,n=t.toLowerCase(),r=e.toLowerCase();return!e||n!==r&&0!==n.indexOf(z(r+"/"))||(t=t.slice(e.length)),(t||"/")+window.location.search+window.location.hash}var ht=function(e){function t(t,n,r){e.call(this,t,n),r&&ft(this.base)||pt()}return e&&(t.__proto__=e),t.prototype=Object.create(e&&e.prototype),t.prototype.constructor=t,t.prototype.setupListeners=function(){var e=this;if(!(this.listeners.length>0)){var t=this.router,n=t.options.scrollBehavior,r=Ye&&n;r&&this.listeners.push(Ae());var i=function(){var t=e.current;pt()&&e.transitionTo(mt(),function(n){r&&we(e.router,n,t,!0),Ye||yt(n.fullPath)})},o=Ye?"popstate":"hashchange";window.addEventListener(o,i),this.listeners.push(function(){window.removeEventListener(o,i)})}},t.prototype.push=function(e,t,n){var r=this,i=this,o=i.current;this.transitionTo(e,function(e){gt(e.fullPath),we(r.router,e,o,!1),t&&t(e)},n)},t.prototype.replace=function(e,t,n){var r=this,i=this,o=i.current;this.transitionTo(e,function(e){yt(e.fullPath),we(r.router,e,o,!1),t&&t(e)},n)},t.prototype.go=function(e){window.history.go(e)},t.prototype.ensureURL=function(e){var t=this.current.fullPath;mt()!==t&&(e?gt(t):yt(t))},t.prototype.getCurrentLocation=function(){return mt()},t}(et);function ft(e){var t=dt(e);if(!/^\/#/.test(t))return window.location.replace(z(e+"/#"+t)),!0}function pt(){var e=mt();return"/"===e.charAt(0)||(yt("/"+e),!1)}function mt(){var e=window.location.href,t=e.indexOf("#");return t<0?"":(e=e.slice(t+1),e)}function vt(e){var t=window.location.href,n=t.indexOf("#"),r=n>=0?t.slice(0,n):t;return r+"#"+e}function gt(e){Ye?Ve(vt(e)):window.location.hash=e}function yt(e){Ye?Pe(vt(e)):window.location.replace(vt(e))}var _t=function(e){function t(t,n){e.call(this,t,n),this.stack=[],this.index=-1}return e&&(t.__proto__=e),t.prototype=Object.create(e&&e.prototype),t.prototype.constructor=t,t.prototype.push=function(e,t,n){var r=this;this.transitionTo(e,function(e){r.stack=r.stack.slice(0,r.index+1).concat(e),r.index++,t&&t(e)},n)},t.prototype.replace=function(e,t,n){var r=this;this.transitionTo(e,function(e){r.stack=r.stack.slice(0,r.index).concat(e),t&&t(e)},n)},t.prototype.go=function(e){var t=this,n=this.index+e;if(!(n<0||n>=this.stack.length)){var r=this.stack[n];this.confirmTransition(r,function(){var e=t.current;t.index=n,t.updateRoute(r),t.router.afterHooks.forEach(function(t){t&&t(r,e)})},function(e){Ke(e,Ee.duplicated)&&(t.index=n)})}},t.prototype.getCurrentLocation=function(){var e=this.stack[this.stack.length-1];return e?e.fullPath:"/"},t.prototype.ensureURL=function(){},t}(et),bt=function(e){void 0===e&&(e={}),this.app=null,this.apps=[],this.options=e,this.beforeHooks=[],this.resolveHooks=[],this.afterHooks=[],this.matcher=fe(e.routes||[],this);var t=e.mode||"hash";switch(this.fallback="history"===t&&!Ye&&!1!==e.fallback,this.fallback&&(t="hash"),ce||(t="abstract"),this.mode=t,t){case"history":this.history=new ut(this,e.base);break;case"hash":this.history=new ht(this,e.base,this.fallback);break;case"abstract":this.history=new _t(this,e.base);break;default:0}},Mt={currentRoute:{configurable:!0}};bt.prototype.match=function(e,t,n){return this.matcher.match(e,t,n)},Mt.currentRoute.get=function(){return this.history&&this.history.current},bt.prototype.init=function(e){var t=this;if(this.apps.push(e),e.$once("hook:destroyed",function(){var n=t.apps.indexOf(e);n>-1&&t.apps.splice(n,1),t.app===e&&(t.app=t.apps[0]||null),t.app||t.history.teardown()}),!this.app){this.app=e;var n=this.history;if(n instanceof ut||n instanceof ht){var r=function(e){var r=n.current,i=t.options.scrollBehavior,o=Ye&&i;o&&"fullPath"in e&&we(t,e,r,!1)},i=function(e){n.setupListeners(),r(e)};n.transitionTo(n.getCurrentLocation(),i,i)}n.listen(function(e){t.apps.forEach(function(t){t._route=e})})}},bt.prototype.beforeEach=function(e){return wt(this.beforeHooks,e)},bt.prototype.beforeResolve=function(e){return wt(this.resolveHooks,e)},bt.prototype.afterEach=function(e){return wt(this.afterHooks,e)},bt.prototype.onReady=function(e,t){this.history.onReady(e,t)},bt.prototype.onError=function(e){this.history.onError(e)},bt.prototype.push=function(e,t,n){var r=this;if(!t&&!n&&"undefined"!==typeof Promise)return new Promise(function(t,n){r.history.push(e,t,n)});this.history.push(e,t,n)},bt.prototype.replace=function(e,t,n){var r=this;if(!t&&!n&&"undefined"!==typeof Promise)return new Promise(function(t,n){r.history.replace(e,t,n)});this.history.replace(e,t,n)},bt.prototype.go=function(e){this.history.go(e)},bt.prototype.back=function(){this.go(-1)},bt.prototype.forward=function(){this.go(1)},bt.prototype.getMatchedComponents=function(e){var t=e?e.matched?e:this.resolve(e).route:this.currentRoute;return t?[].concat.apply([],t.matched.map(function(e){return Object.keys(e.components).map(function(t){return e.components[t]})})):[]},bt.prototype.resolve=function(e,t,n){t=t||this.history.current;var r=Q(e,t,n,this),i=this.match(r,t),o=i.redirectedFrom||i.fullPath,a=this.history.base,s=xt(a,o,this.mode);return{location:r,route:i,href:s,normalizedTo:r,resolved:i}},bt.prototype.getRoutes=function(){return this.matcher.getRoutes()},bt.prototype.addRoute=function(e,t){this.matcher.addRoute(e,t),this.history.current!==v&&this.history.transitionTo(this.history.getCurrentLocation())},bt.prototype.addRoutes=function(e){this.matcher.addRoutes(e),this.history.current!==v&&this.history.transitionTo(this.history.getCurrentLocation())},Object.defineProperties(bt.prototype,Mt);var At=bt;function wt(e,t){return e.push(t),function(){var n=e.indexOf(t);n>-1&&e.splice(n,1)}}function xt(e,t,n){var r="hash"===n?"#"+t:t;return e?z(e+"/"+r):r}bt.install=se,bt.version="3.6.5",bt.isNavigationFailure=Ke,bt.NavigationFailureType=Ee,bt.START_LOCATION=v,ce&&window.Vue&&window.Vue.use(bt)},85471:function(e,t,n){"use strict";n.d(t,{Ay:function(){return pi},EW:function(){return tt},IJ:function(){return Ze},KR:function(){return Xe},dY:function(){return xn},nI:function(){return ge},sV:function(){return Cn},wB:function(){return ct},xo:function(){return Sn}}); diff --git a/frontend_vue/.editorconfig b/frontend_vue/.editorconfig index 6f77dff..bb85462 100644 --- a/frontend_vue/.editorconfig +++ b/frontend_vue/.editorconfig @@ -1,7 +1,8 @@ [*] charset=utf-8 end_of_line=lf -insert_final_newline=false +insert_final_newline=true +trim_trailing_whitespace=true indent_style=space indent_size=2 @@ -36,4 +37,3 @@ indent_size=2 [{.analysis_options,*.yml,*.yaml}] indent_style=space indent_size=2 - diff --git a/frontend_vue/.eslintrc.js b/frontend_vue/.eslintrc.js index 5bece06..2f6e589 100644 --- a/frontend_vue/.eslintrc.js +++ b/frontend_vue/.eslintrc.js @@ -3,7 +3,7 @@ module.exports = { env: { node: true }, - 'extends': [ + extends: [ 'plugin:vue/strongly-recommended', '@vue/standard' ], @@ -27,12 +27,39 @@ module.exports = { 'vue/component-name-in-template-casing': 0, 'vue/html-closing-bracket-spacing': 0, 'vue/singleline-html-element-content-newline': 0, - 'vue/no-unused-components': 0, + 'vue/no-unused-components': 'error', + 'vue/no-unused-vars': 'error', 'vue/multiline-html-element-content-newline': 0, 'vue/no-use-v-if-with-v-for': 0, 'vue/html-closing-bracket-newline': 0, 'vue/no-parsing-error': 0, - 'no-tabs': 0, + 'no-tabs': 'error', + 'no-trailing-spaces': 'error', + 'eol-last': [ + 'error', + 'always' + ], + 'no-unused-vars': [ + 'error', + { + 'args': 'after-used', + 'argsIgnorePattern': '^_', + 'ignoreRestSiblings': true, + 'varsIgnorePattern': '^_' + } + ], + 'import/no-unused-modules': [ + 'error', + { + 'missingExports': false, + 'src': ['src/**/*.js'], + 'unusedExports': true + } + ], + 'space-before-function-paren': [ + 'error', + 'always' + ], 'quotes': [ 2, 'single', diff --git a/frontend_vue/.eslintrc.json b/frontend_vue/.eslintrc.json deleted file mode 100644 index ed223c7..0000000 --- a/frontend_vue/.eslintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "space-before-function-paren": 0 - } -} diff --git a/frontend_vue/.prettierrc b/frontend_vue/.prettierrc index b0e80f7..8252c3c 100644 --- a/frontend_vue/.prettierrc +++ b/frontend_vue/.prettierrc @@ -1,6 +1,8 @@ { "printWidth": 120, + "tabWidth": 2, + "useTabs": false, "semi": false, "singleQuote": true, - "prettier.spaceBeforeFunctionParen": true + "endOfLine": "lf" } diff --git a/frontend_vue/package.json b/frontend_vue/package.json index 8a44296..31cb4da 100644 --- a/frontend_vue/package.json +++ b/frontend_vue/package.json @@ -7,10 +7,14 @@ "build": "vue-cli-service build --no-lint", "test:unit": "vue-cli-service test:unit", "lint": "vue-cli-service lint", + "lint:check": "eslint --ext .js,.vue src tests/unit vue.config.js babel.config.js postcss.config.js jest.config.js --max-warnings=0", + "lint:fix": "eslint --ext .js,.vue src tests/unit vue.config.js babel.config.js postcss.config.js jest.config.js --fix", "build:preview": "vue-cli-service build --no-module --mode preview", "lint:nofix": "vue-cli-service lint --no-fix", "lint:js": "eslint src/**/*.js --fix", - "lint:css": "stylelint src/**/*.*ss --fix --custom-syntax postcss-less" + "lint:css": "stylelint src/**/*.*ss --fix --custom-syntax postcss-less", + "format": "prettier --write \"src/**/*.{js,vue,json,css,less,scss,md}\" \"tests/unit/**/*.{js,vue}\" vue.config.js babel.config.js postcss.config.js jest.config.js jsconfig.json .eslintrc.js .prettierrc", + "format:check": "prettier --check \"src/**/*.{js,vue,json,css,less,scss,md}\" \"tests/unit/**/*.{js,vue}\" vue.config.js babel.config.js postcss.config.js jest.config.js jsconfig.json .eslintrc.js .prettierrc" }, "dependencies": { "@ant-design-vue/pro-layout": "^1.0.12", @@ -61,6 +65,7 @@ "babel-plugin-import": "^1.13.3", "babel-plugin-transform-remove-console": "^6.9.4", "eslint": "^7.32.0", + "eslint-plugin-import": "^2.26.0", "eslint-plugin-html": "^6.2.0", "eslint-plugin-vue": "^7.20.0", "file-loader": "^6.2.0", @@ -69,6 +74,7 @@ "less-loader": "^5.0.0", "postcss": "^8.3.5", "postcss-less": "^6.0.0", + "prettier": "^2.7.1", "regenerator-runtime": "^0.13.9", "stylelint": "^14.8.5", "stylelint-config-css-modules": "^4.1.0", diff --git a/frontend_vue/public/index.html b/frontend_vue/public/index.html index 8b8b263..b08fcc1 100644 --- a/frontend_vue/public/index.html +++ b/frontend_vue/public/index.html @@ -18,14 +18,14 @@ position: relative; overflow: hidden; } - + .first-loading-wrp > h2 { font-size: 32px; margin-bottom: 40px; color: #333; font-weight: 600; } - + .first-loading-wrp .loading-wrp { padding: 40px; display: flex; @@ -36,7 +36,7 @@ width: 100%; max-width: 600px; } - + /* Pixel-style running cat animation container */ .pixel-cat-container { position: relative; @@ -45,7 +45,7 @@ margin: 0 auto 30px; overflow: hidden; } - + /* Pixel cat body */ .pixel-cat { position: absolute; @@ -58,14 +58,14 @@ image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; } - + /* All pixel elements use sharp edges */ .pixel-cat * { image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; } - + /* Cat head - built from pixel blocks */ .cat-head { position: absolute; @@ -73,7 +73,7 @@ top: 0; width: 16px; height: 14px; - background: + background: /* White base of the head */ linear-gradient(#fff, #fff) 2px 4px / 12px 10px no-repeat, /* Black patch on the left side of the face */ @@ -82,7 +82,7 @@ linear-gradient(#fff, #fff) 4px 2px / 8px 2px no-repeat; animation: headBob 0.4s steps(2) infinite; } - + /* Left ear - black pointed ear */ .cat-ear-left { position: absolute; @@ -90,12 +90,12 @@ top: -2px; width: 4px; height: 6px; - background: + background: linear-gradient(#000, #000) 0px 4px / 4px 2px no-repeat, linear-gradient(#000, #000) 1px 2px / 2px 2px no-repeat, linear-gradient(#000, #000) 1px 0px / 2px 2px no-repeat; } - + /* Right ear - white pointed ear */ .cat-ear-right { position: absolute; @@ -103,13 +103,13 @@ top: -2px; width: 4px; height: 6px; - background: + background: linear-gradient(#fff, #fff) 0px 4px / 4px 2px no-repeat, linear-gradient(#fff, #fff) 1px 2px / 2px 2px no-repeat, linear-gradient(#fff, #fff) 1px 0px / 2px 2px no-repeat; box-shadow: inset 0 0 0 1px #000; } - + .cat-ear-right::after { content: ''; position: absolute; @@ -119,7 +119,7 @@ border-width: 0 1px 0 0; box-sizing: border-box; } - + /* Left eye - white eye on black background */ .cat-eye-left { position: absolute; @@ -129,7 +129,7 @@ height: 4px; background: #fff; } - + .cat-eye-left::after { content: ''; position: absolute; @@ -139,7 +139,7 @@ height: 2px; background: #000; } - + /* Right eye - black eye on white background */ .cat-eye-right { position: absolute; @@ -151,7 +151,7 @@ border: 1px solid #000; box-sizing: border-box; } - + .cat-eye-right::after { content: ''; position: absolute; @@ -161,7 +161,7 @@ height: 2px; background: #000; } - + /* Nose - small pink square */ .cat-nose { position: absolute; @@ -171,7 +171,7 @@ height: 2px; background: #000; } - + /* Whiskers - pixel lines */ .cat-whiskers { position: absolute; @@ -180,7 +180,7 @@ width: 16px; height: 4px; } - + .cat-whiskers::before { content: ''; position: absolute; @@ -191,7 +191,7 @@ background: #000; box-shadow: 0 2px 0 #000; } - + .cat-whiskers::after { content: ''; position: absolute; @@ -202,7 +202,7 @@ background: #000; box-shadow: 0 2px 0 #000; } - + /* Body - black and white pixel blocks */ .cat-body { position: absolute; @@ -210,7 +210,7 @@ top: 14px; width: 14px; height: 10px; - background: + background: /* White section */ linear-gradient(#fff, #fff) 6px 0 / 8px 10px no-repeat, /* Black section */ @@ -218,7 +218,7 @@ border: 1px solid #000; box-sizing: border-box; } - + /* Front leg - left, black */ .cat-leg-front-left { position: absolute; @@ -229,7 +229,7 @@ background: #000; animation: legFront 0.2s steps(2) infinite; } - + /* Front leg - right, white with border */ .cat-leg-front-right { position: absolute; @@ -242,7 +242,7 @@ box-sizing: border-box; animation: legFront 0.2s steps(2) infinite 0.1s; } - + /* Back leg - left, white with border */ .cat-leg-back-left { position: absolute; @@ -255,7 +255,7 @@ box-sizing: border-box; animation: legBack 0.2s steps(2) infinite 0.1s; } - + /* Back leg - right, black */ .cat-leg-back-right { position: absolute; @@ -266,7 +266,7 @@ background: #000; animation: legBack 0.2s steps(2) infinite; } - + /* Tail - a long curved pixel tail */ .cat-tail { position: absolute; @@ -274,7 +274,7 @@ top: 10px; width: 12px; height: 10px; - background: + background: /* Tail base */ linear-gradient(#000, #000) 0 6px / 3px 3px no-repeat, /* Middle of the tail */ @@ -286,43 +286,43 @@ animation: tailWag 0.3s steps(2) infinite alternate; transform-origin: left bottom; } - + /* Running animation - slight vertical bounce */ @keyframes catRun { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } } - + /* Movement animation - move left and right */ @keyframes catMove { 0% { left: -40px; } 100% { left: calc(100% + 40px); } } - + /* Front leg animation */ @keyframes legFront { 0%, 100% { transform: rotate(-15deg); } 50% { transform: rotate(15deg); } } - + /* Back leg animation */ @keyframes legBack { 0%, 100% { transform: rotate(15deg); } 50% { transform: rotate(-15deg); } } - + /* Tail swing */ @keyframes tailWag { 0% { transform: rotate(-10deg); } 100% { transform: rotate(10deg); } } - + /* Subtle head sway */ @keyframes headBob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-1px); } } - + /* Ground effect - pixel style */ .ground { position: absolute; @@ -340,12 +340,12 @@ animation: groundMove 0.3s linear infinite; image-rendering: pixelated; } - + @keyframes groundMove { 0% { background-position: 0 0; } 100% { background-position: 12px 0; } } - + /* Brand text */ .brand-text { display: flex; @@ -357,21 +357,21 @@ margin-top: 20px; letter-spacing: 2px; } - + /* Dark theme adaptation */ @media (prefers-color-scheme: dark) { .first-loading-wrp { background: #141414; } - + .first-loading-wrp > h2 { color: #fff; } - + .brand-text { color: #fff; } - + .ground { background: repeating-linear-gradient( 90deg, @@ -381,32 +381,32 @@ #333 12px ); } - + /* Use light gray instead of pure white in dark mode */ .cat-head { - background: + background: linear-gradient(#ddd, #ddd) 2px 4px / 12px 10px no-repeat, linear-gradient(#000, #000) 2px 4px / 6px 10px no-repeat, linear-gradient(#ddd, #ddd) 4px 2px / 8px 2px no-repeat; } - + .cat-body { - background: + background: linear-gradient(#ddd, #ddd) 6px 0 / 8px 10px no-repeat, linear-gradient(#000, #000) 0 0 / 8px 10px no-repeat; } - + .cat-leg-front-right, .cat-leg-back-left { background: #ddd; } - + .cat-eye-left, .cat-eye-right { background: #ddd; } } - + /* Ensure the pixel style renders correctly in all browsers */ .pixel-cat, .pixel-cat * { @@ -415,18 +415,18 @@ image-rendering: pixelated; image-rendering: crisp-edges; } - + /* Mobile adaptation */ @media (max-width: 768px) { .pixel-cat-container { transform: scale(1.5); } - + .first-loading-wrp > h2 { font-size: 24px; margin-bottom: 30px; } - + .brand-text { font-size: 20px; } diff --git a/frontend_vue/src/components/NumberInfo/index.less b/frontend_vue/src/components/NumberInfo/index.less index 82cbd7e..5afa8cb 100644 --- a/frontend_vue/src/components/NumberInfo/index.less +++ b/frontend_vue/src/components/NumberInfo/index.less @@ -42,14 +42,14 @@ font-size: 12px; transform: scale(.82); } - + .anticon-caret-up { color: @red-6; } .anticon-caret-down { color: @green-6; - } + } } } } diff --git a/frontend_vue/src/components/Table/README.md b/frontend_vue/src/components/Table/README.md index 1d2c9d0..7c921b7 100644 --- a/frontend_vue/src/components/Table/README.md +++ b/frontend_vue/src/components/Table/README.md @@ -208,7 +208,7 @@ Table 重封装组件说明 内置属性 ---- -> 除去 `a-table` 自带属性外,还而外提供了一些额外属性属性 +> 除去 `a-table` 自带属性外,还而外提供了一些额外属性属性 | 属性 | 说明 | 类型 | 默认值 | @@ -222,7 +222,7 @@ Table 重封装组件说明 ```javascript alert: { - show: Boolean, + show: Boolean, clear: [Function, Boolean] } ``` diff --git a/frontend_vue/src/locales/lang/zh-CN.js b/frontend_vue/src/locales/lang/zh-CN.js index 574a19c..4745caa 100644 --- a/frontend_vue/src/locales/lang/zh-CN.js +++ b/frontend_vue/src/locales/lang/zh-CN.js @@ -2049,7 +2049,6 @@ const locale = { 'settings.field.CCXT_DEFAULT_EXCHANGE': 'CCXT默认交易所', 'settings.field.CCXT_TIMEOUT': 'CCXT超时(ms)', 'settings.field.CCXT_PROXY': 'CCXT代理', - 'settings.field.AKSHARE_TIMEOUT': 'Akshare超时(秒)', 'settings.field.YFINANCE_TIMEOUT': 'YFinance超时(秒)', 'settings.field.TIINGO_API_KEY': 'Tiingo API Key', 'settings.field.TIINGO_TIMEOUT': 'Tiingo超时(秒)', @@ -2100,7 +2099,6 @@ const locale = { 'settings.desc.FINNHUB_RATE_LIMIT': 'Finnhub API速率限制(每分钟请求数)', 'settings.desc.TIINGO_API_KEY': 'Tiingo API密钥,用于外汇/贵金属数据(免费版不支持1分钟数据)', 'settings.desc.TIINGO_TIMEOUT': 'Tiingo API请求超时时间', - 'settings.desc.AKSHARE_TIMEOUT': 'Akshare API超时时间,用于A股数据', 'settings.desc.YFINANCE_TIMEOUT': 'Yahoo Finance API超时时间', 'settings.desc.SIGNAL_WEBHOOK_URL': '信号通知Webhook地址(POST JSON)', 'settings.desc.SIGNAL_WEBHOOK_TOKEN': 'Webhook认证令牌,通过请求头发送', @@ -2131,10 +2129,10 @@ const locale = { 'settings.desc.PROXY_PORT': '代理服务器端口(留空则禁用代理)', 'settings.desc.PROXY_SCHEME': '代理协议类型。socks5h 表示DNS也走代理', 'settings.desc.PROXY_URL': '完整代理URL(设置后覆盖上面的配置)', - 'settings.desc.SEARCH_PROVIDER': '网页搜索提供商,用于AI研究功能。博查(Bocha)推荐用于A股新闻', + 'settings.desc.SEARCH_PROVIDER': '网页搜索提供商,用于AI研究功能', 'settings.desc.SEARCH_MAX_RESULTS': '搜索返回的最大结果数', 'settings.desc.TAVILY_API_KEYS': 'Tavily搜索API密钥,多个用逗号分隔可轮换。免费1000次/月', - 'settings.desc.BOCHA_API_KEYS': '博查搜索API密钥,多个用逗号分隔可轮换。A股新闻搜索效果最佳', + 'settings.desc.BOCHA_API_KEYS': '博查搜索API密钥,多个用逗号分隔可轮换', 'settings.desc.SERPAPI_KEYS': 'SerpAPI密钥,用于Google/Bing搜索,多个用逗号分隔可轮换', 'settings.desc.SEARCH_GOOGLE_API_KEY': 'Google自定义搜索API密钥', 'settings.desc.SEARCH_GOOGLE_CX': 'Google可编程搜索引擎ID (CX)', diff --git a/frontend_vue/src/locales/lang/zh-TW.js b/frontend_vue/src/locales/lang/zh-TW.js index ca38656..d03c0e3 100644 --- a/frontend_vue/src/locales/lang/zh-TW.js +++ b/frontend_vue/src/locales/lang/zh-TW.js @@ -1841,7 +1841,6 @@ const locale = { 'settings.field.CCXT_DEFAULT_EXCHANGE': 'CCXT默認交易所', 'settings.field.CCXT_TIMEOUT': 'CCXT超時(ms)', 'settings.field.CCXT_PROXY': 'CCXT代理', - 'settings.field.AKSHARE_TIMEOUT': 'Akshare超時(秒)', 'settings.field.YFINANCE_TIMEOUT': 'YFinance超時(秒)', 'settings.field.TIINGO_API_KEY': 'Tiingo API Key', 'settings.field.TIINGO_TIMEOUT': 'Tiingo超時(秒)', @@ -1889,7 +1888,6 @@ const locale = { 'settings.desc.FINNHUB_RATE_LIMIT': 'Finnhub API速率限制(每分鐘請求數)', 'settings.desc.TIINGO_API_KEY': 'Tiingo API密鑰,用於外匯/貴金屬數據(免費版不支持1分鐘數據)', 'settings.desc.TIINGO_TIMEOUT': 'Tiingo API請求超時時間', - 'settings.desc.AKSHARE_TIMEOUT': 'Akshare API超時時間,用於A股數據', 'settings.desc.YFINANCE_TIMEOUT': 'Yahoo Finance API超時時間', 'settings.desc.SIGNAL_WEBHOOK_URL': '信號通知Webhook地址(POST JSON)', 'settings.desc.SIGNAL_WEBHOOK_TOKEN': 'Webhook認證令牌,通過請求頭發送', diff --git a/frontend_vue/src/views/ai-asset-analysis/index.vue b/frontend_vue/src/views/ai-asset-analysis/index.vue index 62c7096..689386c 100644 --- a/frontend_vue/src/views/ai-asset-analysis/index.vue +++ b/frontend_vue/src/views/ai-asset-analysis/index.vue @@ -3,7 +3,7 @@
-

{{ $t('aiAssetAnalysis.opportunities.title') }}

+

{{ $t('aiAssetAnalysis.opportunities.title') }} 11111

{{ $t('aiAssetAnalysis.opportunities.updateHint') }}

diff --git a/frontend_vue/src/views/backtest-center/index.vue b/frontend_vue/src/views/backtest-center/index.vue index a5b5ccf..b5176d6 100644 --- a/frontend_vue/src/views/backtest-center/index.vue +++ b/frontend_vue/src/views/backtest-center/index.vue @@ -525,7 +525,6 @@ - @@ -934,7 +933,6 @@ export default { const colorMap = { Crypto: 'gold', USStock: 'blue', - HKStock: 'geekblue', Forex: 'green', Futures: 'purple' }