""" Market API routes (local-only). Provides watchlist, market metadata, symbol search, and pricing helpers for the frontend. """ import json import time import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from flask import Blueprint, g, jsonify, request from app.data.market_symbols_seed import ( get_hot_symbols as seed_get_hot_symbols, ) 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__) 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() 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"]) def get_public_config(): """ Public config for frontend (local mode). Mirrors the old PHP `/addons/quantdinger/index/getConfig` shape. """ try: cfg = load_addon_config() 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", # 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", } 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 @market_bp.route("/types", methods=["GET"]) def get_market_types(): """Return supported market types for the add-watchlist modal.""" # 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() if not v: return None 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 None def _sort_items(items): # Keep unknown market types after known ones, stable by original order. out = [] for it in items or []: norm = _normalize_item(it) if norm: out.append(norm) 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") # 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}) @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/", }, "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", } return jsonify({"code": 1, "msg": "success", "data": data}) @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) if not market or not keyword: 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}) 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 @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) hot = seed_get_hot_symbols(market=market, limit=limit) 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 @market_bp.route("/watchlist/get", methods=["GET"]) @login_required def get_watchlist(): """Get watchlist for the current user.""" try: user_id = g.user_id _ensure_watchlist_table() 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,) ) rows = cur.fetchall() or [] # Backfill display names for legacy rows (name empty or equals symbol). # 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() 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 cur.execute( "UPDATE qd_watchlist SET name = ?, updated_at = NOW() WHERE user_id = ? AND market = ? AND symbol = ?", (resolved, user_id, market, symbol), ) except Exception: continue db.commit() cur.close() 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 @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() if not market or not symbol: 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) name = name_in or resolved or symbol with get_db_connection() as db: cur = db.cursor() # Insert or update (PostgreSQL UPSERT) cur.execute( """ 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), ) db.commit() cur.close() 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 @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")) if not symbol: 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)) db.commit() cur.close() 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 def get_single_price(market: str, symbol: str) -> dict: """Get price data of a single target""" try: # 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), } 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} @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", "[]") 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 # 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", "") 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: for future in as_completed(futures, timeout=30): completed_futures.add(future) try: result = future.result() results.append(result) 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}) 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) logger.info(f"Watchlist prices: {success_count}/{len(results)} successful") 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 @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", "") if not market or not symbol: 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}) 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 @market_bp.route("/stock/name", methods=["POST"]) def get_stock_name(): """ Get stock name Request body: { "market": "USStock", "symbol": "AAPL" } response: { "code": 1, "msg": "success", "data": { "name": "Apple Inc." } } """ 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", "") if not market or not symbol: 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}}) # Get stock names based on different markets stock_name = symbol # Default use code try: 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": # Cryptocurrency, using trading pair format if "/" in symbol: stock_name = symbol else: stock_name = f"{symbol}/USDT" elif market == "Forex": # Forex forex_names = { "XAUUSD": "黄金", "XAGUSD": "白银", "EURUSD": "欧元/美元", "GBPUSD": "英镑/美元", "USDJPY": "美元/日元", "AUDUSD": "澳元/美元", "USDCAD": "美元/加元", "USDCHF": "美元/瑞郎", } stock_name = forex_names.get(symbol, symbol) elif market == "Futures": # futures futures_names = { "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}}) 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