@@ -207,6 +207,7 @@ def login():
|
||||
'username': user.get('username', username),
|
||||
'nickname': user.get('nickname', 'User'),
|
||||
'avatar': user.get('avatar', '/avatar2.jpg'),
|
||||
'timezone': str(user.get('timezone') or '').strip(),
|
||||
'role': {
|
||||
'id': user.get('role', 'admin'),
|
||||
'permissions': _get_permissions(user.get('role', 'admin'))
|
||||
@@ -419,6 +420,7 @@ def login_with_code():
|
||||
'nickname': user.get('nickname', user['username']),
|
||||
'email': user.get('email'),
|
||||
'avatar': user.get('avatar', '/avatar2.jpg'),
|
||||
'timezone': str(user.get('timezone') or '').strip(),
|
||||
'role': {
|
||||
'id': user.get('role', 'user'),
|
||||
'permissions': _get_permissions(user.get('role', 'user'))
|
||||
@@ -685,6 +687,7 @@ def register():
|
||||
'nickname': username,
|
||||
'email': email,
|
||||
'avatar': '/avatar2.jpg',
|
||||
'timezone': '',
|
||||
'role': {
|
||||
'id': 'user',
|
||||
'permissions': _get_permissions('user')
|
||||
@@ -1049,6 +1052,7 @@ def get_user_info():
|
||||
'nickname': user_data.get('nickname', 'User'),
|
||||
'email': user_data.get('email'),
|
||||
'avatar': user_data.get('avatar', '/avatar2.jpg'),
|
||||
'timezone': str(user_data.get('timezone') or '').strip(),
|
||||
'role': {
|
||||
'id': user_data.get('role', 'user'),
|
||||
'permissions': _get_permissions(user_data.get('role', 'user'))
|
||||
@@ -1065,6 +1069,7 @@ def get_user_info():
|
||||
'username': username,
|
||||
'nickname': 'Admin',
|
||||
'avatar': '/avatar2.jpg',
|
||||
'timezone': '',
|
||||
'role': {
|
||||
'id': role,
|
||||
'permissions': _get_permissions(role)
|
||||
|
||||
@@ -3,6 +3,7 @@ Portfolio API routes (local-only).
|
||||
Manages manual positions (user's existing holdings) and AI monitoring tasks.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from datetime import date, datetime, timezone
|
||||
import json
|
||||
import traceback
|
||||
import time
|
||||
@@ -39,6 +40,27 @@ def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _serialize_monitor_ts(value):
|
||||
"""
|
||||
JSON 序列化监控时间字段。PostgreSQL TIMESTAMP 无 tz 时按 UTC 解释(与 Docker 默认一致),
|
||||
输出带 Z 的 ISO,避免前端把无时区字符串当本地时间而偏差 8 小时。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
dt = value.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = value.astimezone(timezone.utc)
|
||||
s = dt.isoformat()
|
||||
if s.endswith("+00:00"):
|
||||
return s[:-6] + "Z"
|
||||
return s
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_symbol(symbol: str) -> str:
|
||||
return (symbol or '').strip().upper()
|
||||
|
||||
@@ -519,12 +541,12 @@ def get_monitors():
|
||||
'config': _safe_json_loads(row.get('config'), {}),
|
||||
'notification_config': _safe_json_loads(row.get('notification_config'), {}),
|
||||
'is_active': bool(row.get('is_active')),
|
||||
'last_run_at': row.get('last_run_at'),
|
||||
'next_run_at': row.get('next_run_at'),
|
||||
'last_run_at': _serialize_monitor_ts(row.get('last_run_at')),
|
||||
'next_run_at': _serialize_monitor_ts(row.get('next_run_at')),
|
||||
'last_result': _safe_json_loads(row.get('last_result'), {}),
|
||||
'run_count': row.get('run_count') or 0,
|
||||
'created_at': row.get('created_at'),
|
||||
'updated_at': row.get('updated_at')
|
||||
'created_at': _serialize_monitor_ts(row.get('created_at')),
|
||||
'updated_at': _serialize_monitor_ts(row.get('updated_at'))
|
||||
})
|
||||
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': monitors})
|
||||
|
||||
@@ -872,12 +872,16 @@ def get_strategy_notifications():
|
||||
cur.close()
|
||||
|
||||
# Convert created_at to UTC timestamp (seconds) for frontend
|
||||
from datetime import timezone as _dt_tz
|
||||
processed_rows = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
created_at = item.get('created_at')
|
||||
if created_at:
|
||||
if hasattr(created_at, 'timestamp'):
|
||||
# 无时区 datetime:连接已 SET TIME ZONE UTC,按 UTC 解释再转 Unix,避免服务端本地 TZ 误判
|
||||
if getattr(created_at, 'tzinfo', None) is None:
|
||||
created_at = created_at.replace(tzinfo=_dt_tz.utc)
|
||||
item['created_at'] = int(created_at.timestamp())
|
||||
elif isinstance(created_at, str):
|
||||
try:
|
||||
|
||||
@@ -5,6 +5,7 @@ Provides endpoints for user CRUD operations, role management, etc.
|
||||
Only accessible by admin users.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app.services.user_service import get_user_service
|
||||
from app.utils.auth import login_required, admin_required
|
||||
@@ -13,6 +14,8 @@ from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_PROFILE_TIMEZONE_RE = re.compile(r'^[A-Za-z0-9_/+\-.]+$')
|
||||
|
||||
user_bp = Blueprint('user_manage', __name__)
|
||||
|
||||
|
||||
@@ -411,6 +414,7 @@ def update_profile():
|
||||
Request body:
|
||||
nickname: str (optional)
|
||||
avatar: str (optional)
|
||||
timezone: str (optional, IANA id; empty = follow client)
|
||||
|
||||
Note: Email cannot be changed after registration (for security).
|
||||
Only admin can change user email via User Management.
|
||||
@@ -429,6 +433,16 @@ def update_profile():
|
||||
if field in data:
|
||||
allowed[field] = data[field]
|
||||
|
||||
if 'timezone' in data:
|
||||
tz = (data.get('timezone') or '').strip()
|
||||
if tz and (len(tz) > 64 or not _PROFILE_TIMEZONE_RE.match(tz)):
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': 'Invalid timezone identifier',
|
||||
'data': None
|
||||
}), 400
|
||||
allowed['timezone'] = tz
|
||||
|
||||
if not allowed:
|
||||
return jsonify({'code': 0, 'msg': 'No valid fields to update', 'data': None}), 400
|
||||
|
||||
|
||||
@@ -65,6 +65,70 @@ def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _resolve_notification_delivery(user_id: int, notification_config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""
|
||||
合并个人中心保存的 notification_settings 到 targets,并规范化 channels。
|
||||
前端创建监控常只传 channels(email/telegram/webhook),不传 targets;若不合并则外发渠道全部跳过且无任何送达。
|
||||
若当前 channels 均无法送达(无邮箱/Chat ID 等),则追加 browser 保证站内通知。
|
||||
"""
|
||||
cfg: Dict[str, Any] = dict(notification_config) if isinstance(notification_config, dict) else {}
|
||||
raw_ch = cfg.get('channels')
|
||||
if isinstance(raw_ch, str):
|
||||
raw_ch = [raw_ch]
|
||||
elif not isinstance(raw_ch, list):
|
||||
raw_ch = []
|
||||
channels = [str(c).strip().lower() for c in raw_ch if c is not None and str(c).strip()]
|
||||
if not channels:
|
||||
channels = ['browser']
|
||||
|
||||
targets: Dict[str, Any] = dict(cfg.get('targets') or {})
|
||||
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT email, notification_settings FROM qd_users WHERE id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
account_email = ""
|
||||
settings = {}
|
||||
else:
|
||||
account_email = (row.get("email") or "").strip()
|
||||
settings = _safe_json_loads(row.get("notification_settings"), {})
|
||||
if not (targets.get("email") or "").strip():
|
||||
te = (settings.get("email") or "").strip()
|
||||
targets["email"] = te or account_email
|
||||
if not (targets.get("telegram") or "").strip():
|
||||
targets["telegram"] = (settings.get("telegram_chat_id") or "").strip()
|
||||
if not (targets.get("telegram_bot_token") or "").strip():
|
||||
targets["telegram_bot_token"] = (settings.get("telegram_bot_token") or "").strip()
|
||||
if not (targets.get("webhook") or "").strip():
|
||||
targets["webhook"] = (settings.get("webhook_url") or "").strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"_resolve_notification_delivery: load user {user_id} settings failed: {e}")
|
||||
|
||||
def _can_deliver(ch: str) -> bool:
|
||||
if ch == "browser":
|
||||
return True
|
||||
if ch == "email":
|
||||
return bool((targets.get("email") or "").strip())
|
||||
if ch == "telegram":
|
||||
return bool((targets.get("telegram") or "").strip())
|
||||
if ch == "webhook":
|
||||
return bool((targets.get("webhook") or "").strip())
|
||||
return False
|
||||
|
||||
if not any(_can_deliver(c) for c in channels):
|
||||
channels = list(dict.fromkeys(list(channels) + ["browser"]))
|
||||
|
||||
cfg["channels"] = channels
|
||||
cfg["targets"] = targets
|
||||
return cfg
|
||||
|
||||
|
||||
def _safe_json_loads(value, default=None):
|
||||
"""Safely parse JSON string."""
|
||||
if default is None:
|
||||
@@ -159,7 +223,7 @@ def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = No
|
||||
MAX_PARALLEL_ANALYSIS = 5
|
||||
|
||||
|
||||
def _analyze_single_position(pos: Dict[str, Any], language: str) -> Dict[str, Any]:
|
||||
def _analyze_single_position(pos: Dict[str, Any], language: str, user_id: int = None) -> Dict[str, Any]:
|
||||
"""Analyze a single position (designed to run inside a thread pool)."""
|
||||
market = pos.get('market')
|
||||
symbol = pos.get('symbol')
|
||||
@@ -170,10 +234,11 @@ def _analyze_single_position(pos: Dict[str, Any], language: str) -> Dict[str, An
|
||||
return {'market': market, 'symbol': symbol, 'name': name, 'error': 'missing market/symbol'}
|
||||
|
||||
try:
|
||||
logger.info(f"Running fast AI analysis for {market}:{symbol}")
|
||||
logger.info(f"Running fast AI analysis for {market}:{symbol} (user={user_id})")
|
||||
service = get_fast_analysis_service()
|
||||
analysis_result = service.analyze(
|
||||
market=market, symbol=symbol, language=language, timeframe='1D'
|
||||
market=market, symbol=symbol, language=language, timeframe='1D',
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
detailed = analysis_result.get('detailed_analysis', {})
|
||||
@@ -213,40 +278,104 @@ def _analyze_single_position(pos: Dict[str, Any], language: str) -> Dict[str, An
|
||||
return {'market': market, 'symbol': symbol, 'name': name, 'error': str(e)}
|
||||
|
||||
|
||||
def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any], user_id: int = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Run fast AI analysis on positions **in parallel** using a thread pool.
|
||||
Same (market, symbol) is analyzed only once; the result is shared across
|
||||
duplicate positions so we don't waste LLM calls or show redundant entries.
|
||||
"""
|
||||
try:
|
||||
language = config.get('language', 'en-US')
|
||||
custom_prompt = config.get('prompt', '')
|
||||
|
||||
workers = min(len(positions), MAX_PARALLEL_ANALYSIS)
|
||||
position_analyses: List[Dict[str, Any]] = [None] * len(positions)
|
||||
# ── Deduplicate by (market, symbol) ──
|
||||
unique_map: Dict[str, int] = {} # "market|symbol" -> index in unique_positions
|
||||
unique_positions: List[Dict[str, Any]] = []
|
||||
pos_to_unique: List[int] = [] # positions[i] -> unique_positions index
|
||||
for pos in positions:
|
||||
key = f"{pos.get('market')}|{pos.get('symbol')}"
|
||||
if key not in unique_map:
|
||||
unique_map[key] = len(unique_positions)
|
||||
unique_positions.append(pos)
|
||||
pos_to_unique.append(unique_map[key])
|
||||
|
||||
workers = min(len(unique_positions), MAX_PARALLEL_ANALYSIS)
|
||||
unique_analyses: List[Dict[str, Any]] = [None] * len(unique_positions)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
future_to_idx = {
|
||||
executor.submit(_analyze_single_position, pos, language): idx
|
||||
for idx, pos in enumerate(positions)
|
||||
executor.submit(_analyze_single_position, pos, language, user_id): idx
|
||||
for idx, pos in enumerate(unique_positions)
|
||||
}
|
||||
for future in as_completed(future_to_idx):
|
||||
idx = future_to_idx[future]
|
||||
try:
|
||||
position_analyses[idx] = future.result()
|
||||
unique_analyses[idx] = future.result()
|
||||
except Exception as e:
|
||||
pos = positions[idx]
|
||||
position_analyses[idx] = {
|
||||
pos = unique_positions[idx]
|
||||
unique_analyses[idx] = {
|
||||
'market': pos.get('market'), 'symbol': pos.get('symbol'),
|
||||
'name': pos.get('name') or pos.get('symbol'), 'error': str(e)
|
||||
}
|
||||
|
||||
analysis_report = _build_comprehensive_report(positions, position_analyses, language, custom_prompt)
|
||||
# ── Map back: each position gets its own copy with position-specific P&L ──
|
||||
position_analyses: List[Dict[str, Any]] = []
|
||||
seen_keys: set = set()
|
||||
for i, pos in enumerate(positions):
|
||||
key = f"{pos.get('market')}|{pos.get('symbol')}"
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
base = dict(unique_analyses[pos_to_unique[i]])
|
||||
base['entry_price'] = pos.get('entry_price')
|
||||
base['current_price'] = base.get('current_price') or pos.get('current_price')
|
||||
combined_qty = sum(
|
||||
float(p.get('quantity') or 0)
|
||||
for j, p in enumerate(positions)
|
||||
if f"{p.get('market')}|{p.get('symbol')}" == key
|
||||
)
|
||||
combined_cost = sum(
|
||||
float(p.get('entry_price') or 0) * float(p.get('quantity') or 0)
|
||||
for j, p in enumerate(positions)
|
||||
if f"{p.get('market')}|{p.get('symbol')}" == key
|
||||
)
|
||||
cur_price = float(base.get('current_price') or 0)
|
||||
combined_pnl = sum(
|
||||
float(p.get('pnl') or 0)
|
||||
for j, p in enumerate(positions)
|
||||
if f"{p.get('market')}|{p.get('symbol')}" == key
|
||||
)
|
||||
avg_entry = round(combined_cost / combined_qty, 4) if combined_qty else 0
|
||||
pnl_pct = round(combined_pnl / combined_cost * 100, 2) if combined_cost else 0
|
||||
base['quantity'] = combined_qty
|
||||
base['entry_price'] = avg_entry
|
||||
base['pnl'] = round(combined_pnl, 2)
|
||||
base['pnl_percent'] = pnl_pct
|
||||
position_analyses.append(base)
|
||||
|
||||
# Also provide deduplicated positions list for report building
|
||||
deduped_positions = []
|
||||
seen_keys2: set = set()
|
||||
for i, pos in enumerate(positions):
|
||||
key = f"{pos.get('market')}|{pos.get('symbol')}"
|
||||
if key in seen_keys2:
|
||||
continue
|
||||
seen_keys2.add(key)
|
||||
merged = dict(pos)
|
||||
merged['quantity'] = position_analyses[len(deduped_positions)].get('quantity', pos.get('quantity'))
|
||||
merged['entry_price'] = position_analyses[len(deduped_positions)].get('entry_price', pos.get('entry_price'))
|
||||
merged['pnl'] = position_analyses[len(deduped_positions)].get('pnl', pos.get('pnl'))
|
||||
merged['pnl_percent'] = position_analyses[len(deduped_positions)].get('pnl_percent', pos.get('pnl_percent'))
|
||||
deduped_positions.append(merged)
|
||||
|
||||
analysis_report = _build_comprehensive_report(deduped_positions, position_analyses, language, custom_prompt)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'analysis': analysis_report,
|
||||
'position_analyses': position_analyses,
|
||||
'position_count': len(positions),
|
||||
'positions': deduped_positions,
|
||||
'position_count': len(deduped_positions),
|
||||
'analyzed_count': len([p for p in position_analyses if not p.get('error')]),
|
||||
'timestamp': _now_ts()
|
||||
}
|
||||
@@ -629,109 +758,344 @@ def _build_telegram_report(
|
||||
language: str,
|
||||
custom_prompt: str = ''
|
||||
) -> str:
|
||||
"""Build a concise report suitable for Telegram (HTML format)."""
|
||||
|
||||
# Calculate summary
|
||||
total_cost = sum(float(p.get('entry_price', 0)) * float(p.get('quantity', 0)) for p in positions)
|
||||
total_pnl = sum(float(p.get('pnl', 0)) for p in positions)
|
||||
total_pnl_percent = round(total_pnl / total_cost * 100, 2) if total_cost > 0 else 0
|
||||
|
||||
"""Build a concise report suitable for Telegram (HTML format).
|
||||
|
||||
Positions with quantity>0 and entry_price>0 are shown with P&L;
|
||||
others are treated as watchlist items and only show current price.
|
||||
"""
|
||||
|
||||
def _has_holding(pa: Dict[str, Any]) -> bool:
|
||||
return float(pa.get('quantity') or 0) > 0 and float(pa.get('entry_price') or 0) > 0
|
||||
|
||||
held = [p for p in position_analyses if _has_holding(p) and not p.get('error')]
|
||||
watched = [p for p in position_analyses if not _has_holding(p) and not p.get('error')]
|
||||
errored = [p for p in position_analyses if p.get('error')]
|
||||
|
||||
total_cost = sum(float(p.get('entry_price', 0)) * float(p.get('quantity', 0)) for p in held)
|
||||
total_pnl = sum(float(p.get('pnl', 0)) for p in held)
|
||||
total_pnl_pct = round(total_pnl / total_cost * 100, 2) if total_cost > 0 else 0
|
||||
pnl_sign = '+' if total_pnl >= 0 else ''
|
||||
|
||||
buy_count = len([p for p in position_analyses if p.get('final_decision') == 'BUY'])
|
||||
sell_count = len([p for p in position_analyses if p.get('final_decision') == 'SELL'])
|
||||
hold_count = len([p for p in position_analyses if p.get('final_decision') == 'HOLD'])
|
||||
|
||||
|
||||
is_zh = language.startswith('zh')
|
||||
pnl_sign = '+' if total_pnl >= 0 else ''
|
||||
|
||||
|
||||
# ── Header / Overview ──
|
||||
if is_zh:
|
||||
lines = [
|
||||
"<b>📊 投资组合AI分析报告</b>",
|
||||
"",
|
||||
"<b>📈 组合概览</b>",
|
||||
f"• 持仓: {len(positions)} 个",
|
||||
f"• 总成本: ${total_cost:,.2f}",
|
||||
f"• 总盈亏: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_percent:.1f}%)",
|
||||
lines: List[str] = ["<b>📊 AI资产分析报告</b>", ""]
|
||||
overview = ["<b>📈 概览</b>"]
|
||||
if held:
|
||||
overview.append(f"• 持仓: {len(held)} 个")
|
||||
overview.append(f"• 总成本: ${total_cost:,.2f}")
|
||||
overview.append(f"• 总盈亏: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)")
|
||||
if watched:
|
||||
overview.append(f"• 观察: {len(watched)} 个")
|
||||
lines.extend(overview)
|
||||
lines.extend([
|
||||
"",
|
||||
"<b>🤖 AI建议汇总</b>",
|
||||
f"🟢 买入: {buy_count} | 🔴 卖出: {sell_count} | 🟡 持有: {hold_count}",
|
||||
"",
|
||||
"<b>📋 持仓分析</b>"
|
||||
]
|
||||
|
||||
for pa in position_analyses:
|
||||
if pa.get('error'):
|
||||
lines.append(f"⚠️ <b>{pa.get('name', pa.get('symbol'))}</b>: 分析失败")
|
||||
continue
|
||||
|
||||
decision = pa.get('final_decision', 'HOLD')
|
||||
emoji = {'BUY': '🟢', 'SELL': '🔴', 'HOLD': '🟡'}.get(decision, '⚪')
|
||||
text = {'BUY': '买入', 'SELL': '卖出', 'HOLD': '持有'}.get(decision, '持有')
|
||||
pnl = pa.get('pnl', 0)
|
||||
pnl_pct = pa.get('pnl_percent', 0)
|
||||
pnl_s = '+' if pnl >= 0 else ''
|
||||
|
||||
lines.append(f"\n{emoji} <b>{pa.get('name', pa.get('symbol'))}</b> ({pa.get('market')}/{pa.get('symbol')})")
|
||||
lines.append(f" 💰 ${pa.get('current_price', 0):.2f} | 盈亏: {pnl_s}${pnl:.2f} ({pnl_s}{pnl_pct:.1f}%)")
|
||||
lines.append(f" 🎯 建议: <b>{text}</b> (置信度 {pa.get('confidence', 50)}%)")
|
||||
|
||||
reasoning = pa.get('reasoning', '')
|
||||
if reasoning:
|
||||
lines.append(f" 📝 {reasoning[:150]}{'...' if len(reasoning) > 150 else ''}")
|
||||
|
||||
if custom_prompt:
|
||||
lines.extend(["", f"<b>👤 关注点:</b> {custom_prompt}"])
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"─────────────────────",
|
||||
f"<i>⏰ {time.strftime('%Y-%m-%d %H:%M')}</i>",
|
||||
"<i>由 QuantDinger 多智能体系统生成</i>"
|
||||
])
|
||||
else:
|
||||
lines = [
|
||||
"<b>📊 Portfolio AI Analysis Report</b>",
|
||||
"",
|
||||
"<b>📈 Overview</b>",
|
||||
f"• Positions: {len(positions)}",
|
||||
f"• Total Cost: ${total_cost:,.2f}",
|
||||
f"• Total P&L: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_percent:.1f}%)",
|
||||
lines = ["<b>📊 AI Asset Analysis Report</b>", ""]
|
||||
overview = ["<b>📈 Overview</b>"]
|
||||
if held:
|
||||
overview.append(f"• Holdings: {len(held)}")
|
||||
overview.append(f"• Total Cost: ${total_cost:,.2f}")
|
||||
overview.append(f"• Total P&L: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)")
|
||||
if watched:
|
||||
overview.append(f"• Watchlist: {len(watched)}")
|
||||
lines.extend(overview)
|
||||
lines.extend([
|
||||
"",
|
||||
"<b>🤖 AI Recommendations</b>",
|
||||
f"🟢 Buy: {buy_count} | 🔴 Sell: {sell_count} | 🟡 Hold: {hold_count}",
|
||||
"",
|
||||
"<b>📋 Position Analysis</b>"
|
||||
]
|
||||
|
||||
for pa in position_analyses:
|
||||
if pa.get('error'):
|
||||
lines.append(f"⚠️ <b>{pa.get('name', pa.get('symbol'))}</b>: Analysis failed")
|
||||
continue
|
||||
|
||||
decision = pa.get('final_decision', 'HOLD')
|
||||
emoji = {'BUY': '🟢', 'SELL': '🔴', 'HOLD': '🟡'}.get(decision, '⚪')
|
||||
])
|
||||
|
||||
# ── Helper: render one analysis entry ──
|
||||
def _render_pa(pa: Dict[str, Any], show_pnl: bool) -> None:
|
||||
decision = pa.get('final_decision', 'HOLD')
|
||||
emoji = {'BUY': '🟢', 'SELL': '🔴', 'HOLD': '🟡'}.get(decision, '⚪')
|
||||
d_text = decision
|
||||
if is_zh:
|
||||
d_text = {'BUY': '买入', 'SELL': '卖出', 'HOLD': '持有'}.get(decision, '持有')
|
||||
lines.append(f"\n{emoji} <b>{pa.get('name', pa.get('symbol'))}</b> ({pa.get('market')}/{pa.get('symbol')})")
|
||||
if show_pnl:
|
||||
pnl = pa.get('pnl', 0)
|
||||
pnl_pct = pa.get('pnl_percent', 0)
|
||||
pnl_s = '+' if pnl >= 0 else ''
|
||||
|
||||
lines.append(f"\n{emoji} <b>{pa.get('name', pa.get('symbol'))}</b> ({pa.get('market')}/{pa.get('symbol')})")
|
||||
lines.append(f" 💰 ${pa.get('current_price', 0):.2f} | P&L: {pnl_s}${pnl:.2f} ({pnl_s}{pnl_pct:.1f}%)")
|
||||
lines.append(f" 🎯 Rec: <b>{decision}</b> (Conf: {pa.get('confidence', 50)}%)")
|
||||
|
||||
ps = '+' if pnl >= 0 else ''
|
||||
lines.append(
|
||||
f" 💰 ${pa.get('current_price', 0):,.2f} | "
|
||||
f"{'盈亏' if is_zh else 'P&L'}: {ps}${pnl:,.2f} ({ps}{pnl_pct:.1f}%)"
|
||||
)
|
||||
else:
|
||||
lines.append(f" 💰 {'现价' if is_zh else 'Price'}: ${pa.get('current_price', 0):,.2f}")
|
||||
lines.append(
|
||||
f" 🎯 {'建议' if is_zh else 'Rec'}: <b>{d_text}</b> "
|
||||
f"({'置信度' if is_zh else 'Conf'}: {pa.get('confidence', 50)}%)"
|
||||
)
|
||||
reasoning = pa.get('reasoning', '')
|
||||
if reasoning:
|
||||
lines.append(f" 📝 {reasoning[:150]}{'...' if len(reasoning) > 150 else ''}")
|
||||
|
||||
# ── Holdings section ──
|
||||
if held:
|
||||
lines.extend(["", f"<b>📋 {'持仓分析' if is_zh else 'Holdings'}</b>"])
|
||||
for pa in held:
|
||||
_render_pa(pa, show_pnl=True)
|
||||
|
||||
# ── Watchlist section ──
|
||||
if watched:
|
||||
lines.extend(["", f"<b>👁 {'观察列表' if is_zh else 'Watchlist'}</b>"])
|
||||
for pa in watched:
|
||||
_render_pa(pa, show_pnl=False)
|
||||
|
||||
# ── Errors ──
|
||||
for pa in errored:
|
||||
label = pa.get('name') or pa.get('symbol') or '?'
|
||||
lines.append(f"\n⚠️ <b>{label}</b>: {'分析失败' if is_zh else 'Analysis failed'}")
|
||||
|
||||
if custom_prompt:
|
||||
lines.extend(["", f"<b>👤 {'关注点' if is_zh else 'Focus'}:</b> {custom_prompt}"])
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"─────────────────────",
|
||||
f"<i>⏰ {time.strftime('%Y-%m-%d %H:%M')}</i>",
|
||||
f"<i>{'由 QuantDinger 多智能体系统生成' if is_zh else 'Generated by QuantDinger Multi-Agent System'}</i>",
|
||||
])
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _build_batch_telegram_report(
|
||||
monitor_results: List[Dict[str, Any]],
|
||||
language: str,
|
||||
) -> str:
|
||||
"""Build a single Telegram report that combines multiple monitor results."""
|
||||
is_zh = language.startswith('zh')
|
||||
|
||||
def _has_holding(pa: Dict[str, Any]) -> bool:
|
||||
return float(pa.get('quantity') or 0) > 0 and float(pa.get('entry_price') or 0) > 0
|
||||
|
||||
all_analyses: List[Dict[str, Any]] = []
|
||||
monitor_sections: List[str] = []
|
||||
|
||||
for res in monitor_results:
|
||||
meta = res.get('_meta', {})
|
||||
m_name = meta.get('monitor_name', '?')
|
||||
m_analyses = meta.get('position_analyses', [])
|
||||
all_analyses.extend(m_analyses)
|
||||
|
||||
section_lines: List[str] = [f"\n<b>📋 {m_name}</b>"]
|
||||
for pa in m_analyses:
|
||||
if pa.get('error'):
|
||||
label = pa.get('name') or pa.get('symbol') or '?'
|
||||
section_lines.append(f" ⚠️ {label}: {'分析失败' if is_zh else 'Failed'}")
|
||||
continue
|
||||
decision = pa.get('final_decision', 'HOLD')
|
||||
emoji = {'BUY': '🟢', 'SELL': '🔴', 'HOLD': '🟡'}.get(decision, '⚪')
|
||||
d_text = ({'BUY': '买入', 'SELL': '卖出', 'HOLD': '持有'}.get(decision, '持有')) if is_zh else decision
|
||||
cur_price = pa.get('current_price', 0)
|
||||
section_lines.append(
|
||||
f"{emoji} <b>{pa.get('name', pa.get('symbol'))}</b> ({pa.get('market')}/{pa.get('symbol')})"
|
||||
)
|
||||
if _has_holding(pa):
|
||||
pnl = pa.get('pnl', 0)
|
||||
pnl_s = '+' if pnl >= 0 else ''
|
||||
pnl_pct = pa.get('pnl_percent', 0)
|
||||
section_lines.append(
|
||||
f" 💰 ${cur_price:,.2f} | {'盈亏' if is_zh else 'P&L'}: {pnl_s}${pnl:,.2f} ({pnl_s}{pnl_pct:.1f}%)"
|
||||
)
|
||||
else:
|
||||
section_lines.append(f" 💰 {'现价' if is_zh else 'Price'}: ${cur_price:,.2f}")
|
||||
section_lines.append(
|
||||
f" 🎯 {'建议' if is_zh else 'Rec'}: <b>{d_text}</b> "
|
||||
f"({'置信度' if is_zh else 'Conf'}: {pa.get('confidence', 50)}%)"
|
||||
)
|
||||
reasoning = pa.get('reasoning', '')
|
||||
if reasoning:
|
||||
lines.append(f" 📝 {reasoning[:150]}{'...' if len(reasoning) > 150 else ''}")
|
||||
|
||||
if custom_prompt:
|
||||
lines.extend(["", f"<b>👤 Focus:</b> {custom_prompt}"])
|
||||
|
||||
lines.extend([
|
||||
section_lines.append(f" 📝 {reasoning[:120]}{'...' if len(reasoning) > 120 else ''}")
|
||||
monitor_sections.append('\n'.join(section_lines))
|
||||
|
||||
held = [a for a in all_analyses if _has_holding(a) and not a.get('error')]
|
||||
watched = [a for a in all_analyses if not _has_holding(a) and not a.get('error')]
|
||||
total_cost = sum(float(a.get('entry_price', 0)) * float(a.get('quantity', 0)) for a in held)
|
||||
total_pnl = sum(float(a.get('pnl', 0)) for a in held)
|
||||
total_pnl_pct = round(total_pnl / total_cost * 100, 2) if total_cost else 0
|
||||
pnl_sign = '+' if total_pnl >= 0 else ''
|
||||
buy_c = len([a for a in all_analyses if a.get('final_decision') == 'BUY'])
|
||||
sell_c = len([a for a in all_analyses if a.get('final_decision') == 'SELL'])
|
||||
hold_c = len([a for a in all_analyses if a.get('final_decision') == 'HOLD'])
|
||||
|
||||
if is_zh:
|
||||
header = [
|
||||
"<b>📊 定时资产监测报告</b>",
|
||||
"",
|
||||
"─────────────────────",
|
||||
f"<i>⏰ {time.strftime('%Y-%m-%d %H:%M')}</i>",
|
||||
"<i>Generated by QuantDinger Multi-Agent System</i>"
|
||||
"<b>📈 综合概览</b>",
|
||||
f"• 监控任务: {len(monitor_results)} 个",
|
||||
f"• 标的数量: {len(all_analyses)} 个",
|
||||
]
|
||||
if held:
|
||||
header.append(f"• 持仓: {len(held)} 个 | 总成本: ${total_cost:,.2f} | 盈亏: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)")
|
||||
if watched:
|
||||
header.append(f"• 观察: {len(watched)} 个")
|
||||
header.extend([
|
||||
"",
|
||||
"<b>🤖 AI建议汇总</b>",
|
||||
f"🟢 买入: {buy_c} | 🔴 卖出: {sell_c} | 🟡 持有: {hold_c}",
|
||||
])
|
||||
|
||||
return '\n'.join(lines)
|
||||
else:
|
||||
header = [
|
||||
"<b>📊 Scheduled Portfolio Report</b>",
|
||||
"",
|
||||
"<b>📈 Summary</b>",
|
||||
f"• Monitors: {len(monitor_results)}",
|
||||
f"• Symbols: {len(all_analyses)}",
|
||||
]
|
||||
if held:
|
||||
header.append(f"• Holdings: {len(held)} | Cost: ${total_cost:,.2f} | P&L: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)")
|
||||
if watched:
|
||||
header.append(f"• Watchlist: {len(watched)}")
|
||||
header.extend([
|
||||
"",
|
||||
"<b>🤖 AI Recommendations</b>",
|
||||
f"🟢 Buy: {buy_c} | 🔴 Sell: {sell_c} | 🟡 Hold: {hold_c}",
|
||||
])
|
||||
|
||||
footer = [
|
||||
"",
|
||||
"─────────────────────",
|
||||
f"<i>⏰ {time.strftime('%Y-%m-%d %H:%M')}</i>",
|
||||
f"<i>{'由 QuantDinger 多智能体系统生成' if is_zh else 'Generated by QuantDinger Multi-Agent System'}</i>",
|
||||
]
|
||||
|
||||
return '\n'.join(header + monitor_sections + footer)
|
||||
|
||||
|
||||
def _build_batch_html_report(
|
||||
monitor_results: List[Dict[str, Any]],
|
||||
language: str,
|
||||
) -> str:
|
||||
"""Build a combined HTML report for browser / email channel."""
|
||||
parts: List[str] = []
|
||||
for res in monitor_results:
|
||||
report = res.get('analysis', '')
|
||||
if report:
|
||||
parts.append(report)
|
||||
if not parts:
|
||||
return ''
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
divider = '<hr style="border:none;border-top:1px solid #e8e8e8;margin:24px 0;">'
|
||||
return divider.join(parts)
|
||||
|
||||
|
||||
def _send_batch_notification(
|
||||
user_id: int,
|
||||
monitor_results: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""Send a single combined notification for multiple monitor results belonging to one user."""
|
||||
if not monitor_results:
|
||||
return
|
||||
|
||||
successful = [r for r in monitor_results if r.get('success')]
|
||||
if not successful:
|
||||
for r in monitor_results:
|
||||
meta = r.get('_meta', {})
|
||||
_send_monitor_notification(
|
||||
monitor_name=meta.get('monitor_name', '?'),
|
||||
result=r,
|
||||
notification_config=meta.get('notification_config', {}),
|
||||
positions=meta.get('positions', []),
|
||||
position_analyses=meta.get('position_analyses', []),
|
||||
language=meta.get('language', 'en-US'),
|
||||
custom_prompt=meta.get('custom_prompt', ''),
|
||||
user_id=user_id,
|
||||
)
|
||||
return
|
||||
|
||||
first_meta = successful[0].get('_meta', {})
|
||||
language = first_meta.get('language', 'en-US')
|
||||
|
||||
# Merge channels from all monitors (union)
|
||||
all_channels: set = set()
|
||||
for r in successful:
|
||||
m = r.get('_meta', {})
|
||||
nc = m.get('notification_config', {})
|
||||
chs = nc.get('channels')
|
||||
if isinstance(chs, str):
|
||||
chs = [chs]
|
||||
elif not isinstance(chs, list):
|
||||
chs = []
|
||||
for c in chs:
|
||||
if c:
|
||||
all_channels.add(str(c).strip().lower())
|
||||
if not all_channels:
|
||||
all_channels = {'browser'}
|
||||
|
||||
merged_nc = {'channels': list(all_channels), 'targets': {}}
|
||||
resolved_nc = _resolve_notification_delivery(user_id, merged_nc)
|
||||
channels = resolved_nc.get('channels') or ['browser']
|
||||
targets = resolved_nc.get('targets', {})
|
||||
|
||||
is_zh = language.startswith('zh')
|
||||
names = ', '.join(r.get('_meta', {}).get('monitor_name', '?') for r in successful)
|
||||
title = f"📊 定时资产监测: {names}" if is_zh else f"📊 Scheduled Report: {names}"
|
||||
if len(title) > 255:
|
||||
title = title[:252] + '...'
|
||||
|
||||
html_report = _build_batch_html_report(successful, language)
|
||||
telegram_report = _build_batch_telegram_report(successful, language)
|
||||
|
||||
try:
|
||||
notifier = SignalNotifier()
|
||||
for channel in channels:
|
||||
try:
|
||||
ch = str(channel).strip().lower()
|
||||
if ch == 'browser':
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_notifications
|
||||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW())
|
||||
""",
|
||||
(user_id, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report,
|
||||
json.dumps({'batch': True, 'count': len(successful)}, ensure_ascii=False, default=str)),
|
||||
)
|
||||
db.commit()
|
||||
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=telegram_report,
|
||||
token_override=token_override, parse_mode="HTML",
|
||||
)
|
||||
elif ch == 'email':
|
||||
to_email = targets.get('email', '')
|
||||
if to_email:
|
||||
notifier._notify_email(
|
||||
to_email=to_email, subject=title,
|
||||
body_text=html_report, body_html=html_report,
|
||||
)
|
||||
elif ch == 'webhook':
|
||||
url = targets.get('webhook', '')
|
||||
if url:
|
||||
notifier._notify_webhook(url=url, payload={
|
||||
'type': 'portfolio_monitor_batch',
|
||||
'monitors': [r.get('_meta', {}).get('monitor_name') for r in successful],
|
||||
'html_report': html_report,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Batch notification channel {channel} failed: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"_send_batch_notification failed: {e}")
|
||||
|
||||
|
||||
def _send_monitor_notification(
|
||||
@@ -748,14 +1112,19 @@ def _send_monitor_notification(
|
||||
try:
|
||||
notifier = SignalNotifier()
|
||||
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
|
||||
notification_config = _resolve_notification_delivery(effective_user_id, notification_config)
|
||||
|
||||
channels = notification_config.get('channels', ['browser'])
|
||||
channels = notification_config.get('channels') or ['browser']
|
||||
targets = notification_config.get('targets', {})
|
||||
|
||||
title = f"📊 资产监测: {monitor_name}" if language.startswith('zh') else f"📊 Portfolio Monitor: {monitor_name}"
|
||||
if len(title) > 255:
|
||||
title = title[:252] + '...'
|
||||
|
||||
if not result.get('success'):
|
||||
error_title = f"⚠️ 资产监测失败: {monitor_name}" if language.startswith('zh') else f"⚠️ Monitor Failed: {monitor_name}"
|
||||
if len(error_title) > 255:
|
||||
error_title = error_title[:252] + '...'
|
||||
error_msg = f"分析失败: {result.get('error', 'Unknown error')}" if language.startswith('zh') else f"Analysis failed: {result.get('error', 'Unknown error')}"
|
||||
|
||||
for channel in channels:
|
||||
@@ -771,7 +1140,7 @@ def _send_monitor_notification(
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW())
|
||||
""",
|
||||
(effective_user_id, 'PORTFOLIO', 'ai_monitor', 'browser', error_title, error_msg,
|
||||
json.dumps(result, ensure_ascii=False))
|
||||
json.dumps(result, ensure_ascii=False, default=str))
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -818,7 +1187,7 @@ def _send_monitor_notification(
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW())
|
||||
""",
|
||||
(effective_user_id, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report,
|
||||
json.dumps(result, ensure_ascii=False))
|
||||
json.dumps(result, ensure_ascii=False, default=str))
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -866,19 +1235,23 @@ def _send_monitor_notification(
|
||||
logger.error(f"_send_monitor_notification failed: {e}")
|
||||
|
||||
|
||||
def run_single_monitor(monitor_id: int, override_language: str = None, user_id: int = None) -> Dict[str, Any]:
|
||||
def run_single_monitor(
|
||||
monitor_id: int,
|
||||
override_language: str = None,
|
||||
user_id: int = None,
|
||||
skip_notification: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run a single monitor and return the result.
|
||||
|
||||
|
||||
Args:
|
||||
monitor_id: The monitor ID to run
|
||||
override_language: Optional language override (e.g., 'zh-CN', 'en-US')
|
||||
If provided, will override the language in monitor config
|
||||
user_id: Optional user ID for user isolation
|
||||
skip_notification: If True, do NOT send a notification (caller will batch-send later)
|
||||
"""
|
||||
try:
|
||||
# Use provided user_id or default
|
||||
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
|
||||
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -894,46 +1267,78 @@ def run_single_monitor(monitor_id: int, override_language: str = None, user_id:
|
||||
|
||||
if not row:
|
||||
return {'success': False, 'error': 'Monitor not found'}
|
||||
|
||||
|
||||
monitor_user_id = int(row.get('user_id') or effective_user_id)
|
||||
name = row.get('name') or f'Monitor #{monitor_id}'
|
||||
position_ids = _safe_json_loads(row.get('position_ids'), [])
|
||||
monitor_type = row.get('monitor_type') or 'ai'
|
||||
config = _safe_json_loads(row.get('config'), {})
|
||||
notification_config = _safe_json_loads(row.get('notification_config'), {})
|
||||
|
||||
# Override language if provided (from frontend)
|
||||
|
||||
if override_language:
|
||||
config['language'] = override_language
|
||||
|
||||
# Resolve interval (frontend sends run_interval_minutes, legacy uses interval_minutes)
|
||||
|
||||
interval_minutes = int(
|
||||
config.get('run_interval_minutes')
|
||||
or config.get('interval_minutes')
|
||||
or 60
|
||||
)
|
||||
|
||||
# Get positions (or build from config.symbol if no position_ids)
|
||||
positions = _get_positions_for_monitor(position_ids if position_ids else None, user_id=monitor_user_id)
|
||||
if position_ids:
|
||||
positions = _get_positions_for_monitor(position_ids, user_id=monitor_user_id)
|
||||
elif config.get('symbol'):
|
||||
target_sym = config['symbol'].strip().upper()
|
||||
target_mkt = (config.get('market') or '').strip()
|
||||
|
||||
# If monitor was created without positions but has symbol in config, build a virtual position
|
||||
if not positions and config.get('symbol'):
|
||||
positions = [{
|
||||
'market': config.get('market', ''),
|
||||
'symbol': config.get('symbol', ''),
|
||||
'name': config.get('symbol', ''),
|
||||
'side': 'long',
|
||||
'quantity': 0,
|
||||
'entry_price': 0,
|
||||
'current_price': 0,
|
||||
'pnl': 0,
|
||||
'pnl_percent': 0,
|
||||
}]
|
||||
# Rule 4: symbol deleted from watchlist → skip
|
||||
still_in_watchlist = False
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
wl_sql = "SELECT 1 FROM qd_watchlist WHERE user_id = ? AND UPPER(symbol) = ?"
|
||||
wl_args: list = [monitor_user_id, target_sym]
|
||||
if target_mkt:
|
||||
wl_sql += " AND market = ?"
|
||||
wl_args.append(target_mkt)
|
||||
wl_sql += " LIMIT 1"
|
||||
cur.execute(wl_sql, tuple(wl_args))
|
||||
still_in_watchlist = cur.fetchone() is not None
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Monitor #{monitor_id} watchlist check failed: {e}")
|
||||
|
||||
if not still_in_watchlist:
|
||||
logger.info(f"Monitor #{monitor_id} skipped: {target_mkt}:{target_sym} removed from watchlist")
|
||||
return {'success': False, 'error': 'Symbol removed from watchlist'}
|
||||
|
||||
# Rules 1&2: match real position if exists, otherwise virtual observation
|
||||
matched = _get_positions_for_monitor(None, user_id=monitor_user_id)
|
||||
positions = [
|
||||
p for p in matched
|
||||
if (p.get('symbol') or '').strip().upper() == target_sym
|
||||
and (not target_mkt or (p.get('market') or '').strip() == target_mkt)
|
||||
]
|
||||
if not positions:
|
||||
positions = [{
|
||||
'market': target_mkt,
|
||||
'symbol': config['symbol'].strip(),
|
||||
'name': config.get('name', config['symbol']).strip(),
|
||||
'side': 'long',
|
||||
'quantity': 0,
|
||||
'entry_price': 0,
|
||||
'current_price': 0,
|
||||
'pnl': 0,
|
||||
'pnl_percent': 0,
|
||||
}]
|
||||
else:
|
||||
# Rule 5: no position_ids, no config.symbol → nothing to analyze
|
||||
positions = []
|
||||
|
||||
if not positions:
|
||||
return {'success': False, 'error': 'No positions to analyze'}
|
||||
|
||||
# ── Billing: charge per symbol analyzed ──
|
||||
logger.info(f"Monitor #{monitor_id} skipped: no matching positions found")
|
||||
return {'success': False, 'error': 'No matching positions found'}
|
||||
|
||||
# ── Billing ──
|
||||
billing = get_billing_service()
|
||||
symbol_count = len(positions)
|
||||
per_symbol_cost = billing.get_feature_cost('ai_analysis')
|
||||
@@ -961,45 +1366,57 @@ def run_single_monitor(monitor_id: int, override_language: str = None, user_id:
|
||||
logger.warning(f"Monitor #{monitor_id} billing failed at symbol #{i+1}: {msg}")
|
||||
break
|
||||
|
||||
# Run analysis based on type
|
||||
if monitor_type == 'ai':
|
||||
result = _run_ai_analysis(positions, config)
|
||||
result = _run_ai_analysis(positions, config, user_id=monitor_user_id)
|
||||
else:
|
||||
result = {'success': False, 'error': f'Unsupported monitor type: {monitor_type}'}
|
||||
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_position_monitors
|
||||
SET last_run_at = NOW(),
|
||||
next_run_at = NOW() + INTERVAL '%s minutes',
|
||||
last_result = ?,
|
||||
run_count = run_count + 1,
|
||||
SET last_run_at = NOW(),
|
||||
next_run_at = NOW() + INTERVAL '%s minutes',
|
||||
last_result = ?,
|
||||
run_count = run_count + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(interval_minutes, json.dumps(result, ensure_ascii=False), monitor_id)
|
||||
(interval_minutes, json.dumps(result, ensure_ascii=False, default=str), monitor_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
# Send notification
|
||||
if notification_config.get('channels'):
|
||||
language = config.get('language', 'en-US')
|
||||
custom_prompt = config.get('prompt', '')
|
||||
position_analyses = result.get('position_analyses', [])
|
||||
|
||||
language = config.get('language', 'en-US')
|
||||
custom_prompt = config.get('prompt', '')
|
||||
position_analyses = result.get('position_analyses', [])
|
||||
deduped_positions = result.get('positions', positions)
|
||||
|
||||
# Attach metadata used by batch notification / history
|
||||
result['_meta'] = {
|
||||
'monitor_id': monitor_id,
|
||||
'monitor_name': name,
|
||||
'user_id': monitor_user_id,
|
||||
'language': language,
|
||||
'custom_prompt': custom_prompt,
|
||||
'notification_config': notification_config,
|
||||
'positions': deduped_positions,
|
||||
'position_analyses': position_analyses,
|
||||
}
|
||||
|
||||
if not skip_notification:
|
||||
_send_monitor_notification(
|
||||
monitor_name=name,
|
||||
result=result,
|
||||
notification_config=notification_config,
|
||||
positions=positions,
|
||||
positions=deduped_positions,
|
||||
position_analyses=position_analyses,
|
||||
language=language,
|
||||
custom_prompt=custom_prompt,
|
||||
user_id=monitor_user_id
|
||||
user_id=monitor_user_id,
|
||||
)
|
||||
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"run_single_monitor failed: {e}")
|
||||
@@ -1132,9 +1549,10 @@ def _check_position_alerts():
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
# Send notification
|
||||
channels = notification_config.get('channels', ['browser'])
|
||||
targets = notification_config.get('targets', {})
|
||||
# Send notification(合并个人中心通知配置,与资产监控任务一致)
|
||||
resolved = _resolve_notification_delivery(alert_user_id, notification_config)
|
||||
channels = resolved.get('channels') or ['browser']
|
||||
targets = resolved.get('targets', {})
|
||||
alert_title = _get_alert_title(alert_language)
|
||||
|
||||
for channel in channels:
|
||||
@@ -1254,15 +1672,17 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type:
|
||||
|
||||
|
||||
def _monitor_loop():
|
||||
"""Background loop that checks and runs due monitors."""
|
||||
"""Background loop that checks and runs due monitors.
|
||||
|
||||
All monitors due in the same cycle are executed first (with skip_notification),
|
||||
then results are grouped by user_id and sent as one combined notification per user.
|
||||
"""
|
||||
logger.info("Portfolio monitor background loop started")
|
||||
|
||||
|
||||
while not _stop_event.is_set():
|
||||
try:
|
||||
# 1. Check position alerts (price/pnl alerts) for all users
|
||||
_check_position_alerts()
|
||||
|
||||
# 2. Find AI monitors that are due for all users
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -1270,29 +1690,57 @@ def _monitor_loop():
|
||||
SELECT id, user_id FROM qd_position_monitors
|
||||
WHERE is_active = 1 AND next_run_at <= NOW()
|
||||
ORDER BY next_run_at ASC
|
||||
LIMIT 10
|
||||
LIMIT 20
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
|
||||
# Collect results per user
|
||||
user_results: Dict[int, List[Dict[str, Any]]] = {}
|
||||
for row in rows:
|
||||
if _stop_event.is_set():
|
||||
break
|
||||
monitor_id = row.get('id')
|
||||
monitor_user_id = int(row.get('user_id') or 1)
|
||||
if monitor_id:
|
||||
logger.info(f"Running due monitor #{monitor_id} for user #{monitor_user_id}")
|
||||
try:
|
||||
run_single_monitor(monitor_id, user_id=monitor_user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Monitor #{monitor_id} execution failed: {e}")
|
||||
if not monitor_id:
|
||||
continue
|
||||
logger.info(f"Running due monitor #{monitor_id} for user #{monitor_user_id}")
|
||||
try:
|
||||
result = run_single_monitor(
|
||||
monitor_id,
|
||||
user_id=monitor_user_id,
|
||||
skip_notification=True,
|
||||
)
|
||||
user_results.setdefault(monitor_user_id, []).append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Monitor #{monitor_id} execution failed: {e}")
|
||||
|
||||
# Send one combined notification per user
|
||||
for uid, results in user_results.items():
|
||||
try:
|
||||
if len(results) == 1:
|
||||
meta = results[0].get('_meta', {})
|
||||
_send_monitor_notification(
|
||||
monitor_name=meta.get('monitor_name', '?'),
|
||||
result=results[0],
|
||||
notification_config=meta.get('notification_config', {}),
|
||||
positions=meta.get('positions', []),
|
||||
position_analyses=meta.get('position_analyses', []),
|
||||
language=meta.get('language', 'en-US'),
|
||||
custom_prompt=meta.get('custom_prompt', ''),
|
||||
user_id=uid,
|
||||
)
|
||||
else:
|
||||
_send_batch_notification(uid, results)
|
||||
except Exception as e:
|
||||
logger.error(f"Batch notification for user #{uid} failed: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Monitor loop error: {e}")
|
||||
|
||||
# Sleep for 30 seconds before next check
|
||||
|
||||
_stop_event.wait(30)
|
||||
|
||||
|
||||
logger.info("Portfolio monitor background loop stopped")
|
||||
|
||||
|
||||
|
||||
@@ -715,14 +715,15 @@ class SignalNotifier:
|
||||
try:
|
||||
# Heuristic: if port is 465 and SMTP_USE_SSL is not explicitly set, assume SSL.
|
||||
use_ssl = bool(self.smtp_use_ssl) or int(self.smtp_port or 0) == 465
|
||||
smtp_timeout = max(self.timeout_sec, 20)
|
||||
if use_ssl:
|
||||
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, timeout=self.timeout_sec) as server:
|
||||
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, timeout=smtp_timeout) as server:
|
||||
server.ehlo()
|
||||
if self.smtp_user and self.smtp_password:
|
||||
server.login(self.smtp_user, self.smtp_password)
|
||||
server.send_message(msg)
|
||||
else:
|
||||
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=self.timeout_sec) as server:
|
||||
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=smtp_timeout) as server:
|
||||
server.ehlo()
|
||||
if self.smtp_use_tls:
|
||||
server.starttls()
|
||||
|
||||
@@ -4,6 +4,7 @@ User Service - Multi-user management
|
||||
Handles user CRUD operations, password hashing, and role management.
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
import os
|
||||
from typing import Optional, Dict, Any, List
|
||||
@@ -12,6 +13,9 @@ from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# IANA timezone id subset check (e.g. Asia/Shanghai, America/New_York)
|
||||
_TIMEZONE_ID_RE = re.compile(r'^[A-Za-z0-9_/+\-.]+$')
|
||||
|
||||
# Try to import bcrypt for secure password hashing
|
||||
try:
|
||||
import bcrypt
|
||||
@@ -74,8 +78,8 @@ class UserService:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, email, nickname, avatar, status, role,
|
||||
credits, vip_expires_at, last_login_at, created_at, updated_at
|
||||
SELECT id, username, email, nickname, avatar, status, role,
|
||||
credits, vip_expires_at, timezone, last_login_at, created_at, updated_at
|
||||
FROM qd_users WHERE id = ?
|
||||
""",
|
||||
(user_id,)
|
||||
@@ -94,8 +98,8 @@ class UserService:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, password_hash, email, nickname, avatar,
|
||||
status, role, last_login_at, created_at, updated_at
|
||||
SELECT id, username, password_hash, email, nickname, avatar,
|
||||
status, role, timezone, last_login_at, created_at, updated_at
|
||||
FROM qd_users WHERE username = ?
|
||||
""",
|
||||
(username,)
|
||||
@@ -116,8 +120,8 @@ class UserService:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, password_hash, email, nickname, avatar,
|
||||
status, role, last_login_at, created_at, updated_at
|
||||
SELECT id, username, password_hash, email, nickname, avatar,
|
||||
status, role, timezone, last_login_at, created_at, updated_at
|
||||
FROM qd_users WHERE LOWER(email) = LOWER(?)
|
||||
""",
|
||||
(email,)
|
||||
@@ -340,7 +344,7 @@ class UserService:
|
||||
user_id: User ID
|
||||
data: Fields to update (email, nickname, avatar, role, status)
|
||||
"""
|
||||
allowed_fields = ['email', 'nickname', 'avatar', 'role', 'status']
|
||||
allowed_fields = ['email', 'nickname', 'avatar', 'role', 'status', 'timezone']
|
||||
updates = []
|
||||
values = []
|
||||
|
||||
@@ -349,6 +353,13 @@ class UserService:
|
||||
value = data[field]
|
||||
if field == 'role' and value not in self.ROLES:
|
||||
continue
|
||||
if field == 'timezone':
|
||||
s = '' if value is None else str(value).strip()
|
||||
if s and (len(s) > 64 or not _TIMEZONE_ID_RE.match(s)):
|
||||
continue
|
||||
updates.append('timezone = ?')
|
||||
values.append(s)
|
||||
continue
|
||||
updates.append(f"{field} = ?")
|
||||
values.append(value)
|
||||
|
||||
@@ -461,7 +472,7 @@ class UserService:
|
||||
# Get users
|
||||
query_sql = f"""
|
||||
SELECT id, username, email, nickname, avatar, status, role,
|
||||
credits, vip_expires_at, last_login_at, created_at, updated_at
|
||||
credits, vip_expires_at, timezone, last_login_at, created_at, updated_at
|
||||
FROM qd_users
|
||||
{where_clause}
|
||||
ORDER BY id DESC
|
||||
|
||||
@@ -222,6 +222,16 @@ class PostgresConnection:
|
||||
logger.warning(f"Failed to return connection to pool: {e}")
|
||||
|
||||
|
||||
def _ensure_session_utc(conn) -> None:
|
||||
"""每条连接检出后固定为 UTC,与 API 序列化约定一致。"""
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SET TIME ZONE 'UTC'")
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not SET TIME ZONE UTC on connection: {e}")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_pg_connection():
|
||||
"""
|
||||
@@ -231,6 +241,7 @@ def get_pg_connection():
|
||||
conn = None
|
||||
try:
|
||||
conn = pool.getconn()
|
||||
_ensure_session_utc(conn)
|
||||
pg_conn = PostgresConnection(conn)
|
||||
yield pg_conn
|
||||
except Exception as e:
|
||||
@@ -258,6 +269,7 @@ def get_pg_connection_sync() -> PostgresConnection:
|
||||
"""
|
||||
pool = _get_connection_pool()
|
||||
conn = pool.getconn()
|
||||
_ensure_session_utc(conn)
|
||||
return PostgresConnection(conn)
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS qd_users (
|
||||
email_verified BOOLEAN DEFAULT FALSE, -- 邮箱是否已验证
|
||||
referred_by INTEGER, -- 邀请人ID
|
||||
notification_settings TEXT DEFAULT '', -- 用户通知配置 JSON (telegram_chat_id, default_channels等)
|
||||
timezone VARCHAR(64) DEFAULT '', -- IANA 时区标识,空表示跟随客户端/浏览器
|
||||
token_version INTEGER DEFAULT 1, -- Token版本号,用于单一客户端登录控制
|
||||
last_login_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
@@ -656,6 +657,21 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 20b. Migration: user profile timezone (IANA)
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_users' AND column_name = 'timezone'
|
||||
) THEN
|
||||
ALTER TABLE qd_users ADD COLUMN timezone VARCHAR(64) DEFAULT '';
|
||||
RAISE NOTICE 'Added timezone column to qd_users table';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 21. Indicator Community Tables
|
||||
-- =============================================================================
|
||||
|
||||
@@ -4,6 +4,45 @@ This document records version updates, new features, bug fixes, and database mig
|
||||
|
||||
---
|
||||
|
||||
## V2.2.3 (2026-03-24)
|
||||
|
||||
### 🚀 New Features
|
||||
|
||||
- **User profile IANA timezone (`qd_users.timezone`)**: 个人资料可保存时区(IANA 标识,如 `Asia/Shanghai`);为空表示跟随浏览器。登录态 `/api/auth/info`、资料接口与前端 AI 分析页等时间展示会按该时区调用 `toLocaleString(..., { timeZone })`(非法或空则回退本机时区)。
|
||||
|
||||
### 📋 Database Migration
|
||||
|
||||
**在已有 PostgreSQL 库上执行(新库若已通过更新后的 `migrations/init.sql` 初始化则无需再执行):**
|
||||
|
||||
```sql
|
||||
-- ============================================================
|
||||
-- QuantDinger V2.2.3 — qd_users.timezone(用户资料时区)
|
||||
-- ============================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'qd_users'
|
||||
AND column_name = 'timezone'
|
||||
) THEN
|
||||
ALTER TABLE qd_users ADD COLUMN timezone VARCHAR(64) DEFAULT '';
|
||||
RAISE NOTICE 'Added timezone column to qd_users table';
|
||||
END IF;
|
||||
END $$;
|
||||
```
|
||||
|
||||
**仅当列不存在时的一行式写法(自行确认无列后再执行):**
|
||||
|
||||
```sql
|
||||
ALTER TABLE qd_users ADD COLUMN IF NOT EXISTS timezone VARCHAR(64) DEFAULT '';
|
||||
```
|
||||
|
||||
> 说明:`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 需 **PostgreSQL 11+**(本仓库 Docker 默认 `postgres:16` 可用);与上面 `DO` 块二选一即可。
|
||||
|
||||
---
|
||||
|
||||
## V2.2.2 (2026-02-28)
|
||||
|
||||
### 🚀 New Features
|
||||
|
||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+42
-42
@@ -71,39 +71,39 @@ body.dark .ant-select-dropdown .ant-select-dropdown-menu-item-selected,body.real
|
||||
.fast-analysis-report.theme-dark .result-container .indicators-section .indicators-methodology .anticon[data-v-03feb1fa]{color:var(--primary-color,#1890ff)}
|
||||
.fast-analysis-report.theme-dark .result-container .indicators-section .indicators-pro-wrap .indicators-pro-title .anticon[data-v-03feb1fa]{color:var(--primary-color,#1890ff)}
|
||||
.fast-analysis-report.theme-dark .result-container .feedback-section .ant-btn[data-v-03feb1fa]:hover{border-color:color-mix(in srgb,var(--primary-color,#1890ff) 30%,transparent);color:var(--primary-color,#1890ff)}
|
||||
.left-panel .calendar-box .box-header .box-title .anticon[data-v-06c771c0]{color:var(--primary-color,#1890ff)}
|
||||
.right-panel .analysis-toolbar .analyze-button[data-v-06c771c0]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .batch-bar .ant-btn[data-v-06c771c0]:not(.ant-btn-primary):hover{border-color:var(--primary-color,#1890ff);color:var(--primary-color,#1890ff)}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card.active[data-v-06c771c0]{background:color-mix(in srgb,var(--primary-color,#1890ff) 8%,transparent);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 28%,transparent)}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card-hover-actions .wl-hover-btn[data-v-06c771c0]:hover{color:var(--primary-color,#1890ff);background:color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent)}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card.active .wl-card-hover-actions[data-v-06c771c0]{background:linear-gradient(90deg,transparent 0,color-mix(in srgb,var(--primary-color,#1890ff) 6%,transparent) 30%)}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hero-badge[data-v-06c771c0]{background:color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent);color:var(--primary-color,#1890ff)}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hstat[data-v-06c771c0]:hover{border-color:color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent);-webkit-box-shadow:0 4px 16px color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent);box-shadow:0 4px 16px color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent)}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hstat-icon[data-v-06c771c0]{background:color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent);color:var(--primary-color,#1890ff)}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hero-cta .ant-btn-primary[data-v-06c771c0]{-webkit-box-shadow:0 4px 14px color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent);box-shadow:0 4px 14px color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent)}
|
||||
.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip.active[data-v-06c771c0],.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip[data-v-06c771c0]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
|
||||
.ai-analysis-container.theme-dark .right-panel .analysis-toolbar .history-button[data-v-06c771c0]:hover{border-color:color-mix(in srgb,var(--primary-color,#1890ff) 45%,transparent);color:var(--primary-color,#1890ff)}
|
||||
.ai-analysis-container.theme-dark .hero-cta .ant-btn[data-v-06c771c0]:not(.ant-btn-primary):hover{border-color:#1890ff;color:#1890ff}
|
||||
.ai-analysis-container.theme-dark .panel-header-icon[data-v-06c771c0]:hover{color:var(--primary-color,#1890ff);background:color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent)}
|
||||
.ai-analysis-container.theme-dark .batch-bar .ant-btn[data-v-06c771c0]:not(.ant-btn-primary):hover{border-color:var(--primary-color,#1890ff)!important;color:var(--primary-color,#1890ff)!important}
|
||||
.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip.active[data-v-06c771c0],.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip[data-v-06c771c0]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
|
||||
.ai-analysis-container.theme-dark[data-v-06c771c0] .ant-tabs-tab-active{color:#1890ff!important}
|
||||
.ai-analysis-container.theme-dark[data-v-06c771c0] .ant-tabs-ink-bar{background-color:#1890ff}
|
||||
.ai-analysis-container.theme-dark[data-v-06c771c0] .ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
|
||||
.ai-analysis-container.theme-dark[data-v-06c771c0] .ant-alert{background:rgba(24,144,255,.06)}
|
||||
.hero-bg-circle.c1[data-v-06c771c0]{background:radial-gradient(circle,rgba(24,144,255,.1) 0,transparent 70%)}
|
||||
.hero-bg-grid[data-v-06c771c0]{background-image:linear-gradient(rgba(24,144,255,.03) 1px,transparent 0),linear-gradient(90deg,rgba(24,144,255,.03) 1px,transparent 0)}
|
||||
.hero-badge[data-v-06c771c0]{color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08);border:1px solid rgba(24,144,255,.2)}
|
||||
.hstat[data-v-06c771c0]:hover{border-color:var(--primary-color,#1890ff);-webkit-box-shadow:0 4px 16px rgba(24,144,255,.1);box-shadow:0 4px 16px rgba(24,144,255,.1)}
|
||||
.hstat-icon[data-v-06c771c0]{background:linear-gradient(135deg,rgba(24,144,255,.1),rgba(114,46,209,.08));color:var(--primary-color,#1890ff)}
|
||||
.hero-cta .ant-btn-primary[data-v-06c771c0]{-webkit-box-shadow:0 4px 14px rgba(24,144,255,.3);box-shadow:0 4px 14px rgba(24,144,255,.3)}
|
||||
.panel-header-icon[data-v-06c771c0]:hover{color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
|
||||
.batch-bar .ant-btn-primary[data-v-06c771c0]{-webkit-box-shadow:0 1px 2px color-mix(in srgb,var(--primary-color,#1890ff) 20%,transparent);box-shadow:0 1px 2px color-mix(in srgb,var(--primary-color,#1890ff) 20%,transparent)}
|
||||
.wl-card.active[data-v-06c771c0]{background:linear-gradient(135deg,color-mix(in srgb,var(--primary-color,#1890ff) 6%,#fff) 0,color-mix(in srgb,var(--primary-color,#1890ff) 4%,#fff) 100%);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 28%,transparent);-webkit-box-shadow:0 1px 4px color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent);box-shadow:0 1px 4px color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent)}
|
||||
.wl-card.active .wl-card-hover-actions[data-v-06c771c0]{background:-webkit-gradient(linear,left top,right top,from(transparent),color-stop(30%,#e6f7ff));background:linear-gradient(90deg,transparent,#e6f7ff 30%)}
|
||||
.wl-hover-btn[data-v-06c771c0]:hover{color:var(--primary-color,#1890ff);background:#e6f7ff}
|
||||
.task-item-actions .ant-btn.ant-btn-primary[data-v-06c771c0]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
|
||||
.task-item-actions .ant-btn.ant-btn-primary[data-v-06c771c0]:hover{-webkit-box-shadow:0 2px 6px color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent);box-shadow:0 2px 6px color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent)}
|
||||
.left-panel .calendar-box .box-header .box-title .anticon[data-v-22f26594]{color:var(--primary-color,#1890ff)}
|
||||
.right-panel .analysis-toolbar .analyze-button[data-v-22f26594]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .batch-bar .ant-btn[data-v-22f26594]:not(.ant-btn-primary):hover{border-color:var(--primary-color,#1890ff);color:var(--primary-color,#1890ff)}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card.active[data-v-22f26594]{background:color-mix(in srgb,var(--primary-color,#1890ff) 8%,transparent);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 28%,transparent)}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card-hover-actions .wl-hover-btn[data-v-22f26594]:hover{color:var(--primary-color,#1890ff);background:color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent)}
|
||||
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card.active .wl-card-hover-actions[data-v-22f26594]{background:linear-gradient(90deg,transparent 0,color-mix(in srgb,var(--primary-color,#1890ff) 6%,transparent) 30%)}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hero-badge[data-v-22f26594]{background:color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent);color:var(--primary-color,#1890ff)}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hstat[data-v-22f26594]:hover{border-color:color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent);-webkit-box-shadow:0 4px 16px color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent);box-shadow:0 4px 16px color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent)}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hstat-icon[data-v-22f26594]{background:color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent);color:var(--primary-color,#1890ff)}
|
||||
.ai-analysis-container.theme-dark .placeholder-hero .hero-cta .ant-btn-primary[data-v-22f26594]{-webkit-box-shadow:0 4px 14px color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent);box-shadow:0 4px 14px color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent)}
|
||||
.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip.active[data-v-22f26594],.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip[data-v-22f26594]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
|
||||
.ai-analysis-container.theme-dark .right-panel .analysis-toolbar .history-button[data-v-22f26594]:hover{border-color:color-mix(in srgb,var(--primary-color,#1890ff) 45%,transparent);color:var(--primary-color,#1890ff)}
|
||||
.ai-analysis-container.theme-dark .hero-cta .ant-btn[data-v-22f26594]:not(.ant-btn-primary):hover{border-color:#1890ff;color:#1890ff}
|
||||
.ai-analysis-container.theme-dark .panel-header-icon[data-v-22f26594]:hover{color:var(--primary-color,#1890ff);background:color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent)}
|
||||
.ai-analysis-container.theme-dark .batch-bar .ant-btn[data-v-22f26594]:not(.ant-btn-primary):hover{border-color:var(--primary-color,#1890ff)!important;color:var(--primary-color,#1890ff)!important}
|
||||
.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip.active[data-v-22f26594],.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip[data-v-22f26594]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
|
||||
.ai-analysis-container.theme-dark[data-v-22f26594] .ant-tabs-tab-active{color:#1890ff!important}
|
||||
.ai-analysis-container.theme-dark[data-v-22f26594] .ant-tabs-ink-bar{background-color:#1890ff}
|
||||
.ai-analysis-container.theme-dark[data-v-22f26594] .ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
|
||||
.ai-analysis-container.theme-dark[data-v-22f26594] .ant-alert{background:rgba(24,144,255,.06)}
|
||||
.hero-bg-circle.c1[data-v-22f26594]{background:radial-gradient(circle,rgba(24,144,255,.1) 0,transparent 70%)}
|
||||
.hero-bg-grid[data-v-22f26594]{background-image:linear-gradient(rgba(24,144,255,.03) 1px,transparent 0),linear-gradient(90deg,rgba(24,144,255,.03) 1px,transparent 0)}
|
||||
.hero-badge[data-v-22f26594]{color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08);border:1px solid rgba(24,144,255,.2)}
|
||||
.hstat[data-v-22f26594]:hover{border-color:var(--primary-color,#1890ff);-webkit-box-shadow:0 4px 16px rgba(24,144,255,.1);box-shadow:0 4px 16px rgba(24,144,255,.1)}
|
||||
.hstat-icon[data-v-22f26594]{background:linear-gradient(135deg,rgba(24,144,255,.1),rgba(114,46,209,.08));color:var(--primary-color,#1890ff)}
|
||||
.hero-cta .ant-btn-primary[data-v-22f26594]{-webkit-box-shadow:0 4px 14px rgba(24,144,255,.3);box-shadow:0 4px 14px rgba(24,144,255,.3)}
|
||||
.panel-header-icon[data-v-22f26594]:hover{color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
|
||||
.batch-bar .ant-btn-primary[data-v-22f26594]{-webkit-box-shadow:0 1px 2px color-mix(in srgb,var(--primary-color,#1890ff) 20%,transparent);box-shadow:0 1px 2px color-mix(in srgb,var(--primary-color,#1890ff) 20%,transparent)}
|
||||
.wl-card.active[data-v-22f26594]{background:linear-gradient(135deg,color-mix(in srgb,var(--primary-color,#1890ff) 6%,#fff) 0,color-mix(in srgb,var(--primary-color,#1890ff) 4%,#fff) 100%);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 28%,transparent);-webkit-box-shadow:0 1px 4px color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent);box-shadow:0 1px 4px color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent)}
|
||||
.wl-card.active .wl-card-hover-actions[data-v-22f26594]{background:-webkit-gradient(linear,left top,right top,from(transparent),color-stop(30%,#e6f7ff));background:linear-gradient(90deg,transparent,#e6f7ff 30%)}
|
||||
.wl-hover-btn[data-v-22f26594]:hover{color:var(--primary-color,#1890ff);background:#e6f7ff}
|
||||
.task-item-actions .ant-btn.ant-btn-primary[data-v-22f26594]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
|
||||
.task-item-actions .ant-btn.ant-btn-primary[data-v-22f26594]:hover{-webkit-box-shadow:0 2px 6px color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent);box-shadow:0 2px 6px color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent)}
|
||||
.qd-dark-modal .ant-modal-footer .ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
|
||||
.qd-dark-modal .ant-tabs-tab-active{color:#1890ff!important}
|
||||
.qd-dark-modal .ant-tag-blue{background:rgba(24,144,255,.1);border-color:rgba(24,144,255,.3);color:#1890ff}
|
||||
@@ -237,15 +237,15 @@ body.dark .trading-records ::v-deep .ant-pagination .ant-pagination-next:hover .
|
||||
.user-manage-page .address-text[data-v-0b8bb4d8],.user-manage-page .hash-text[data-v-0b8bb4d8]{color:#1890ff}
|
||||
.user-manage-page.theme-dark .manage-tabs[data-v-0b8bb4d8] .ant-tabs-tab-active{color:#1890ff}
|
||||
.user-manage-page .current-credits-info .value[data-v-0b8bb4d8],.user-manage-page .current-vip-info .value[data-v-0b8bb4d8]{color:#1890ff}
|
||||
.profile-page .page-header .page-title .anticon[data-v-4224bd2a]{color:#1890ff}
|
||||
.profile-page .profile-card .profile-info .info-item .anticon[data-v-4224bd2a]{color:#1890ff}
|
||||
.profile-page.theme-dark .edit-card[data-v-4224bd2a] .ant-tabs-tab-active{color:#1890ff}
|
||||
.profile-page.theme-dark .edit-card[data-v-4224bd2a] .ant-input-password:focus,.profile-page.theme-dark .edit-card[data-v-4224bd2a] .ant-input-password:hover,.profile-page.theme-dark .edit-card[data-v-4224bd2a] .ant-input:focus,.profile-page.theme-dark .edit-card[data-v-4224bd2a] .ant-input:hover{border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-4224bd2a] .ant-table-wrapper .ant-tag-blue{background:rgba(24,144,255,.25);border-color:#1890ff;color:#69c0ff}
|
||||
.profile-page.theme-dark .notification-settings-form[data-v-4224bd2a] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .notification-settings-form[data-v-4224bd2a] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .password-form[data-v-4224bd2a] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .password-form[data-v-4224bd2a] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .profile-form[data-v-4224bd2a] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .profile-form[data-v-4224bd2a] .ant-radio-checked .ant-radio-inner{background-color:#1890ff;border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-4224bd2a] .ant-pagination .ant-pagination-item:hover{border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-4224bd2a] .ant-pagination .ant-pagination-item-active{background:#1890ff;border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-4224bd2a] .ant-btn.ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
|
||||
.profile-page .page-header .page-title .anticon[data-v-082fd138]{color:#1890ff}
|
||||
.profile-page .profile-card .profile-info .info-item .anticon[data-v-082fd138]{color:#1890ff}
|
||||
.profile-page.theme-dark .edit-card[data-v-082fd138] .ant-tabs-tab-active{color:#1890ff}
|
||||
.profile-page.theme-dark .edit-card[data-v-082fd138] .ant-input-password:focus,.profile-page.theme-dark .edit-card[data-v-082fd138] .ant-input-password:hover,.profile-page.theme-dark .edit-card[data-v-082fd138] .ant-input:focus,.profile-page.theme-dark .edit-card[data-v-082fd138] .ant-input:hover{border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-082fd138] .ant-table-wrapper .ant-tag-blue{background:rgba(24,144,255,.25);border-color:#1890ff;color:#69c0ff}
|
||||
.profile-page.theme-dark .notification-settings-form[data-v-082fd138] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .notification-settings-form[data-v-082fd138] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .password-form[data-v-082fd138] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .password-form[data-v-082fd138] .ant-radio-checked .ant-radio-inner,.profile-page.theme-dark .profile-form[data-v-082fd138] .ant-checkbox-checked .ant-checkbox-inner,.profile-page.theme-dark .profile-form[data-v-082fd138] .ant-radio-checked .ant-radio-inner{background-color:#1890ff;border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-082fd138] .ant-pagination .ant-pagination-item:hover{border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-082fd138] .ant-pagination .ant-pagination-item-active{background:#1890ff;border-color:#1890ff}
|
||||
.profile-page.theme-dark[data-v-082fd138] .ant-btn.ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
|
||||
.billing-page .plan-card.highlight[data-v-714c882a]{border:1px solid rgba(24,144,255,.35)}
|
||||
.usdt-checkout .checkout-body .info-section .addr-box .copy-btn:hover,.usdt-checkout .checkout-body .info-section .amt-box .copy-btn:hover{background:rgba(24,144,255,.08);color:#1890ff}
|
||||
body.dark .usdt-checkout .checkout-body .info-section .addr-box .copy-btn:hover,body.dark .usdt-checkout .checkout-body .info-section .amt-box .copy-btn:hover,body.realdark .usdt-checkout .checkout-body .info-section .addr-box .copy-btn:hover,body.realdark .usdt-checkout .checkout-body .info-section .amt-box .copy-btn:hover{background:rgba(24,144,255,.15);color:#40a9ff}
|
||||
Vendored
+1
-1
@@ -421,4 +421,4 @@
|
||||
.brand-text {
|
||||
font-size: 20px;
|
||||
}
|
||||
}</style><script defer="defer" src="/js/chunk-vendors.b465f607.js" type="module"></script><script defer="defer" src="/js/app.740bc472.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.9dca4aef.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.0b89f5de.js" nomodule></script><script defer="defer" src="/js/app-legacy.6beb7055.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
|
||||
}</style><script defer="defer" src="/js/chunk-vendors.93065237.js" type="module"></script><script defer="defer" src="/js/app.0a924cd8.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.9dca4aef.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.2dba589a.js" nomodule></script><script defer="defer" src="/js/app-legacy.182d5520.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
|
||||
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user