""" Portfolio Monitor Service. Runs scheduled AI analysis on manual positions and sends notifications. """ from __future__ import annotations import hashlib import json import threading import time import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any, Dict, List, Optional from app.services.billing_service import get_billing_service from app.services.fast_analysis import get_fast_analysis_service from app.services.kline import KlineService from app.services.signal_notifier import SignalNotifier from app.utils.db import get_db_connection from app.utils.logger import get_logger logger = get_logger(__name__) DEFAULT_USER_ID = 1 _monitor_thread: Optional[threading.Thread] = None _stop_event = threading.Event() # Multilingual message templates ALERT_MESSAGES = { "zh-CN": { "price_above": "๐Ÿ”” ไปทๆ ผ็ช็ ด้ข„่ญฆ: {symbol} ๅฝ“ๅ‰ไปทๆ ผ ${current_price:.4f} ๅทฒ็ช็ ด ${threshold:.4f}", "price_below": "๐Ÿ”” ไปทๆ ผ่ทŒ็ ด้ข„่ญฆ: {symbol} ๅฝ“ๅ‰ไปทๆ ผ ${current_price:.4f} ๅทฒ่ทŒ็ ด ${threshold:.4f}", "pnl_above": "๐ŸŽ‰ ็›ˆๅˆฉ้ข„่ญฆ: {symbol} ๅฝ“ๅ‰็›ˆไบ {pnl_percent:.1f}% ๅทฒ่พพๅˆฐ {threshold:.1f}% ็›ฎๆ ‡", "pnl_below": "โš ๏ธ ไบๆŸ้ข„่ญฆ: {symbol} ๅฝ“ๅ‰็›ˆไบ {pnl_percent:.1f}% ๅทฒ่งฆๅŠ {threshold:.1f}% ๆญขๆŸ็บฟ", "alert_title": "ไปทๆ ผ/็›ˆไบ้ข„่ญฆ", }, "en-US": { "price_above": "๐Ÿ”” Price Alert: {symbol} current price ${current_price:.4f} has exceeded ${threshold:.4f}", "price_below": "๐Ÿ”” Price Alert: {symbol} current price ${current_price:.4f} has dropped below ${threshold:.4f}", "pnl_above": "๐ŸŽ‰ Profit Alert: {symbol} P&L {pnl_percent:.1f}% has reached {threshold:.1f}% target", "pnl_below": "โš ๏ธ Loss Alert: {symbol} P&L {pnl_percent:.1f}% has hit {threshold:.1f}% stop-loss", "alert_title": "Price/P&L Alert", }, } def _get_alert_message(alert_type: str, language: str = "en-US", **kwargs) -> str: """Get localized alert message.""" lang = "zh-CN" if language and language.startswith("zh") else "en-US" templates = ALERT_MESSAGES.get(lang, ALERT_MESSAGES["en-US"]) template = templates.get(alert_type, "") if template: return template.format(**kwargs) return "" def _get_alert_title(language: str = "en-US") -> str: """Get localized alert title.""" lang = "zh-CN" if language and language.startswith("zh") else "en-US" return ALERT_MESSAGES.get(lang, ALERT_MESSAGES["en-US"]).get("alert_title", "Alert") def _now_ts() -> int: return int(time.time()) def _resolve_notification_delivery(user_id: int, notification_config: Optional[Dict[str, Any]]) -> Dict[str, Any]: """ Merge notification_settings saved in the personal center to targets and normalize channels. When creating monitoring on the front end, only channels (email/telegram/webhook) are usually passed, and targets are not passed; if not merged, all outgoing channels will be skipped and no delivery will be made. If the current channels cannot be delivered (no email/Chat ID, etc.), add a browser to ensure on-site notification. """ 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: default = {} if isinstance(value, dict): return value if isinstance(value, list): return value if isinstance(value, str) and value.strip(): try: return json.loads(value) except Exception: return default return default def _get_positions_for_monitor(position_ids: List[int] = None, user_id: int = None) -> List[Dict[str, Any]]: """Get positions, optionally filtered by IDs and user_id.""" try: kline_service = KlineService() effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID with get_db_connection() as db: cur = db.cursor() if position_ids: placeholders = ",".join(["?" for _ in position_ids]) cur.execute( f""" SELECT id, market, symbol, name, side, quantity, entry_price, group_name FROM qd_manual_positions WHERE user_id = ? AND id IN ({placeholders}) """, [effective_user_id] + list(position_ids), ) else: cur.execute( """ SELECT id, market, symbol, name, side, quantity, entry_price, group_name FROM qd_manual_positions WHERE user_id = ? """, (effective_user_id,), ) rows = cur.fetchall() or [] cur.close() positions = [] for row in rows: market = row.get("market") symbol = row.get("symbol") entry_price = float(row.get("entry_price") or 0) quantity = float(row.get("quantity") or 0) side = row.get("side") or "long" group_name = row.get("group_name") # Get current price (use realtime price API) current_price = 0 try: price_data = kline_service.get_realtime_price(market, symbol) current_price = float(price_data.get("price") or 0) except Exception: pass # Calculate PnL if side == "long": pnl = (current_price - entry_price) * quantity else: pnl = (entry_price - current_price) * quantity pnl_percent = round(pnl / (entry_price * quantity) * 100, 2) if entry_price * quantity > 0 else 0 positions.append( { "id": row.get("id"), "market": market, "symbol": symbol, "name": row.get("name") or symbol, "side": side, "quantity": quantity, "entry_price": entry_price, "current_price": current_price, "pnl": round(pnl, 2), "pnl_percent": pnl_percent, "group_name": group_name, } ) return positions except Exception as e: logger.error(f"_get_positions_for_monitor failed: {e}") return [] MAX_PARALLEL_ANALYSIS = 5 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") name = pos.get("name") or symbol group_name = pos.get("group_name") if not market or not symbol: return {"market": market, "symbol": symbol, "name": name, "error": "missing market/symbol"} try: 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", user_id=user_id, ) detailed = analysis_result.get("detailed_analysis", {}) trading_plan = analysis_result.get("trading_plan", {}) scores = analysis_result.get("scores", {}) risks = analysis_result.get("risks", []) risk_report = "\n".join([f"โ€ข {r}" for r in risks]) if risks else "" result = { "market": market, "symbol": symbol, "name": name, "group_name": group_name, "entry_price": pos.get("entry_price"), "current_price": pos.get("current_price") or analysis_result.get("market_data", {}).get("current_price"), "pnl": pos.get("pnl"), "pnl_percent": pos.get("pnl_percent"), "quantity": pos.get("quantity"), "side": pos.get("side"), "final_decision": analysis_result.get("decision", "HOLD"), "confidence": analysis_result.get("confidence", 50), "reasoning": analysis_result.get("summary", ""), "trader_decision": analysis_result.get("decision", "HOLD"), "trader_reasoning": analysis_result.get("summary", ""), "overview_report": detailed.get("technical", ""), "fundamental_report": detailed.get("fundamental", ""), "sentiment_report": detailed.get("sentiment", ""), "risk_report": risk_report, "suggested_entry": trading_plan.get("entry_price"), "suggested_stop_loss": trading_plan.get("stop_loss"), "suggested_take_profit": trading_plan.get("take_profit"), "technical_score": scores.get("technical", 50), "fundamental_score": scores.get("fundamental", 50), "sentiment_score": scores.get("sentiment", 50), "key_reasons": analysis_result.get("reasons", []), "error": analysis_result.get("error"), } logger.info(f"Fast analysis completed for {market}:{symbol}: {analysis_result.get('decision', 'N/A')}") return result except Exception as e: logger.error(f"Failed to analyze {market}:{symbol}: {e}") return {"market": market, "symbol": symbol, "name": name, "error": str(e)} 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", "") # โ”€โ”€ 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, user_id): idx for idx, pos in enumerate(unique_positions) } for future in as_completed(future_to_idx): idx = future_to_idx[future] try: unique_analyses[idx] = future.result() except Exception as e: 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), } # โ”€โ”€ 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 ) 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, "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(), } except Exception as e: logger.error(f"_run_ai_analysis failed: {e}") logger.error(traceback.format_exc()) return {"success": False, "error": str(e), "timestamp": _now_ts()} def _build_comprehensive_report( positions: List[Dict[str, Any]], position_analyses: List[Dict[str, Any]], language: str, custom_prompt: str = "" ) -> str: """Build a comprehensive text report (backward compatible).""" # Use HTML report as the main format return _build_html_report(positions, position_analyses, language, custom_prompt) def _build_html_report( positions: List[Dict[str, Any]], position_analyses: List[Dict[str, Any]], language: str, custom_prompt: str = "" ) -> str: """Build a beautiful HTML report with collapsible sections.""" # Calculate portfolio 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 total_market_value = sum(float(p.get("current_price", 0)) * float(p.get("quantity", 0)) for p in positions) # Count recommendations 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") # Text translations texts = { "title": "ๆŠ•่ต„็ป„ๅˆAIๅˆ†ๆžๆŠฅๅ‘Š" if is_zh else "Portfolio AI Analysis Report", "subtitle": "็”ฑ QuantDinger AI ๅฟซ้€Ÿๅˆ†ๆžๅผ•ๆ“Ž็”Ÿๆˆ" if is_zh else "Generated by QuantDinger Fast AI Analysis Engine", "overview": "็ป„ๅˆๆฆ‚่งˆ" if is_zh else "Portfolio Overview", "positions": "ๆŒไป“ๆ•ฐ้‡" if is_zh else "Positions", "total_value": "ๆ€ปๅธ‚ๅ€ผ" if is_zh else "Total Value", "total_cost": "ๆ€ปๆˆๆœฌ" if is_zh else "Total Cost", "total_pnl": "ๆ€ป็›ˆไบ" if is_zh else "Total P&L", "ai_recommendations": "๐Ÿค– AIๆ™บ่ƒฝๅˆ†ๆžๅปบ่ฎฎ" if is_zh else "๐Ÿค– AI Recommendations", "buy": "ไนฐๅ…ฅ" if is_zh else "Buy", "sell": "ๅ–ๅ‡บ" if is_zh else "Sell", "hold": "ๆŒๆœ‰" if is_zh else "Hold", "position_analysis": "๐Ÿ“ˆ ๅ„ๆŒไป“่ฏฆ็ป†ๅˆ†ๆž" if is_zh else "๐Ÿ“ˆ Position Analysis", "current_price": "ๅฝ“ๅ‰ไปทๆ ผ" if is_zh else "Current", "entry_price": "ไนฐๅ…ฅไปท" if is_zh else "Entry", "pnl": "็›ˆไบ" if is_zh else "P&L", "quantity": "ๆ•ฐ้‡" if is_zh else "Qty", "side": "ๆ–นๅ‘" if is_zh else "Side", "long": "ๅšๅคš" if is_zh else "Long", "short": "ๅš็ฉบ" if is_zh else "Short", "ai_decision": "AIๅ†ณ็ญ–" if is_zh else "AI Decision", "confidence": "็ฝฎไฟกๅบฆ" if is_zh else "Confidence", "reasoning": "ๅˆ†ๆžๆ‘˜่ฆ" if is_zh else "Summary", "trader_report": "๐Ÿ“‹ ไบคๆ˜“ๅ‘˜่ฏฆ็ป†่ฏ„ไผฐ" if is_zh else "๐Ÿ“‹ Trader Analysis", "risk_report": "โš ๏ธ ้ฃŽ้™ฉ่ฏ„ไผฐ" if is_zh else "โš ๏ธ Risk Assessment", "overview_report": "๐Ÿ“Š ๅธ‚ๅœบๆฆ‚่งˆ" if is_zh else "๐Ÿ“Š Market Overview", "click_expand": "็‚นๅ‡ปๅฑ•ๅผ€่ฏฆๆƒ…" if is_zh else "Click to expand", "user_focus": "๐Ÿ‘ค ็”จๆˆทๅ…ณๆณจ็‚น" if is_zh else "๐Ÿ‘ค User Focus", "generated_at": "ๆŠฅๅ‘Š็”Ÿๆˆๆ—ถ้—ด" if is_zh else "Generated at", "disclaimer": "ๆœฌๆŠฅๅ‘Šไป…ไพ›ๅ‚่€ƒ๏ผŒไธๆž„ๆˆๆŠ•่ต„ๅปบ่ฎฎใ€‚" if is_zh else "For reference only. Not investment advice.", "analysis_failed": "ๅˆ†ๆžๅคฑ่ดฅ" if is_zh else "Analysis failed", } # CSS Styles css = """ """ # Build HTML pnl_class = "positive" if total_pnl >= 0 else "negative" pnl_sign = "+" if total_pnl >= 0 else "" html = f""" {css}

{texts["title"]}

{texts["subtitle"]}

{texts["overview"]}

{texts["positions"]}
{len(positions)}
{texts["total_value"]}
${total_market_value:,.2f}
{texts["total_cost"]}
${total_cost:,.2f}
{texts["total_pnl"]}
{pnl_sign}${total_pnl:,.2f}({pnl_sign}{total_pnl_percent:.1f}%)

{texts["ai_recommendations"]}

๐ŸŸข
{buy_count}
{texts["buy"]}
๐Ÿ”ด
{sell_count}
{texts["sell"]}
๐ŸŸก
{hold_count}
{texts["hold"]}

{texts["position_analysis"]}

""" for pa in position_analyses: symbol = pa.get("symbol", "") name = pa.get("name", symbol) market = pa.get("market", "") group_name = pa.get("group_name", "") if pa.get("error"): html += f"""
โš ๏ธ
{name}
{market}/{symbol}
{texts["analysis_failed"]}: {pa.get("error")}
""" continue decision = pa.get("final_decision", "HOLD") decision_lower = decision.lower() decision_text = texts.get(decision_lower, decision) confidence = pa.get("confidence", 50) current_price = pa.get("current_price", 0) entry_price = pa.get("entry_price", 0) pnl = pa.get("pnl", 0) pnl_pct = pa.get("pnl_percent", 0) quantity = pa.get("quantity", 0) side = pa.get("side", "long") side_text = texts["long"] if side == "long" else texts["short"] pnl_class = "positive" if pnl >= 0 else "negative" pnl_sign = "+" if pnl >= 0 else "" reasoning = pa.get("reasoning", "") trader_reasoning = pa.get("trader_reasoning", "") overview_report = pa.get("overview_report", "") risk_report = pa.get("risk_report", "") html += f"""
{decision[0]}
{name}
{market}/{symbol}
{decision_text}
{texts["confidence"]}: {confidence}%
{texts["current_price"]}
${current_price:.4f}
{texts["entry_price"]}
${entry_price:.4f}
{texts["pnl"]}
{pnl_sign}${pnl:.2f} ({pnl_sign}{pnl_pct:.1f}%)
{texts["quantity"]} / {texts["side"]}
{quantity} / {side_text}
""" # Reasoning summary if reasoning: html += f"""
{texts["reasoning"]}
{reasoning[:500]}{"..." if len(reasoning) > 500 else ""}
""" # Generate unique ID for collapsible sections (use symbol hash to avoid special chars) section_id_base = hashlib.md5(f"{symbol}_{market}_{group_name}".encode()).hexdigest()[:8] # Collapsible: Trader Analysis if trader_reasoning: trader_id = f"trader_{section_id_base}" html += f'''
{trader_reasoning.replace(chr(10), "
")}
''' # Collapsible: Market Overview if overview_report: overview_id = f"overview_{section_id_base}" html += f'''
{overview_report.replace(chr(10), "
")}
''' # Collapsible: Risk Assessment if risk_report: risk_id = f"risk_{section_id_base}" html += f'''
{risk_report.replace(chr(10), "
")}
''' html += """
""" # User focus section if custom_prompt: html += f"""

{texts["user_focus"]}

{custom_prompt}
""" # Footer html += f"""
""" return html def _build_telegram_report( positions: List[Dict[str, Any]], position_analyses: List[Dict[str, Any]], language: str, custom_prompt: str = "" ) -> str: """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") # โ”€โ”€ Header / Overview โ”€โ”€ if is_zh: lines: List[str] = ["๐Ÿ“Š AI Asset Analysis Report", ""] overview = ["๐Ÿ“ˆ Overview"] if held: overview.append(f"โ€ขPositions: {len(held)}") overview.append(f"โ€ขTotal cost: ${total_cost:,.2f}") overview.append(f"โ€ขTotal profit and loss: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)") if watched: overview.append(f"โ€ข Observations: {len(watched)}") lines.extend(overview) lines.extend( [ "", "๐Ÿค– Summary of AI suggestions", f"๐ŸŸข Buy: {buy_count} | ๐Ÿ”ด Sell: {sell_count} | ๐ŸŸก Hold: {hold_count}", ] ) else: lines = ["๐Ÿ“Š AI Asset Analysis Report", ""] overview = ["๐Ÿ“ˆ Overview"] 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( [ "", "๐Ÿค– AI Recommendations", f"๐ŸŸข Buy: {buy_count} | ๐Ÿ”ด Sell: {sell_count} | ๐ŸŸก Hold: {hold_count}", ] ) # โ”€โ”€ 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": "Buy", "SELL": "Sell", "HOLD": "Hold"}.get(decision, "Hold") lines.append(f"\n{emoji} {pa.get('name', pa.get('symbol'))} ({pa.get('market')}/{pa.get('symbol')})") if show_pnl: pnl = pa.get("pnl", 0) pnl_pct = pa.get("pnl_percent", 0) ps = "+" if pnl >= 0 else "" lines.append( f" ๐Ÿ’ฐ ${pa.get('current_price', 0):,.2f} | " f"{'profit and loss' 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'}: {d_text} " f"({'confidence' 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"๐Ÿ“‹ {'position analysis' if is_zh else 'Holdings'}"]) for pa in held: _render_pa(pa, show_pnl=True) # โ”€โ”€ Watchlist section โ”€โ”€ if watched: lines.extend(["", f"๐Ÿ‘ {'Watchlist' if is_zh else 'Watchlist'}"]) 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โš ๏ธ {label}: {'Analysis failed' if is_zh else 'Analysis failed'}") if custom_prompt: lines.extend(["", f"๐Ÿ‘ค {'ๅ…ณๆณจ็‚น' if is_zh else 'Focus'}: {custom_prompt}"]) lines.extend( [ "", "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€", f"โฐ {time.strftime('%Y-%m-%d %H:%M')}", f"{'Generated by QuantDinger Multi-Agent System' if is_zh else 'Generated by QuantDinger Multi-Agent System'}", ] ) 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๐Ÿ“‹ {m_name}"] for pa in m_analyses: if pa.get("error"): label = pa.get("name") or pa.get("symbol") or "?" section_lines.append(f" โš ๏ธ {label}: {'Analysis failed' if is_zh else 'Failed'}") continue decision = pa.get("final_decision", "HOLD") emoji = {"BUY": "๐ŸŸข", "SELL": "๐Ÿ”ด", "HOLD": "๐ŸŸก"}.get(decision, "โšช") d_text = ({"BUY": "Buy", "SELL": "Sell", "HOLD": "Hold"}.get(decision, "Hold")) if is_zh else decision cur_price = pa.get("current_price", 0) section_lines.append( f"{emoji} {pa.get('name', pa.get('symbol'))} ({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'}: {d_text} " f"({'confidence' if is_zh else 'Conf'}: {pa.get('confidence', 50)}%)" ) reasoning = pa.get("reasoning", "") if reasoning: 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 = [ "๐Ÿ“Š Regular asset monitoring report", "", "๐Ÿ“ˆ General Overview", f"โ€ขMonitoring tasks: {len(monitor_results)}", f"โ€ข Number of targets: {len(all_analyses)}", ] if held: header.append( f"โ€ข Positions: {len(held)} | Total cost: ${total_cost:,.2f} | Profit and loss: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_pct:.1f}%)" ) if watched: header.append(f"โ€ข Observations: {len(watched)}") header.extend( [ "", "๐Ÿค– Summary of AI suggestions", f"๐ŸŸข Buy: {buy_c} | ๐Ÿ”ด Sell: {sell_c} | ๐ŸŸก Hold: {hold_c}", ] ) else: header = [ "๐Ÿ“Š Scheduled Portfolio Report", "", "๐Ÿ“ˆ Summary", 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( [ "", "๐Ÿค– AI Recommendations", f"๐ŸŸข Buy: {buy_c} | ๐Ÿ”ด Sell: {sell_c} | ๐ŸŸก Hold: {hold_c}", ] ) footer = [ "", "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€", f"โฐ {time.strftime('%Y-%m-%d %H:%M')}", f"{'Generated by QuantDinger Multi-Agent System' if is_zh else 'Generated by QuantDinger Multi-Agent System'}", ] 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 = '
' 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"๐Ÿ“Š Scheduled Asset Monitoring: {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( monitor_name: str, result: Dict[str, Any], notification_config: Dict[str, Any], positions: List[Dict[str, Any]] = None, position_analyses: List[Dict[str, Any]] = None, language: str = "en-US", custom_prompt: str = "", user_id: int = None, ) -> None: """Send notification with analysis result using appropriate format for each channel.""" 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") or ["browser"] targets = notification_config.get("targets", {}) title = ( f"๐Ÿ“Š Asset Monitor: {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"โš ๏ธ Asset monitoring failed: {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: 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()) """, ( effective_user_id, "PORTFOLIO", "ai_monitor", "browser", error_title, error_msg, json.dumps(result, 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=f"{error_title}\n\n{error_msg}", 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=error_title, body_text=error_msg) except Exception as e: logger.warning(f"Failed to send error notification to {channel}: {e}") return # Generate reports for different channels html_report = result.get("analysis", "") # This is already HTML from _build_html_report # Generate Telegram-specific report if we have the data telegram_report = "" if positions is not None and position_analyses is not None: telegram_report = _build_telegram_report(positions, position_analyses, language, custom_prompt) else: # Fallback: strip HTML tags for Telegram import re telegram_report = re.sub(r"<[^>]+>", "", html_report) if len(telegram_report) > 4000: telegram_report = telegram_report[:4000] + "..." # Send to each channel for channel in channels: try: ch = str(channel).strip().lower() if ch == "browser": # Browser notification uses HTML report 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()) """, ( effective_user_id, "PORTFOLIO", "ai_monitor", "browser", title, html_report, json.dumps(result, 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: # Use Telegram-optimized format 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: # Email uses full HTML report notifier._notify_email( to_email=to_email, subject=title, body_text=html_report, body_html=html_report, # Send as HTML email ) elif ch == "webhook": url = targets.get("webhook", "") if url: notifier._notify_webhook( url=url, payload={ "type": "portfolio_monitor", "monitor_name": monitor_name, "result": result, "html_report": html_report, }, ) except Exception as e: logger.warning(f"Failed to send notification to {channel}: {e}") except Exception as e: logger.error(f"_send_monitor_notification failed: {e}") 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') user_id: Optional user ID for user isolation skip_notification: If True, do NOT send a notification (caller will batch-send later) """ try: 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( """ SELECT id, user_id, name, position_ids, monitor_type, config, notification_config FROM qd_position_monitors WHERE id = ? AND user_id = ? """, (monitor_id, effective_user_id), ) row = cur.fetchone() cur.close() 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"), {}) if override_language: config["language"] = override_language interval_minutes = int(config.get("run_interval_minutes") or config.get("interval_minutes") or 60) 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() # 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: 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") total_cost = per_symbol_cost * symbol_count if total_cost > 0 and billing.is_billing_enabled(): user_credits = billing.get_user_credits(monitor_user_id) if user_credits < total_cost: logger.warning( f"Monitor #{monitor_id} skipped: insufficient credits " f"({user_credits} < {total_cost} for {symbol_count} symbols)" ) return {"success": False, "error": f"Insufficient credits: need {total_cost}, have {user_credits}"} for i in range(symbol_count): pos = positions[i] ok, msg = billing.check_and_consume( user_id=monitor_user_id, feature="ai_analysis", reference_id=f"monitor_{monitor_id}_{pos.get('symbol', '')}", ) if not ok: logger.warning(f"Monitor #{monitor_id} billing failed at symbol #{i + 1}: {msg}") break if monitor_type == "ai": 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, updated_at = NOW() WHERE id = ? """, (interval_minutes, json.dumps(result, ensure_ascii=False, default=str), monitor_id), ) db.commit() cur.close() 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=deduped_positions, position_analyses=position_analyses, language=language, custom_prompt=custom_prompt, user_id=monitor_user_id, ) return result except Exception as e: logger.error(f"run_single_monitor failed: {e}") logger.error(traceback.format_exc()) return {"success": False, "error": str(e)} def _check_position_alerts(): """Check all active alerts and trigger notifications if conditions are met.""" from datetime import datetime, timezone try: kline_service = KlineService() notifier = SignalNotifier() now = datetime.now(timezone.utc) with get_db_connection() as db: cur = db.cursor() # Get active alerts for all users that haven't been triggered (or can repeat) cur.execute( """ SELECT a.id, a.user_id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold, a.notification_config, a.is_triggered, a.last_triggered_at, a.repeat_interval, p.entry_price, p.quantity, p.side, p.name as position_name FROM qd_position_alerts a LEFT JOIN qd_manual_positions p ON a.position_id = p.id WHERE a.is_active = 1 """ ) alerts = cur.fetchall() or [] cur.close() for alert in alerts: try: alert_id = alert.get("id") alert_user_id = int(alert.get("user_id") or 1) alert_type = alert.get("alert_type") threshold = float(alert.get("threshold") or 0) market = alert.get("market") symbol = alert.get("symbol") is_triggered = bool(alert.get("is_triggered")) last_triggered_at = alert.get("last_triggered_at") # datetime or None repeat_interval = int(alert.get("repeat_interval") or 0) notification_config = _safe_json_loads(alert.get("notification_config"), {}) # Check if we can trigger (not triggered yet, or repeat interval passed) can_trigger = not is_triggered if is_triggered and repeat_interval > 0 and last_triggered_at: # Convert last_triggered_at to timezone-aware if needed if last_triggered_at.tzinfo is None: last_triggered_at = last_triggered_at.replace(tzinfo=timezone.utc) elapsed_seconds = (now - last_triggered_at).total_seconds() if elapsed_seconds >= repeat_interval: can_trigger = True if not can_trigger: continue # Get current price (use realtime price API) current_price = 0 try: price_data = kline_service.get_realtime_price(market, symbol) current_price = float(price_data.get("price") or 0) except Exception: continue if current_price <= 0: continue triggered = False alert_message = "" # Get language from notification_config (saved when alert was created) alert_language = notification_config.get("language", "en-US") if alert_type == "price_above": if current_price >= threshold: triggered = True alert_message = _get_alert_message( "price_above", alert_language, symbol=symbol, current_price=current_price, threshold=threshold, ) elif alert_type == "price_below": if current_price <= threshold: triggered = True alert_message = _get_alert_message( "price_below", alert_language, symbol=symbol, current_price=current_price, threshold=threshold, ) elif alert_type in ("pnl_above", "pnl_below"): entry_price = float(alert.get("entry_price") or 0) quantity = float(alert.get("quantity") or 0) side = alert.get("side") or "long" if entry_price > 0 and quantity > 0: if side == "long": pnl = (current_price - entry_price) * quantity else: pnl = (entry_price - current_price) * quantity pnl_percent = pnl / (entry_price * quantity) * 100 if alert_type == "pnl_above" and pnl_percent >= threshold: triggered = True alert_message = _get_alert_message( "pnl_above", alert_language, symbol=symbol, pnl_percent=pnl_percent, threshold=threshold ) elif alert_type == "pnl_below" and pnl_percent <= threshold: triggered = True alert_message = _get_alert_message( "pnl_below", alert_language, symbol=symbol, pnl_percent=pnl_percent, threshold=threshold ) if triggered: logger.info(f"Alert #{alert_id} triggered: {alert_message}") # Update alert status with get_db_connection() as db: cur = db.cursor() cur.execute( """ UPDATE qd_position_alerts SET is_triggered = 1, last_triggered_at = NOW(), trigger_count = trigger_count + 1, updated_at = NOW() WHERE id = ? """, (alert_id,), ) db.commit() cur.close() # Send notification (merge personal center notification configuration, consistent with asset monitoring tasks) 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: 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()) """, ( alert_user_id, symbol, "price_alert", "browser", alert_title, alert_message, json.dumps( {"alert_id": alert_id, "alert_type": alert_type}, ensure_ascii=False ), ), ) 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=alert_message, 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=alert_title, body_text=alert_message ) except Exception as e: logger.warning(f"Failed to send alert notification: {e}") except Exception as e: logger.warning(f"Error processing alert: {e}") except Exception as e: logger.error(f"_check_position_alerts failed: {e}") def notify_strategy_signal_for_positions( market: str, symbol: str, signal_type: str, signal_detail: str, user_id: int = None ): """ Called when a strategy signal is triggered. Check if user has manual positions in this symbol and send notification. """ try: symbol = (symbol or "").strip().upper() if not symbol: return with get_db_connection() as db: cur = db.cursor() # Query positions for all users or specific user if user_id is not None: cur.execute( """ SELECT id, user_id, market, symbol, name, side, quantity, entry_price, group_name FROM qd_manual_positions WHERE user_id = ? AND symbol = ? """, (user_id, symbol), ) else: cur.execute( """ SELECT id, user_id, market, symbol, name, side, quantity, entry_price, group_name FROM qd_manual_positions WHERE symbol = ? """, (symbol,), ) positions = cur.fetchall() or [] cur.close() if not positions: return # User has positions in this symbol - send notification for pos in positions: pos_user_id = int(pos.get("user_id") or 1) pos_name = pos.get("name") or symbol pos_side = pos.get("side") or "long" quantity = float(pos.get("quantity") or 0) entry_price = float(pos.get("entry_price") or 0) title = f"๐Ÿ”—Strategy signal linkage: {pos_name}" message = f"""The strategy emits {signal_type} signal! Target: {market}/{symbol} Your position: {pos_side.upper()} {quantity} @ {entry_price:.4f} Signal details: {signal_detail} Please check whether your position needs adjustment. """ # Save browser notification 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()) """, ( pos_user_id, symbol, "strategy_linkage", "browser", title, message, json.dumps({"signal_type": signal_type}, ensure_ascii=False), ), ) db.commit() cur.close() logger.info(f"Strategy signal linkage: notified {len(positions)} position(s) for {symbol}") except Exception as e: logger.error(f"notify_strategy_signal_for_positions failed: {e}") def _monitor_loop(): """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: _check_position_alerts() with get_db_connection() as db: cur = db.cursor() cur.execute( """ SELECT id, user_id FROM qd_position_monitors WHERE is_active = 1 AND next_run_at <= NOW() ORDER BY next_run_at ASC 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 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}") _stop_event.wait(30) logger.info("Portfolio monitor background loop stopped") def start_monitor_service(): """Start the background monitor service.""" global _monitor_thread if _monitor_thread and _monitor_thread.is_alive(): logger.info("Portfolio monitor service already running") return _stop_event.clear() _monitor_thread = threading.Thread(target=_monitor_loop, daemon=True, name="PortfolioMonitor") _monitor_thread.start() logger.info("Portfolio monitor service started") def stop_monitor_service(): """Stop the background monitor service.""" global _monitor_thread _stop_event.set() if _monitor_thread: _monitor_thread.join(timeout=5) _monitor_thread = None logger.info("Portfolio monitor service stopped")