Merge branch 'brokermr810:main' into main
This commit is contained in:
@@ -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')
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 $$;
|
||||
Reference in New Issue
Block a user