Merge branch 'brokermr810:main' into main

This commit is contained in:
PengfaGuo
2026-01-25 11:32:44 +08:00
committed by GitHub
27 changed files with 5122 additions and 119 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')
+6 -1
View File
@@ -379,9 +379,14 @@ def login_with_code():
(user['id'],)
)
db.commit()
affected = cur.rowcount
cur.close()
if affected == 0:
logger.error(f"Failed to update last_login_at: no rows affected for user_id={user['id']}")
else:
logger.info(f"Updated last_login_at for user_id={user['id']}")
except Exception as e:
logger.warning(f"Failed to update last_login_at: {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)
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',
+19 -11
View File
@@ -816,9 +816,6 @@ def get_strategy_notifications():
user_strategy_ids = [r.get('id') for r in rows if r.get('id')]
cur.close()
if not user_strategy_ids:
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': []}})
where = []
args = []
@@ -830,9 +827,15 @@ def get_strategy_notifications():
else:
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': []}})
else:
placeholders = ",".join(["?"] * len(user_strategy_ids))
where.append(f"strategy_id IN ({placeholders})")
args.extend(user_strategy_ids)
if user_strategy_ids:
placeholders = ",".join(["?"] * len(user_strategy_ids))
where.append(f"(strategy_id IN ({placeholders}) OR (strategy_id IS NULL AND user_id = ?))")
args.extend(user_strategy_ids)
args.append(user_id)
else:
# 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 > ?")
@@ -889,15 +892,18 @@ def mark_notification_read():
if not notification_id:
return jsonify({'code': 0, 'msg': 'Missing id'}), 400
# Only update notifications for user's strategies
# 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
WHERE id = ? AND strategy_id IN (SELECT id FROM qd_strategies_trading WHERE user_id = ?)
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)
(int(notification_id), user_id, user_id)
)
db.commit()
cur.close()
@@ -920,8 +926,9 @@ def mark_all_notifications_read():
"""
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)
)
db.commit()
cur.close()
@@ -944,8 +951,9 @@ def clear_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)
)
db.commit()
cur.close()
+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
@@ -768,8 +768,9 @@ def _send_monitor_notification(
cur.close()
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"<b>{error_title}</b>\n\n{error_msg}", parse_mode="HTML")
notifier._notify_telegram(chat_id=chat_id, text=f"<b>{error_title}</b>\n\n{error_msg}", token_override=token_override, parse_mode="HTML")
elif ch == 'email':
to_email = targets.get('email', '')
if to_email:
@@ -815,11 +816,13 @@ def _send_monitor_notification(
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"
)
@@ -1099,8 +1102,9 @@ def _check_position_alerts():
cur.close()
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, parse_mode="HTML")
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:
+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 []
@@ -52,29 +52,48 @@ class TradingExecutor:
self._ensure_db_columns()
def _ensure_db_columns(self):
"""确保必要的数据库字段存在"""
"""确保必要的数据库字段存在(支持 SQLite 和 PostgreSQL"""
try:
db_type = os.getenv('DB_TYPE', 'sqlite').lower()
with get_db_connection() as db:
cursor = db.cursor()
col_names = set()
# SQLite 兼容:使用 PRAGMA table_info 检查列是否存在
try:
cursor.execute("PRAGMA table_info(qd_strategy_positions)")
cols = cursor.fetchall() or []
col_names = {c.get('name') for c in cols if isinstance(c, dict)}
except Exception:
col_names = set()
if db_type == 'postgresql':
# PostgreSQL: 使用 information_schema 查询列
try:
cursor.execute("""
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)}
except Exception:
col_names = set()
else:
# SQLite: 使用 PRAGMA table_info
try:
cursor.execute("PRAGMA table_info(qd_strategy_positions)")
cols = cursor.fetchall() or []
col_names = {c.get('name') for c in cols if isinstance(c, dict)}
except Exception:
col_names = set()
if 'highest_price' not in col_names:
logger.info("Adding highest_price column to qd_strategy_positions (SQLite)...")
# SQLite 不支持 AFTER 子句,类型用 REAL 即可
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN highest_price REAL DEFAULT 0")
logger.info(f"Adding highest_price column to qd_strategy_positions ({db_type})...")
if db_type == 'postgresql':
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS highest_price DOUBLE PRECISION DEFAULT 0")
else:
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN highest_price REAL DEFAULT 0")
db.commit()
logger.info("highest_price column added")
if 'lowest_price' not in col_names:
logger.info("Adding lowest_price column to qd_strategy_positions (SQLite)...")
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN lowest_price REAL DEFAULT 0")
logger.info(f"Adding lowest_price column to qd_strategy_positions ({db_type})...")
if db_type == 'postgresql':
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS lowest_price DOUBLE PRECISION DEFAULT 0")
else:
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN lowest_price REAL DEFAULT 0")
db.commit()
logger.info("lowest_price column added")
@@ -170,9 +170,14 @@ class UserService:
(user['id'],)
)
db.commit()
affected = cur.rowcount
cur.close()
if affected == 0:
logger.error(f"Failed to update last_login_at: no rows affected for user_id={user['id']}")
else:
logger.info(f"Updated last_login_at for user_id={user['id']}")
except Exception as e:
logger.warning(f"Failed to update last_login_at: {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)
@@ -1,16 +0,0 @@
-- Migration: Add notification_settings column to qd_users table
-- Run this if you see error: column "notification_settings" does not exist
-- Add notification_settings column (if not exists)
DO $$
BEGIN
IF NOT EXISTS (
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 '';
RAISE NOTICE 'Column notification_settings added to qd_users';
ELSE
RAISE NOTICE 'Column notification_settings already exists';
END IF;
END $$;