@@ -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
|
||||
-- =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user