Improve global market dashboard UX and settings

- Add global market dashboard APIs/assets and improve data robustness (incl. crypto heatmap by market cap)

- Enhance global market UI (map+heatmap layout, loading behavior, formatting, theme tweaks)

- Fix Settings LLM Provider select to render label/value options correctly

- Rename Indicator Community to Official Community and move it to the bottom

- Add search fallback when Google quota is exhausted
This commit is contained in:
TIANHE
2026-01-24 23:26:26 +08:00
parent f4e5a9f8e0
commit 35d7ac5e1b
15 changed files with 4744 additions and 75 deletions
@@ -22,6 +22,7 @@ def register_routes(app: Flask):
from app.routes.ibkr import ibkr_bp
from app.routes.mt5 import mt5_bp
from app.routes.user import user_bp
from app.routes.global_market import global_market_bp
app.register_blueprint(health_bp)
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
@@ -39,3 +40,4 @@ def register_routes(app: Flask):
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
app.register_blueprint(global_market_bp, url_prefix='/api/global-market')
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -66,7 +66,7 @@ def get_public_config():
'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-oss-120b': 'OpenAI: gpt-oss-120b',
'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',
+3 -2
View File
@@ -387,8 +387,9 @@ class LLMService:
logger.error(f"{p.value} API HTTP error ({current_model}): {error_detail}")
last_error = str(e)
# Check for payment/quota errors
if e.response and e.response.status_code in (402, 429):
# Check for recoverable errors - try fallback model
# 402: Payment required, 403: Forbidden (invalid key), 404: Model not found, 429: Rate limit
if e.response and e.response.status_code in (402, 403, 404, 429):
logger.warning(f"{p.value} returned {e.response.status_code} for model {current_model}; trying fallback...")
continue
+177 -12
View File
@@ -1,19 +1,24 @@
"""
Search service.
Integrates Google Custom Search (CSE) and Bing Search API.
Integrates Google Custom Search (CSE), Bing Search API, and DuckDuckGo (free fallback).
Configuration is provided via environment variables (see env.example) through config_loader.
"""
import requests
import json
import time
from typing import List, Dict, Any, Optional
from app.utils.logger import get_logger
from app.utils.config_loader import load_addon_config
logger = get_logger(__name__)
# Track Google API quota status
_google_quota_exhausted = False
_google_quota_reset_time = 0
class SearchService:
"""Search service."""
"""Search service with automatic fallback."""
def __init__(self):
self._config = {}
@@ -28,7 +33,7 @@ class SearchService:
def search(self, query: str, num_results: int = None, date_restrict: str = None) -> List[Dict[str, Any]]:
"""
Execute a web search.
Execute a web search with automatic fallback.
Args:
query: Search query
@@ -38,18 +43,45 @@ class SearchService:
Returns:
List of search results
"""
global _google_quota_exhausted, _google_quota_reset_time
# 重新加载配置以支持热更新
self._load_config()
limit = num_results if num_results else self.max_results
# Check if Google quota has reset (after midnight UTC typically)
if _google_quota_exhausted and time.time() > _google_quota_reset_time:
_google_quota_exhausted = False
logger.info("Google API quota reset, re-enabling Google search")
results = []
if self.provider == 'bing':
return self._search_bing(query, limit)
results = self._search_bing(query, limit)
elif self.provider == 'duckduckgo':
results = self._search_duckduckgo(query, limit)
else:
return self._search_google(query, limit, date_restrict)
# Google with fallback
if not _google_quota_exhausted:
results = self._search_google(query, limit, date_restrict)
# If Google failed or returned empty, try fallbacks
if not results:
logger.info("Google search failed or empty, trying fallback search engines...")
# Try Bing first if configured
results = self._search_bing(query, limit)
# If Bing also failed, try DuckDuckGo (free, no API key needed)
if not results:
results = self._search_duckduckgo(query, limit)
return results
def _search_google(self, query: str, num_results: int, date_restrict: str = None) -> List[Dict[str, Any]]:
"""Google Custom Search (CSE)."""
global _google_quota_exhausted, _google_quota_reset_time
api_key = self._config.get('google', {}).get('api_key')
cx = self._config.get('google', {}).get('cx')
@@ -71,17 +103,23 @@ class SearchService:
params['dateRestrict'] = date_restrict
try:
# logger.info(f"正在调用 Google Search API: q={query}, params={params}")
response = requests.get(url, params=params, timeout=10)
# Check for quota exceeded (429)
if response.status_code == 429:
logger.warning("Google Search API quota exceeded (429). Switching to fallback search engines.")
_google_quota_exhausted = True
# Set reset time to next day midnight UTC
import datetime
tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
_google_quota_reset_time = tomorrow.timestamp()
return []
response.raise_for_status()
data = response.json()
# logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)}") # 打印全部字符
# logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)[:500]}...") # 打印前500字符避免日志过大
results = []
if 'items' in data:
# logger.info(f"Google Search 返回了 {len(data['items'])} 条结果")
for item in data['items']:
logger.debug(f"Search Item: {item.get('title')} - {item.get('link')}")
results.append({
@@ -96,10 +134,20 @@ class SearchService:
return results
except requests.exceptions.HTTPError as e:
if hasattr(e, 'response') and e.response is not None and e.response.status_code == 429:
logger.warning("Google Search API quota exceeded. Switching to fallback.")
_google_quota_exhausted = True
import datetime
tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
_google_quota_reset_time = tomorrow.timestamp()
else:
logger.error(f"Google search failed: {e}")
if hasattr(e, 'response') and e.response is not None:
logger.error(f"Response: {e.response.text}")
return []
except Exception as e:
logger.error(f"Google search failed: {e}")
if 'response' in locals():
logger.error(f"Response: {response.text}")
return []
def _search_bing(self, query: str, num_results: int) -> List[Dict[str, Any]]:
@@ -140,3 +188,120 @@ class SearchService:
logger.error(f"Bing search failed: {e}")
return []
def _search_duckduckgo(self, query: str, num_results: int) -> List[Dict[str, Any]]:
"""
DuckDuckGo search (free, no API key required).
Uses the DuckDuckGo HTML search endpoint.
"""
try:
# Use DuckDuckGo Instant Answer API
url = "https://api.duckduckgo.com/"
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 results from RelatedTopics
related_topics = data.get('RelatedTopics', [])
for topic in related_topics[:num_results]:
if isinstance(topic, dict):
if 'FirstURL' in topic:
results.append({
'title': topic.get('Text', '')[:100],
'link': topic.get('FirstURL', ''),
'snippet': topic.get('Text', ''),
'source': 'DuckDuckGo',
'published': ''
})
# Handle nested topics
elif 'Topics' in topic:
for sub_topic in topic['Topics']:
if len(results) >= num_results:
break
if 'FirstURL' in sub_topic:
results.append({
'title': sub_topic.get('Text', '')[:100],
'link': sub_topic.get('FirstURL', ''),
'snippet': sub_topic.get('Text', ''),
'source': 'DuckDuckGo',
'published': ''
})
# Also check AbstractURL and AbstractText
if data.get('AbstractURL') and len(results) < num_results:
results.insert(0, {
'title': data.get('Heading', query),
'link': data.get('AbstractURL', ''),
'snippet': data.get('AbstractText', ''),
'source': 'DuckDuckGo',
'published': ''
})
# If no results from Instant Answer, try HTML scraping as fallback
if not results:
results = self._search_duckduckgo_html(query, num_results)
if results:
logger.info(f"DuckDuckGo search returned {len(results)} results")
return results[:num_results]
except Exception as e:
logger.error(f"DuckDuckGo search failed: {e}")
# Try HTML fallback
return self._search_duckduckgo_html(query, num_results)
def _search_duckduckgo_html(self, query: str, num_results: int) -> List[Dict[str, Any]]:
"""
DuckDuckGo HTML search fallback.
Scrapes the lite HTML version for better results.
"""
try:
url = "https://lite.duckduckgo.com/lite/"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
data = {'q': query}
response = requests.post(url, headers=headers, data=data, timeout=10)
response.raise_for_status()
results = []
# Simple HTML parsing without BeautifulSoup
html = response.text
# Find all result links (they have class="result-link")
import re
# Pattern to find result entries
link_pattern = r'<a[^>]*class="result-link"[^>]*href="([^"]*)"[^>]*>([^<]*)</a>'
snippet_pattern = r'<td[^>]*class="result-snippet"[^>]*>([^<]*)</td>'
links = re.findall(link_pattern, html)
snippets = re.findall(snippet_pattern, html)
for i, (link, title) in enumerate(links[:num_results]):
snippet = snippets[i] if i < len(snippets) else ''
if link and title:
results.append({
'title': title.strip(),
'link': link,
'snippet': snippet.strip(),
'source': 'DuckDuckGo',
'published': ''
})
return results
except Exception as e:
logger.error(f"DuckDuckGo HTML search failed: {e}")
return []