Skip to content

Commit 35d7ac5

Browse files
committed
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
1 parent f4e5a9f commit 35d7ac5

15 files changed

Lines changed: 4744 additions & 75 deletions

File tree

backend_api_python/app/routes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def register_routes(app: Flask):
2222
from app.routes.ibkr import ibkr_bp
2323
from app.routes.mt5 import mt5_bp
2424
from app.routes.user import user_bp
25+
from app.routes.global_market import global_market_bp
2526

2627
app.register_blueprint(health_bp)
2728
app.register_blueprint(auth_bp, url_prefix='/api/auth') # Auth routes
@@ -39,3 +40,4 @@ def register_routes(app: Flask):
3940
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
4041
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
4142
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
43+
app.register_blueprint(global_market_bp, url_prefix='/api/global-market')

backend_api_python/app/routes/global_market.py

Lines changed: 1624 additions & 0 deletions
Large diffs are not rendered by default.

backend_api_python/app/routes/market.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def get_public_config():
6666
'google/gemini-2.5-pro': 'Google: Gemini 2.5 Pro',
6767
'openai/gpt-4o-mini': 'OpenAI: GPT-4o-mini',
6868
'openai/gpt-5-mini': 'OpenAI: GPT-5 Mini',
69-
'openai/gpt-oss-120b': 'OpenAI: gpt-oss-120b',
69+
'openai/gpt-4.1-mini': 'OpenAI: GPT-4.1 Mini',
7070
'deepseek/deepseek-v3.2': 'DeepSeek: DeepSeek V3.2',
7171
'minimax/minimax-m2': 'MiniMax: MiniMax M2',
7272
'anthropic/claude-sonnet-4': 'Anthropic: Claude Sonnet 4',

backend_api_python/app/services/llm.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,8 +387,9 @@ def call_llm_api(self, messages: list, model: str = None, temperature: float = 0
387387
logger.error(f"{p.value} API HTTP error ({current_model}): {error_detail}")
388388
last_error = str(e)
389389

390-
# Check for payment/quota errors
391-
if e.response and e.response.status_code in (402, 429):
390+
# Check for recoverable errors - try fallback model
391+
# 402: Payment required, 403: Forbidden (invalid key), 404: Model not found, 429: Rate limit
392+
if e.response and e.response.status_code in (402, 403, 404, 429):
392393
logger.warning(f"{p.value} returned {e.response.status_code} for model {current_model}; trying fallback...")
393394
continue
394395

backend_api_python/app/services/search.py

Lines changed: 177 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
"""
22
Search service.
3-
Integrates Google Custom Search (CSE) and Bing Search API.
3+
Integrates Google Custom Search (CSE), Bing Search API, and DuckDuckGo (free fallback).
44
Configuration is provided via environment variables (see env.example) through config_loader.
55
"""
66
import requests
77
import json
8+
import time
89
from typing import List, Dict, Any, Optional
910
from app.utils.logger import get_logger
1011
from app.utils.config_loader import load_addon_config
1112

1213
logger = get_logger(__name__)
1314

15+
# Track Google API quota status
16+
_google_quota_exhausted = False
17+
_google_quota_reset_time = 0
18+
1419

1520
class SearchService:
16-
"""Search service."""
21+
"""Search service with automatic fallback."""
1722

1823
def __init__(self):
1924
self._config = {}
@@ -28,7 +33,7 @@ def _load_config(self):
2833

2934
def search(self, query: str, num_results: int = None, date_restrict: str = None) -> List[Dict[str, Any]]:
3035
"""
31-
Execute a web search.
36+
Execute a web search with automatic fallback.
3237
3338
Args:
3439
query: Search query
@@ -38,18 +43,45 @@ def search(self, query: str, num_results: int = None, date_restrict: str = None)
3843
Returns:
3944
List of search results
4045
"""
46+
global _google_quota_exhausted, _google_quota_reset_time
47+
4148
# 重新加载配置以支持热更新
4249
self._load_config()
4350

4451
limit = num_results if num_results else self.max_results
4552

53+
# Check if Google quota has reset (after midnight UTC typically)
54+
if _google_quota_exhausted and time.time() > _google_quota_reset_time:
55+
_google_quota_exhausted = False
56+
logger.info("Google API quota reset, re-enabling Google search")
57+
58+
results = []
59+
4660
if self.provider == 'bing':
47-
return self._search_bing(query, limit)
61+
results = self._search_bing(query, limit)
62+
elif self.provider == 'duckduckgo':
63+
results = self._search_duckduckgo(query, limit)
4864
else:
49-
return self._search_google(query, limit, date_restrict)
65+
# Google with fallback
66+
if not _google_quota_exhausted:
67+
results = self._search_google(query, limit, date_restrict)
68+
69+
# If Google failed or returned empty, try fallbacks
70+
if not results:
71+
logger.info("Google search failed or empty, trying fallback search engines...")
72+
# Try Bing first if configured
73+
results = self._search_bing(query, limit)
74+
75+
# If Bing also failed, try DuckDuckGo (free, no API key needed)
76+
if not results:
77+
results = self._search_duckduckgo(query, limit)
78+
79+
return results
5080

5181
def _search_google(self, query: str, num_results: int, date_restrict: str = None) -> List[Dict[str, Any]]:
5282
"""Google Custom Search (CSE)."""
83+
global _google_quota_exhausted, _google_quota_reset_time
84+
5385
api_key = self._config.get('google', {}).get('api_key')
5486
cx = self._config.get('google', {}).get('cx')
5587

@@ -71,17 +103,23 @@ def _search_google(self, query: str, num_results: int, date_restrict: str = None
71103
params['dateRestrict'] = date_restrict
72104

73105
try:
74-
# logger.info(f"正在调用 Google Search API: q={query}, params={params}")
75106
response = requests.get(url, params=params, timeout=10)
107+
108+
# Check for quota exceeded (429)
109+
if response.status_code == 429:
110+
logger.warning("Google Search API quota exceeded (429). Switching to fallback search engines.")
111+
_google_quota_exhausted = True
112+
# Set reset time to next day midnight UTC
113+
import datetime
114+
tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
115+
_google_quota_reset_time = tomorrow.timestamp()
116+
return []
117+
76118
response.raise_for_status()
77119
data = response.json()
78120

79-
# logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)}") # 打印全部字符
80-
# logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)[:500]}...") # 打印前500字符避免日志过大
81-
82121
results = []
83122
if 'items' in data:
84-
# logger.info(f"Google Search 返回了 {len(data['items'])} 条结果")
85123
for item in data['items']:
86124
logger.debug(f"Search Item: {item.get('title')} - {item.get('link')}")
87125
results.append({
@@ -96,10 +134,20 @@ def _search_google(self, query: str, num_results: int, date_restrict: str = None
96134

97135
return results
98136

137+
except requests.exceptions.HTTPError as e:
138+
if hasattr(e, 'response') and e.response is not None and e.response.status_code == 429:
139+
logger.warning("Google Search API quota exceeded. Switching to fallback.")
140+
_google_quota_exhausted = True
141+
import datetime
142+
tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
143+
_google_quota_reset_time = tomorrow.timestamp()
144+
else:
145+
logger.error(f"Google search failed: {e}")
146+
if hasattr(e, 'response') and e.response is not None:
147+
logger.error(f"Response: {e.response.text}")
148+
return []
99149
except Exception as e:
100150
logger.error(f"Google search failed: {e}")
101-
if 'response' in locals():
102-
logger.error(f"Response: {response.text}")
103151
return []
104152

105153
def _search_bing(self, query: str, num_results: int) -> List[Dict[str, Any]]:
@@ -140,3 +188,120 @@ def _search_bing(self, query: str, num_results: int) -> List[Dict[str, Any]]:
140188
logger.error(f"Bing search failed: {e}")
141189
return []
142190

191+
def _search_duckduckgo(self, query: str, num_results: int) -> List[Dict[str, Any]]:
192+
"""
193+
DuckDuckGo search (free, no API key required).
194+
Uses the DuckDuckGo HTML search endpoint.
195+
"""
196+
try:
197+
# Use DuckDuckGo Instant Answer API
198+
url = "https://api.duckduckgo.com/"
199+
params = {
200+
'q': query,
201+
'format': 'json',
202+
'no_html': 1,
203+
'skip_disambig': 1
204+
}
205+
206+
response = requests.get(url, params=params, timeout=10)
207+
response.raise_for_status()
208+
data = response.json()
209+
210+
results = []
211+
212+
# Get results from RelatedTopics
213+
related_topics = data.get('RelatedTopics', [])
214+
for topic in related_topics[:num_results]:
215+
if isinstance(topic, dict):
216+
if 'FirstURL' in topic:
217+
results.append({
218+
'title': topic.get('Text', '')[:100],
219+
'link': topic.get('FirstURL', ''),
220+
'snippet': topic.get('Text', ''),
221+
'source': 'DuckDuckGo',
222+
'published': ''
223+
})
224+
# Handle nested topics
225+
elif 'Topics' in topic:
226+
for sub_topic in topic['Topics']:
227+
if len(results) >= num_results:
228+
break
229+
if 'FirstURL' in sub_topic:
230+
results.append({
231+
'title': sub_topic.get('Text', '')[:100],
232+
'link': sub_topic.get('FirstURL', ''),
233+
'snippet': sub_topic.get('Text', ''),
234+
'source': 'DuckDuckGo',
235+
'published': ''
236+
})
237+
238+
# Also check AbstractURL and AbstractText
239+
if data.get('AbstractURL') and len(results) < num_results:
240+
results.insert(0, {
241+
'title': data.get('Heading', query),
242+
'link': data.get('AbstractURL', ''),
243+
'snippet': data.get('AbstractText', ''),
244+
'source': 'DuckDuckGo',
245+
'published': ''
246+
})
247+
248+
# If no results from Instant Answer, try HTML scraping as fallback
249+
if not results:
250+
results = self._search_duckduckgo_html(query, num_results)
251+
252+
if results:
253+
logger.info(f"DuckDuckGo search returned {len(results)} results")
254+
255+
return results[:num_results]
256+
257+
except Exception as e:
258+
logger.error(f"DuckDuckGo search failed: {e}")
259+
# Try HTML fallback
260+
return self._search_duckduckgo_html(query, num_results)
261+
262+
def _search_duckduckgo_html(self, query: str, num_results: int) -> List[Dict[str, Any]]:
263+
"""
264+
DuckDuckGo HTML search fallback.
265+
Scrapes the lite HTML version for better results.
266+
"""
267+
try:
268+
url = "https://lite.duckduckgo.com/lite/"
269+
headers = {
270+
'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'
271+
}
272+
data = {'q': query}
273+
274+
response = requests.post(url, headers=headers, data=data, timeout=10)
275+
response.raise_for_status()
276+
277+
results = []
278+
279+
# Simple HTML parsing without BeautifulSoup
280+
html = response.text
281+
282+
# Find all result links (they have class="result-link")
283+
import re
284+
285+
# Pattern to find result entries
286+
link_pattern = r'<a[^>]*class="result-link"[^>]*href="([^"]*)"[^>]*>([^<]*)</a>'
287+
snippet_pattern = r'<td[^>]*class="result-snippet"[^>]*>([^<]*)</td>'
288+
289+
links = re.findall(link_pattern, html)
290+
snippets = re.findall(snippet_pattern, html)
291+
292+
for i, (link, title) in enumerate(links[:num_results]):
293+
snippet = snippets[i] if i < len(snippets) else ''
294+
if link and title:
295+
results.append({
296+
'title': title.strip(),
297+
'link': link,
298+
'snippet': snippet.strip(),
299+
'source': 'DuckDuckGo',
300+
'published': ''
301+
})
302+
303+
return results
304+
305+
except Exception as e:
306+
logger.error(f"DuckDuckGo HTML search failed: {e}")
307+
return []

backend_api_python/migrations/add_notification_settings.sql

Lines changed: 0 additions & 16 deletions
This file was deleted.

quantdinger_vue/public/maps/world-atlas.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

quantdinger_vue/public/maps/world.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Global Market Dashboard API
3+
*/
4+
import request from '@/utils/request'
5+
6+
const BASE_URL = '/api/global-market'
7+
8+
/**
9+
* Get global market overview (indices, forex, crypto, commodities)
10+
* Includes geo coordinates for world map display
11+
*/
12+
export function getMarketOverview () {
13+
return request({
14+
url: `${BASE_URL}/overview`,
15+
method: 'get'
16+
})
17+
}
18+
19+
/**
20+
* Get market heatmap data (crypto, stock sectors, forex)
21+
*/
22+
export function getMarketHeatmap () {
23+
return request({
24+
url: `${BASE_URL}/heatmap`,
25+
method: 'get'
26+
})
27+
}
28+
29+
/**
30+
* Get financial news - separated by language (cn/en)
31+
* @param {string} lang - Language filter: 'cn', 'en', or 'all' (default)
32+
*/
33+
export function getMarketNews (lang = 'all') {
34+
return request({
35+
url: `${BASE_URL}/news`,
36+
method: 'get',
37+
params: { lang }
38+
})
39+
}
40+
41+
/**
42+
* Get economic calendar with impact indicators
43+
*/
44+
export function getEconomicCalendar () {
45+
return request({
46+
url: `${BASE_URL}/calendar`,
47+
method: 'get'
48+
})
49+
}
50+
51+
/**
52+
* Get market sentiment (Fear & Greed Index, VIX)
53+
*/
54+
export function getMarketSentiment () {
55+
return request({
56+
url: `${BASE_URL}/sentiment`,
57+
method: 'get'
58+
})
59+
}
60+
61+
/**
62+
* Get trading opportunities based on technical analysis
63+
*/
64+
export function getTradingOpportunities () {
65+
return request({
66+
url: `${BASE_URL}/opportunities`,
67+
method: 'get'
68+
})
69+
}
70+
71+
/**
72+
* Force refresh all market data (clears cache)
73+
*/
74+
export function refreshMarketData () {
75+
return request({
76+
url: `${BASE_URL}/refresh`,
77+
method: 'post'
78+
})
79+
}

0 commit comments

Comments
 (0)