Spaces:
Running
Running
| """HTTP routes for the stock data API.""" | |
| from __future__ import annotations | |
| import time | |
| from fastapi import APIRouter, Depends, HTTPException, Query | |
| from pydantic import BaseModel, Field | |
| from app.core.cache import cache | |
| from app.core.security import require_api_key | |
| from app.services.market_data import market_data_service | |
| from app.services.ai_search_hub_service import AISearchHubNotConfigured, ai_search_hub_service | |
| from app.services.search_service import SearchConfigMissing, SearchUpstreamFailed, search_service | |
| from app.services.source_runner import AllSourcesFailed, get_source_pool_stats | |
| from app.utils.stock_code import InvalidSymbolError | |
| router = APIRouter(dependencies=[Depends(require_api_key)]) | |
| GET_SEARCH_SYSTEM_PROMPT = ( | |
| "You are a concise financial search assistant. Answer in the user's language. " | |
| "Prioritize verifiable facts, key risks, and fresh market context. Keep the answer short." | |
| ) | |
| GET_SEARCH_DEFAULT_ATTEMPTS = 1 | |
| GET_SEARCH_DEFAULT_TIMEOUT_SECONDS = 60 | |
| GET_SEARCH_DEFAULT_MAX_TOKENS = 1024 | |
| GET_SEARCH_FAST_SITES = ["qwen"] | |
| class SearchRequest(BaseModel): | |
| query: str = Field(..., min_length=1, max_length=8000) | |
| context: str | None = Field(default=None, max_length=16000) | |
| system_prompt: str | None = Field(default=None, max_length=4000) | |
| max_attempts: int | None = Field(default=None, ge=1, le=10) | |
| timeout_seconds: int | None = Field(default=None, ge=30, le=600) | |
| temperature: float | None = Field(default=None, ge=0, le=2) | |
| max_tokens: int | None = Field(default=None, ge=256, le=16000) | |
| use_cache: bool = True | |
| class AISearchHubRequest(BaseModel): | |
| query: str = Field(..., min_length=1, max_length=8000) | |
| sites: list[str] | None = Field(default=None, description="Platform keys to search (e.g. gemini, grok, doubao)") | |
| timeout: int | None = Field(default=None, ge=30, le=300) | |
| headless: bool | None = Field(default=None) | |
| use_cache: bool = True | |
| def _handle(func): | |
| try: | |
| return func() | |
| except AllSourcesFailed as exc: | |
| raise HTTPException( | |
| status_code=503, | |
| detail={ | |
| "ok": False, | |
| "code": "upstream_unavailable", | |
| "message": "all upstream data sources failed", | |
| "endpoint": exc.endpoint, | |
| "attempts": exc.attempts, | |
| }, | |
| ) from exc | |
| except InvalidSymbolError as exc: | |
| raise HTTPException( | |
| status_code=400, | |
| detail={ | |
| "ok": False, | |
| "code": exc.code, | |
| "message": str(exc), | |
| "symbol": exc.symbol, | |
| }, | |
| ) from exc | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail=str(exc)) from exc | |
| except SearchConfigMissing as exc: | |
| raise HTTPException( | |
| status_code=503, | |
| detail={ | |
| "ok": False, | |
| "code": "search_config_missing", | |
| "message": f"search service is not configured: {exc}", | |
| "missing_config": exc.missing_config, | |
| }, | |
| ) from exc | |
| except SearchUpstreamFailed as exc: | |
| raise HTTPException( | |
| status_code=502, | |
| detail={ | |
| "message": "upstream search service failed", | |
| "error": str(exc), | |
| "attempts": exc.attempts, | |
| }, | |
| ) from exc | |
| except AISearchHubNotConfigured as exc: | |
| raise HTTPException( | |
| status_code=503, | |
| detail={ | |
| "ok": False, | |
| "code": "ai_search_hub_not_configured", | |
| "message": str(exc), | |
| "missing_config": exc.missing_config, | |
| }, | |
| ) from exc | |
| except RuntimeError as exc: | |
| raise HTTPException( | |
| status_code=502, | |
| detail={ | |
| "message": "upstream search service failed", | |
| "error": str(exc), | |
| }, | |
| ) from exc | |
| def _search_get_response( | |
| q: str, | |
| max_attempts: int | None, | |
| timeout_seconds: int | None, | |
| temperature: float | None, | |
| max_tokens: int | None, | |
| use_cache: bool, | |
| ): | |
| attempts = max_attempts if max_attempts is not None else GET_SEARCH_DEFAULT_ATTEMPTS | |
| timeout = timeout_seconds if timeout_seconds is not None else GET_SEARCH_DEFAULT_TIMEOUT_SECONDS | |
| token_limit = max_tokens if max_tokens is not None else GET_SEARCH_DEFAULT_MAX_TOKENS | |
| use_model_first = max_attempts is not None or temperature is not None or max_tokens is not None | |
| if not use_model_first: | |
| fallback_error: Exception | None = None | |
| fallback_started = time.perf_counter() | |
| try: | |
| payload = ai_search_hub_service.search( | |
| query=q, | |
| sites=GET_SEARCH_FAST_SITES, | |
| timeout=min(max(timeout, 30), 90), | |
| use_cache=use_cache, | |
| ) | |
| data = payload.get("data") if isinstance(payload, dict) else None | |
| if isinstance(data, dict) and not data.get("sites_succeeded"): | |
| raise RuntimeError("ai search hub returned no successful sites") | |
| elapsed_ms = int((time.perf_counter() - fallback_started) * 1000) | |
| meta = payload.setdefault("meta", {}) | |
| meta["endpoint"] = "search" | |
| meta["source"] = "ai_search_hub.multi_platform.fast_get" | |
| meta["attempts"] = [ | |
| { | |
| "source": "ai_search_hub.multi_platform", | |
| "ok": True, | |
| "attempt": 1, | |
| "elapsed_ms": elapsed_ms, | |
| } | |
| ] | |
| meta["routing"] = { | |
| "mode": "fast_get", | |
| "primary": "ai_search_hub.multi_platform", | |
| "model_search_available_with": "max_attempts, timeout_seconds, temperature, or max_tokens", | |
| } | |
| return payload | |
| except (AISearchHubNotConfigured, RuntimeError, ValueError) as exc: | |
| fallback_error = exc | |
| try: | |
| payload = search_service.search( | |
| query=q, | |
| system_prompt=GET_SEARCH_SYSTEM_PROMPT, | |
| max_attempts=attempts, | |
| timeout_seconds=timeout, | |
| temperature=temperature, | |
| max_tokens=token_limit, | |
| use_cache=use_cache, | |
| ) | |
| if not use_model_first and fallback_error is not None: | |
| meta = payload.setdefault("meta", {}) | |
| meta["fallback"] = { | |
| "from": "ai_search_hub.multi_platform", | |
| "to": "openai_compatible.search_model", | |
| "reason": type(fallback_error).__name__, | |
| } | |
| return payload | |
| except (SearchConfigMissing, SearchUpstreamFailed) as exc: | |
| fallback_started = time.perf_counter() | |
| payload = ai_search_hub_service.search( | |
| query=q, | |
| timeout=min(max(timeout, 30), 90), | |
| use_cache=use_cache, | |
| ) | |
| elapsed_ms = int((time.perf_counter() - fallback_started) * 1000) | |
| meta = payload.setdefault("meta", {}) | |
| primary_attempts = getattr(exc, "attempts", []) | |
| meta["endpoint"] = "search" | |
| meta["source"] = "ai_search_hub.multi_platform.fallback" | |
| meta["attempts"] = [ | |
| {"source": "openai_compatible.search_model", **attempt} | |
| for attempt in primary_attempts | |
| ] + [ | |
| { | |
| "source": "ai_search_hub.multi_platform", | |
| "ok": True, | |
| "attempt": 1, | |
| "elapsed_ms": elapsed_ms, | |
| } | |
| ] | |
| meta["fallback"] = { | |
| "from": "openai_compatible.search_model", | |
| "to": "ai_search_hub.multi_platform", | |
| "reason": type(exc).__name__, | |
| } | |
| return payload | |
| def catalog(): | |
| return market_data_service.catalog() | |
| def cache_stats(): | |
| return cache.stats() | |
| def purge_expired_cache(): | |
| return {"deleted": cache.purge_expired()} | |
| def purge_all_cache(): | |
| return {"deleted": cache.purge_all()} | |
| def search(request: SearchRequest): | |
| return _handle( | |
| lambda: search_service.search( | |
| query=request.query, | |
| context=request.context, | |
| system_prompt=request.system_prompt, | |
| max_attempts=request.max_attempts, | |
| timeout_seconds=request.timeout_seconds, | |
| temperature=request.temperature, | |
| max_tokens=request.max_tokens, | |
| use_cache=request.use_cache, | |
| ) | |
| ) | |
| def search_get( | |
| q: str = Query(..., min_length=1, max_length=2000), | |
| max_attempts: int | None = Query(None, ge=1, le=10), | |
| timeout_seconds: int | None = Query(None, ge=30, le=600), | |
| temperature: float | None = Query(None, ge=0, le=2), | |
| max_tokens: int | None = Query(None, ge=256, le=16000), | |
| use_cache: bool = Query(True), | |
| ): | |
| return _handle( | |
| lambda: _search_get_response(q, max_attempts, timeout_seconds, temperature, max_tokens, use_cache) | |
| ) | |
| def ai_search_hub_sites(): | |
| return ai_search_hub_service.list_sites() | |
| def ai_search_hub_search(request: AISearchHubRequest): | |
| return _handle( | |
| lambda: ai_search_hub_service.search( | |
| query=request.query, | |
| sites=request.sites, | |
| timeout=request.timeout, | |
| headless=request.headless, | |
| use_cache=request.use_cache, | |
| ) | |
| ) | |
| def ai_search_hub_search_get( | |
| q: str = Query(..., min_length=1, max_length=2000), | |
| sites: str | None = Query(None, description="Comma-separated site keys"), | |
| timeout: int | None = Query(None, ge=30, le=300), | |
| headless: bool | None = Query(None), | |
| use_cache: bool = Query(True), | |
| ): | |
| site_list = [s.strip() for s in sites.split(",") if s.strip()] if sites else None | |
| return _handle( | |
| lambda: ai_search_hub_service.search( | |
| query=q, | |
| sites=site_list, | |
| timeout=timeout, | |
| headless=headless, | |
| use_cache=use_cache, | |
| ) | |
| ) | |
| def stock_quote(stock_code: str): | |
| return _handle(lambda: market_data_service.stock_quote(stock_code)) | |
| def stock_order_book(stock_code: str): | |
| return _handle(lambda: market_data_service.stock_order_book(stock_code)) | |
| def stock_daily( | |
| stock_code: str, | |
| days: int = Query(60, ge=1, le=5000), | |
| start_date: str | None = None, | |
| end_date: str | None = None, | |
| adjust: str = Query("qfq", pattern="^(|qfq|hfq)$"), | |
| ): | |
| return _handle(lambda: market_data_service.stock_daily(stock_code, days, start_date, end_date, adjust)) | |
| def stock_technical( | |
| stock_code: str, | |
| days: int = Query(120, ge=30, le=5000), | |
| history_days: int = Query(0, ge=0, le=120), | |
| adjust: str = Query("qfq", pattern="^(|qfq|hfq)$"), | |
| ): | |
| return _handle(lambda: market_data_service.stock_technical(stock_code, days, history_days, adjust)) | |
| def stock_chip( | |
| stock_code: str, | |
| adjust: str = Query("qfq", pattern="^(|qfq|hfq)$"), | |
| ): | |
| return _handle(lambda: market_data_service.stock_chip(stock_code, adjust)) | |
| def stock_chip_simple( | |
| stock_code: str, | |
| lookback_days: int = Query(60, ge=10, le=500), | |
| adjust: str = Query("qfq", pattern="^(|qfq|hfq)$"), | |
| ): | |
| return _handle(lambda: market_data_service.stock_chip_simple(stock_code, lookback_days, adjust)) | |
| def stock_fund_flow(stock_code: str, days: int = Query(10, ge=1, le=120)): | |
| return _handle(lambda: market_data_service.stock_fund_flow(stock_code, days)) | |
| def stock_margin(stock_code: str, days: int = Query(30, ge=1, le=500)): | |
| return _handle(lambda: market_data_service.stock_margin(stock_code, days)) | |
| def stock_shareholders(stock_code: str, limit: int = Query(12, ge=1, le=50)): | |
| return _handle(lambda: market_data_service.stock_shareholders(stock_code, limit)) | |
| def stock_shareholder_top(stock_code: str, date: str | None = None): | |
| return _handle(lambda: market_data_service.stock_shareholder_top(stock_code, date)) | |
| def stock_f10_company(stock_code: str): | |
| return _handle(lambda: market_data_service.stock_f10_company(stock_code)) | |
| def stock_research_reports(stock_code: str, limit: int = Query(20, ge=1, le=100)): | |
| return _handle(lambda: market_data_service.stock_research_reports(stock_code, limit)) | |
| def fund_flow_rank( | |
| indicator: str = Query("5日", pattern="^(今日|3日|5日|10日)$"), | |
| limit: int = Query(100, ge=1, le=5000), | |
| ): | |
| return _handle(lambda: market_data_service.fund_flow_rank(indicator, limit)) | |
| def big_deal(limit: int = Query(100, ge=1, le=5000)): | |
| return _handle(lambda: market_data_service.big_deal(limit)) | |
| def market_indices(limit: int = Query(100, ge=1, le=1000)): | |
| return _handle(lambda: market_data_service.market_indices(limit)) | |
| def market_moves( | |
| move_type: str = Query("surge", pattern="^(surge|drop|change_up|change_down|mainflow|turnover|up|down|rise|fall|fund|capital|active)$"), | |
| limit: int = Query(50, ge=1, le=200), | |
| ): | |
| return _handle(lambda: market_data_service.market_moves(move_type, limit)) | |
| def market_longhubang( | |
| date: str | None = None, | |
| limit: int = Query(50, ge=1, le=200), | |
| page: int = Query(1, ge=1, le=100), | |
| ): | |
| return _handle(lambda: market_data_service.longhubang(date, limit, page)) | |
| def market_limit_up(date: str | None = None, limit: int = Query(100, ge=1, le=1000)): | |
| return _handle(lambda: market_data_service.limit_pool("up", date, limit)) | |
| def market_limit_down(date: str | None = None, limit: int = Query(100, ge=1, le=1000)): | |
| return _handle(lambda: market_data_service.limit_pool("down", date, limit)) | |
| def market_breadth(): | |
| return _handle(lambda: market_data_service.market_breadth()) | |
| def market_temperature(date: str | None = None): | |
| return _handle(lambda: market_data_service.market_temperature(date)) | |
| def market_margin(date: str | None = None, limit: int = Query(50, ge=1, le=5000)): | |
| return _handle(lambda: market_data_service.market_margin(date, limit)) | |
| def market_northbound(days: int = Query(30, ge=1, le=500)): | |
| return _handle(lambda: market_data_service.northbound_hist(days)) | |
| def market_northbound_realtime(): | |
| return _handle(lambda: market_data_service.northbound_realtime()) | |
| def market_northbound_holdings( | |
| stock_code: str = Query("", description="个股代码,为空时返回市场汇总"), | |
| limit: int = Query(50, ge=1, le=500), | |
| ): | |
| return _handle(lambda: market_data_service.northbound_holdings(stock_code, limit)) | |
| def shenwan_industry(limit: int = Query(50, ge=1, le=200)): | |
| return _handle(lambda: market_data_service.shenwan_industry(limit)) | |
| def fund_holdings(date: str = Query("20260331", description="报告期,格式 YYYYMMDD"), limit: int = Query(100, ge=1, le=5000)): | |
| return _handle(lambda: market_data_service.fund_holdings(date, limit)) | |
| def fund_structure(): | |
| return _handle(lambda: market_data_service.fund_structure()) | |
| def board_flow( | |
| category: str = Query("industry", pattern="^(industry|concept|region|concepts|area|province)$"), | |
| limit: int = Query(100, ge=1, le=200), | |
| ): | |
| return _handle(lambda: market_data_service.board_flow(category, limit)) | |
| def concept_flow( | |
| symbol: str = Query("即时"), | |
| limit: int = Query(1000, ge=1, le=5000), | |
| ): | |
| return _handle(lambda: market_data_service.concept_flow(symbol, limit)) | |
| def industry_flow( | |
| symbol: str = Query("即时"), | |
| limit: int = Query(1000, ge=1, le=5000), | |
| ): | |
| return _handle(lambda: market_data_service.industry_flow(symbol, limit)) | |
| def index_fund_flow( | |
| index_code: str, | |
| interval: str = Query("1m", pattern="^(1|1m|5|5m|15|15m|30|30m|60|60m|101|1d|day|daily)$"), | |
| limit: int = Query(120, ge=1, le=2000), | |
| ): | |
| return _handle(lambda: market_data_service.index_fund_flow(index_code, interval, limit)) | |
| def etf_spot(limit: int = Query(200, ge=1, le=5000)): | |
| return _handle(lambda: market_data_service.etf_spot(limit)) | |
| def etf_premium( | |
| limit: int = Query(200, ge=1, le=5000), | |
| sort: str = Query("abs", pattern="^(abs|premium|discount|code)$"), | |
| ): | |
| return _handle(lambda: market_data_service.etf_premium(limit, sort)) | |
| def etf_quote(fund_code: str): | |
| return _handle(lambda: market_data_service.etf_quote(fund_code)) | |
| def etf_premium_detail(fund_code: str): | |
| return _handle(lambda: market_data_service.etf_premium_detail(fund_code)) | |
| def etf_daily( | |
| fund_code: str, | |
| days: int = Query(120, ge=1, le=5000), | |
| start_date: str | None = None, | |
| end_date: str | None = None, | |
| adjust: str = Query("", pattern="^(|qfq|hfq)$"), | |
| ): | |
| return _handle(lambda: market_data_service.etf_daily(fund_code, days, start_date, end_date, adjust)) | |
| def fund_open_spot(limit: int = Query(200, ge=1, le=5000)): | |
| return _handle(lambda: market_data_service.fund_open_spot(limit)) | |
| def fund_money_spot(limit: int = Query(200, ge=1, le=5000)): | |
| return _handle(lambda: market_data_service.fund_money_spot(limit)) | |
| def fund_nav( | |
| fund_code: str, | |
| fund_type: str = Query("open", pattern="^(open|money|etf)$"), | |
| limit: int = Query(300, ge=1, le=5000), | |
| ): | |
| return _handle(lambda: market_data_service.fund_nav(fund_code, fund_type, limit)) | |
| def hk_short_selling( | |
| stock_code: str, | |
| limit: int = Query(100, ge=1, le=1000), | |
| pages: int = Query(2, ge=1, le=20), | |
| ): | |
| return _handle(lambda: market_data_service.hk_short_selling(stock_code, limit, pages)) | |
| def legacy_board_concept_hot( | |
| lookback_days: int = Query(1, ge=1, le=60), | |
| limit: int = Query(30, ge=1, le=100), | |
| ): | |
| return _handle(lambda: market_data_service.legacy_board_concept_hot(lookback_days, limit)) | |
| def legacy_board_sector_flow(limit: int = Query(30, ge=1, le=100)): | |
| return _handle(lambda: market_data_service.legacy_board_sector_flow(limit)) | |
| def legacy_board_temperature(date: str | None = None): | |
| return _handle(lambda: market_data_service.legacy_board_temperature(date)) | |
| def legacy_board_news( | |
| stock_code: str = "000300", | |
| data_type: str = Query("global_news", pattern="^(news|global_news|notice|important_news|hotspot_news)$"), | |
| limit: int = Query(20, ge=1, le=100), | |
| ): | |
| return _handle(lambda: market_data_service.legacy_board_news(stock_code, data_type, limit)) | |
| def legacy_sector_ambush( | |
| lookback_days: int = Query(60, ge=1, le=250), | |
| limit: int = Query(10, ge=1, le=50), | |
| ): | |
| return _handle(lambda: market_data_service.legacy_sector_ambush(lookback_days, limit)) | |
| def legacy_sector_leaders( | |
| sector_name: str, | |
| lookback_days: int = Query(5, ge=1, le=60), | |
| top_n: int = Query(3, ge=1, le=10), | |
| ): | |
| return _handle(lambda: market_data_service.legacy_sector_leaders(sector_name, lookback_days, top_n)) | |
| def legacy_leader_frequency( | |
| stock_name: str, | |
| lookback_days: int = Query(5, ge=1, le=60), | |
| ): | |
| return _handle(lambda: market_data_service.legacy_leader_frequency(stock_name, lookback_days)) | |
| def global_news(limit: int = Query(50, ge=1, le=200)): | |
| return _handle(lambda: market_data_service.global_news(limit)) | |
| def stock_news(stock_code: str, limit: int = Query(30, ge=1, le=200)): | |
| return _handle(lambda: market_data_service.stock_news(stock_code, limit)) | |
| def stock_notices(stock_code: str, limit: int = Query(30, ge=1, le=200)): | |
| return _handle(lambda: market_data_service.stock_notices(stock_code, limit)) | |
| def stock_financial( | |
| stock_code: str, | |
| kind: str = Query("abstract", pattern="^(abstract|indicators|forecast|express)$"), | |
| limit: int = Query(20, ge=1, le=200), | |
| report_date: str | None = None, | |
| start_year: int | None = Query(None, ge=2000, le=2100, description="起始年份"), | |
| end_year: int | None = Query(None, ge=2000, le=2100, description="结束年份"), | |
| ): | |
| return _handle(lambda: market_data_service.stock_financial(stock_code, kind, limit, report_date, str(start_year) if start_year is not None else None, str(end_year) if end_year is not None else None)) | |
| def stock_income( | |
| stock_code: str, | |
| limit: int = Query(10, ge=1, le=200), | |
| kind: str = Query("ytd", pattern="^(ytd|quarterly|sq|single_quarter)$"), | |
| ): | |
| return _handle(lambda: market_data_service.stock_income(stock_code, limit, kind)) | |
| def stock_balancesheet(stock_code: str, limit: int = Query(10, ge=1, le=200)): | |
| return _handle(lambda: market_data_service.stock_balancesheet(stock_code, limit)) | |
| def stock_cashflow( | |
| stock_code: str, | |
| limit: int = Query(10, ge=1, le=200), | |
| kind: str = Query("ytd", pattern="^(ytd|quarterly|sq|single_quarter)$"), | |
| ): | |
| return _handle(lambda: market_data_service.stock_cashflow(stock_code, limit, kind)) | |
| def stock_dividends( | |
| stock_code: str, | |
| limit: int = Query(20, ge=1, le=200), | |
| kind: str = Query("main", pattern="^(main|allotment|all)$"), | |
| ): | |
| return _handle(lambda: market_data_service.stock_dividends(stock_code, limit, kind)) | |
| def stock_equity_history(stock_code: str, limit: int = Query(20, ge=1, le=200)): | |
| return _handle(lambda: market_data_service.stock_equity_history(stock_code, limit)) | |
| def stock_freeholders( | |
| stock_code: str, | |
| limit: int = Query(20, ge=1, le=200), | |
| end_date: str | None = None, | |
| ): | |
| return _handle(lambda: market_data_service.stock_freeholders(stock_code, limit, end_date)) | |
| def stock_daily_basic(stock_code: str, days: int = Query(30, ge=1, le=500)): | |
| return _handle(lambda: market_data_service.stock_daily_basic(stock_code, days)) | |
| def trade_calendar( | |
| start_date: str | None = None, | |
| end_date: str | None = None, | |
| limit: int = Query(5000, ge=1, le=10000), | |
| ): | |
| return _handle(lambda: market_data_service.trade_calendar(start_date, end_date, limit)) | |
| def china_bond_yield_curve( | |
| start_date: str | None = None, | |
| end_date: str | None = None, | |
| days: int = Query(30, ge=1, le=3650), | |
| limit: int = Query(500, ge=1, le=5000), | |
| ): | |
| return _handle(lambda: market_data_service.china_bond_yield_curve(start_date, end_date, days, limit)) | |
| def china_macro(indicator: str, limit: int = Query(200, ge=1, le=5000)): | |
| return _handle(lambda: market_data_service.china_macro(indicator, limit)) | |
| # ---- US market (sourced from niuone patterns) ---- | |
| def us_indices(limit: int = Query(20, ge=1, le=100)): | |
| return _handle(lambda: market_data_service.us_indices(limit)) | |
| def us_sectors(limit: int = Query(20, ge=1, le=50)): | |
| return _handle(lambda: market_data_service.us_sectors(limit)) | |
| def us_market_summary(): | |
| return _handle(lambda: market_data_service.us_market_summary()) | |
| def us_stock_quote(symbol: str): | |
| return _handle(lambda: market_data_service.us_stock_quote(symbol)) | |
| def us_stock_daily(symbol: str, days: int = Query(60, ge=1, le=2000)): | |
| return _handle(lambda: market_data_service.us_stock_daily(symbol, days)) | |
| # ---- X / Twitter timeline (sourced from niuone patterns) ---- | |
| def x_timeline( | |
| accounts: str = Query(..., min_length=1, max_length=2000, description="X 账号,逗号分隔,如 elonmusk,OpenAI"), | |
| limit: int = Query(5, ge=1, le=10, description="每个账号最多条数"), | |
| hydrate: bool = Query(True, description="是否补充推文上下文/媒体直链"), | |
| ): | |
| return _handle(lambda: market_data_service.x_timeline(accounts, limit, hydrate)) | |
| def futures_basis(days: int = Query(1, ge=1, le=120)): | |
| return _handle(lambda: market_data_service.futures_basis(days)) | |
| def source_pool_stats(): | |
| """获取源调用池的统计信息(用于监控 worker pool 使用情况)""" | |
| stats = get_source_pool_stats() | |
| return { | |
| "pool_stats": stats, | |
| "recommendations": { | |
| "worker_pool_saturated": stats.get("active", 0) >= stats.get("max_concurrent", 2), | |
| "suggestion": "如果 active >= max_concurrent,说明 worker pool 已饱和,需要增加 MAX_CONCURRENT_SOURCES 或优化上游响应时间", | |
| }, | |
| } | |
| def health_detailed(): | |
| """详细的健康检查,包括源调用池状态""" | |
| pool_stats = get_source_pool_stats() | |
| return { | |
| "ok": True, | |
| "version": "v24-concurrent-control", | |
| "source_pool": pool_stats, | |
| "config": { | |
| "max_concurrent_sources": int(os.getenv("MAX_CONCURRENT_SOURCES", "2")), | |
| "source_pool_workers": int(os.getenv("SOURCE_POOL_WORKERS", "4")), | |
| "source_timeout_seconds": int(os.getenv("SOURCE_TIMEOUT_SECONDS", "15")), | |
| }, | |
| } | |
| import os | |