2026-01-12 22:36:10 +08:00
"""
Portfolio Monitor Service.
Runs scheduled AI analysis on manual positions and sends notifications.
"""
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
from __future__ import annotations
import hashlib
import json
import threading
import time
import traceback
2026-03-23 23:01:04 +08:00
from concurrent.futures import ThreadPoolExecutor , as_completed
2026-01-12 22:36:10 +08:00
from typing import Any , Dict , List , Optional
2026-04-09 14:30:51 +07:00
from app.services.billing_service import get_billing_service
2026-01-31 02:59:49 +08:00
from app.services.fast_analysis import get_fast_analysis_service
2026-01-12 22:36:10 +08:00
from app.services.kline import KlineService
2026-04-09 14:30:51 +07:00
from app.services.signal_notifier import SignalNotifier
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
2026-01-12 22:36:10 +08:00
logger = get_logger ( __name__ )
DEFAULT_USER_ID = 1
_monitor_thread : Optional [ threading . Thread ] = None
_stop_event = threading . Event ()
2026-04-06 16:47:36 +07:00
# Multilingual message templates
2026-01-12 22:36:10 +08:00
ALERT_MESSAGES = {
2026-04-09 14:30:51 +07:00
"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}% ha s reached {threshold:.1f} % target" ,
"pnl_below" : "⚠️ Loss Alert: {symbol} P&L {pnl_percent:.1f}% ha s hit {threshold:.1f}% s top-loss" ,
"alert_title" : "Price/P&L Alert" ,
2026-01-12 22:36:10 +08:00
},
}
2026-04-09 14:30:51 +07:00
def _get_alert_message ( alert_type : str , language : str = "en-US" , ** kwargs ) -> str :
2026-01-12 22:36:10 +08:00
"""Get localized alert message."""
2026-04-09 14:30:51 +07:00
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 , "" )
2026-01-12 22:36:10 +08:00
if template :
return template . format ( ** kwargs )
2026-04-09 14:30:51 +07:00
return ""
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
def _get_alert_title ( language : str = "en-US" ) -> str :
2026-01-12 22:36:10 +08:00
"""Get localized alert title."""
2026-04-09 14:30:51 +07:00
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" )
2026-01-12 22:36:10 +08:00
def _now_ts () -> int :
return int ( time . time ())
2026-03-25 13:27:48 +08:00
def _resolve_notification_delivery ( user_id : int , notification_config : Optional [ Dict [ str , Any ]]) -> Dict [ str , Any ]:
"""
2026-04-06 16:47:36 +07:00
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.
2026-03-25 13:27:48 +08:00
"""
cfg : Dict [ str , Any ] = dict ( notification_config ) if isinstance ( notification_config , dict ) else {}
2026-04-09 14:30:51 +07:00
raw_ch = cfg . get ( "channels" )
2026-03-25 13:27:48 +08:00
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 :
2026-04-09 14:30:51 +07:00
channels = [ "browser" ]
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
targets : Dict [ str , Any ] = dict ( cfg . get ( "targets" ) or {})
2026-03-25 13:27:48 +08:00
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
2026-01-12 22:36:10 +08:00
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
2026-01-14 05:29:55 +08:00
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."""
2026-01-12 22:36:10 +08:00
try :
kline_service = KlineService ()
2026-01-14 05:29:55 +08:00
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
if position_ids :
2026-04-09 14:30:51 +07:00
placeholders = "," . join ([ "?" for _ in position_ids ])
2026-01-12 22:36:10 +08:00
cur . execute (
f """
2026-01-18 18:55:41 +08:00
SELECT id, market, symbol, name, side, quantity, entry_price, group_name
2026-01-12 22:36:10 +08:00
FROM qd_manual_positions
WHERE user_id = ? AND id IN ( { placeholders } )
""" ,
2026-04-09 14:30:51 +07:00
[ effective_user_id ] + list ( position_ids ),
2026-01-12 22:36:10 +08:00
)
else :
cur . execute (
"""
2026-01-18 18:55:41 +08:00
SELECT id, market, symbol, name, side, quantity, entry_price, group_name
2026-01-12 22:36:10 +08:00
FROM qd_manual_positions
WHERE user_id = ?
""" ,
2026-04-09 14:30:51 +07:00
( effective_user_id ,),
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
cur . close ()
positions = []
for row in rows :
2026-04-09 14:30:51 +07:00
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" )
2026-01-13 00:21:33 +08:00
# Get current price (use realtime price API)
2026-01-12 22:36:10 +08:00
current_price = 0
try :
2026-01-13 00:21:33 +08:00
price_data = kline_service . get_realtime_price ( market , symbol )
2026-04-09 14:30:51 +07:00
current_price = float ( price_data . get ( "price" ) or 0 )
2026-01-12 22:36:10 +08:00
except Exception :
pass
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Calculate PnL
2026-04-09 14:30:51 +07:00
if side == "long" :
2026-01-12 22:36:10 +08:00
pnl = ( current_price - entry_price ) * quantity
else :
pnl = ( entry_price - current_price ) * quantity
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
pnl_percent = round ( pnl / ( entry_price * quantity ) * 100 , 2 ) if entry_price * quantity > 0 else 0
2026-04-09 14:30:51 +07:00
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 ,
}
)
2026-01-12 22:36:10 +08:00
return positions
except Exception as e :
logger . error ( f "_get_positions_for_monitor failed: { e } " )
return []
2026-03-23 23:01:04 +08:00
MAX_PARALLEL_ANALYSIS = 5
2026-03-25 13:27:48 +08:00
def _analyze_single_position ( pos : Dict [ str , Any ], language : str , user_id : int = None ) -> Dict [ str , Any ]:
2026-03-23 23:01:04 +08:00
"""Analyze a single position (designed to run inside a thread pool)."""
2026-04-09 14:30:51 +07:00
market = pos . get ( "market" )
symbol = pos . get ( "symbol" )
name = pos . get ( "name" ) or symbol
group_name = pos . get ( "group_name" )
2026-03-23 23:01:04 +08:00
if not market or not symbol :
2026-04-09 14:30:51 +07:00
return { "market" : market , "symbol" : symbol , "name" : name , "error" : "missing market/symbol" }
2026-03-23 23:01:04 +08:00
try :
2026-03-25 13:27:48 +08:00
logger . info ( f "Running fast AI analysis for { market } : { symbol } (user= { user_id } )" )
2026-03-23 23:01:04 +08:00
service = get_fast_analysis_service ()
analysis_result = service . analyze (
2026-04-09 14:30:51 +07:00
market = market ,
symbol = symbol ,
language = language ,
timeframe = "1D" ,
2026-03-25 13:27:48 +08:00
user_id = user_id ,
2026-03-23 23:01:04 +08:00
)
2026-04-09 14:30:51 +07:00
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 ""
2026-03-23 23:01:04 +08:00
result = {
2026-04-09 14:30:51 +07:00
"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" ),
2026-03-23 23:01:04 +08:00
}
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 } " )
2026-04-09 14:30:51 +07:00
return { "market" : market , "symbol" : symbol , "name" : name , "error" : str ( e )}
2026-03-23 23:01:04 +08:00
2026-03-25 13:27:48 +08:00
def _run_ai_analysis ( positions : List [ Dict [ str , Any ]], config : Dict [ str , Any ], user_id : int = None ) -> Dict [ str , Any ]:
2026-01-12 22:36:10 +08:00
"""
2026-03-23 23:01:04 +08:00
Run fast AI analysis on positions **in parallel** using a thread pool.
2026-03-25 13:27:48 +08:00
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.
2026-01-12 22:36:10 +08:00
"""
try :
2026-04-09 14:30:51 +07:00
language = config . get ( "language" , "en-US" )
custom_prompt = config . get ( "prompt" , "" )
2026-03-23 23:01:04 +08:00
2026-03-25 13:27:48 +08:00
# ── Deduplicate by (market, symbol) ──
2026-04-09 14:30:51 +07:00
unique_map : Dict [ str , int ] = {} # "market|symbol" -> index in unique_positions
2026-03-25 13:27:48 +08:00
unique_positions : List [ Dict [ str , Any ]] = []
2026-04-09 14:30:51 +07:00
pos_to_unique : List [ int ] = [] # positions[i] -> unique_positions index
2026-03-25 13:27:48 +08:00
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 )
2026-03-23 23:01:04 +08:00
with ThreadPoolExecutor ( max_workers = workers ) as executor :
future_to_idx = {
2026-03-25 13:27:48 +08:00
executor . submit ( _analyze_single_position , pos , language , user_id ): idx
for idx , pos in enumerate ( unique_positions )
2026-03-23 23:01:04 +08:00
}
for future in as_completed ( future_to_idx ):
idx = future_to_idx [ future ]
try :
2026-03-25 13:27:48 +08:00
unique_analyses [ idx ] = future . result ()
2026-03-23 23:01:04 +08:00
except Exception as e :
2026-03-25 13:27:48 +08:00
pos = unique_positions [ idx ]
unique_analyses [ idx ] = {
2026-04-09 14:30:51 +07:00
"market" : pos . get ( "market" ),
"symbol" : pos . get ( "symbol" ),
"name" : pos . get ( "name" ) or pos . get ( "symbol" ),
"error" : str ( e ),
2026-03-23 23:01:04 +08:00
}
2026-03-25 13:27:48 +08:00
# ── 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 ]])
2026-04-09 14:30:51 +07:00
base [ "entry_price" ] = pos . get ( "entry_price" )
base [ "current_price" ] = base . get ( "current_price" ) or pos . get ( "current_price" )
2026-03-25 13:27:48 +08:00
combined_qty = sum (
2026-04-09 14:30:51 +07:00
float ( p . get ( "quantity" ) or 0 )
2026-03-25 13:27:48 +08:00
for j , p in enumerate ( positions )
if f " { p . get ( 'market' ) } | { p . get ( 'symbol' ) } " == key
)
combined_cost = sum (
2026-04-09 14:30:51 +07:00
float ( p . get ( "entry_price" ) or 0 ) * float ( p . get ( "quantity" ) or 0 )
2026-03-25 13:27:48 +08:00
for j , p in enumerate ( positions )
if f " { p . get ( 'market' ) } | { p . get ( 'symbol' ) } " == key
)
combined_pnl = sum (
2026-04-09 14:30:51 +07:00
float ( p . get ( "pnl" ) or 0 )
2026-03-25 13:27:48 +08:00
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
2026-04-09 14:30:51 +07:00
base [ "quantity" ] = combined_qty
base [ "entry_price" ] = avg_entry
base [ "pnl" ] = round ( combined_pnl , 2 )
base [ "pnl_percent" ] = pnl_pct
2026-03-25 13:27:48 +08:00
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 )
2026-04-09 14:30:51 +07:00
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" ))
2026-03-25 13:27:48 +08:00
deduped_positions . append ( merged )
analysis_report = _build_comprehensive_report ( deduped_positions , position_analyses , language , custom_prompt )
2026-03-23 23:01:04 +08:00
2026-01-12 22:36:10 +08:00
return {
2026-04-09 14:30:51 +07:00
"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 (),
2026-01-12 22:36:10 +08:00
}
2026-03-23 23:01:04 +08:00
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "_run_ai_analysis failed: { e } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return { "success" : False , "error" : str ( e ), "timestamp" : _now_ts ()}
2026-01-12 22:36:10 +08:00
def _build_comprehensive_report (
2026-04-09 14:30:51 +07:00
positions : List [ Dict [ str , Any ]], position_analyses : List [ Dict [ str , Any ]], language : str , custom_prompt : str = ""
2026-01-12 22:36:10 +08:00
) -> 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 (
2026-04-09 14:30:51 +07:00
positions : List [ Dict [ str , Any ]], position_analyses : List [ Dict [ str , Any ]], language : str , custom_prompt : str = ""
2026-01-12 22:36:10 +08:00
) -> str :
"""Build a beautiful HTML report with collapsible sections."""
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Calculate portfolio summary
2026-04-09 14:30:51 +07:00
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 )
2026-01-12 22:36:10 +08:00
total_pnl_percent = round ( total_pnl / total_cost * 100 , 2 ) if total_cost > 0 else 0
2026-04-09 14:30:51 +07:00
total_market_value = sum ( float ( p . get ( "current_price" , 0 )) * float ( p . get ( "quantity" , 0 )) for p in positions )
2026-01-12 22:36:10 +08:00
# Count recommendations
2026-04-09 14:30:51 +07:00
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" )
2026-01-12 22:36:10 +08:00
# Text translations
texts = {
2026-04-09 14:30:51 +07:00
"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" ,
2026-01-12 22:36:10 +08:00
}
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# CSS Styles
2026-04-09 14:30:51 +07:00
css = """
2026-01-12 22:36:10 +08:00
<style>
.qd-report { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 16px; }
.qd-report * { box-sizing: border-box; }
.qd-header { text-align: center; color: #fff; padding: 20px 0 30px; }
.qd-header h1 { margin: 0 0 8px; font-size: 24px; font-weight: 700; text-shadow: 0 2px 4px rgba(0,0,0,0.2); }
.qd-header .subtitle { font-size: 13px; opacity: 0.9; }
.qd-content { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.15); }
.qd-section { margin-bottom: 24px; }
.qd-section:last-child { margin-bottom: 0; }
.qd-section-title { font-size: 16px; font-weight: 600; color: #1a1a2e; margin: 0 0 16px; padding-bottom: 8px; border-bottom: 2px solid #667eea; }
.qd-overview-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.qd-stat-card { background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf3 100%); border-radius: 10px; padding: 16px; text-align: center; }
.qd-stat-card .label { font-size: 12px; color: #666; margin-bottom: 6px; }
.qd-stat-card .value { font-size: 20px; font-weight: 700; color: #1a1a2e; }
.qd-stat-card .value.positive { color: #10b981; }
.qd-stat-card .value.negative { color: #ef4444; }
.qd-stat-card .percent { font-size: 12px; font-weight: 500; margin-left: 4px; }
.qd-rec-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.qd-rec-card { border-radius: 10px; padding: 16px; text-align: center; }
.qd-rec-card.buy { background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); }
.qd-rec-card.sell { background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); }
.qd-rec-card.hold { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); }
.qd-rec-card .emoji { font-size: 28px; margin-bottom: 8px; }
.qd-rec-card .count { font-size: 24px; font-weight: 700; }
.qd-rec-card.buy .count { color: #059669; }
.qd-rec-card.sell .count { color: #dc2626; }
.qd-rec-card.hold .count { color: #d97706; }
.qd-rec-card .label { font-size: 13px; color: #666; margin-top: 4px; }
.qd-position { background: #f8fafc; border-radius: 12px; margin-bottom: 16px; overflow: hidden; border: 1px solid #e2e8f0; }
.qd-position:last-child { margin-bottom: 0; }
.qd-pos-header { display: flex; justify-content: space-between; align-items: center; padding: 16px; background: #fff; cursor: default; }
.qd-pos-symbol { display: flex; align-items: center; gap: 12px; }
.qd-pos-symbol .icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; color: #fff; }
.qd-pos-symbol .icon.buy { background: linear-gradient(135deg, #10b981 0%, #059669 100%); }
.qd-pos-symbol .icon.sell { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); }
.qd-pos-symbol .icon.hold { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); }
.qd-pos-symbol .name { font-weight: 600; font-size: 15px; color: #1a1a2e; }
.qd-pos-symbol .market { font-size: 12px; color: #666; }
.qd-pos-decision { text-align: right; }
.qd-pos-decision .decision-tag { display: inline-block; padding: 6px 14px; border-radius: 20px; font-weight: 600; font-size: 13px; }
.qd-pos-decision .decision-tag.buy { background: #d1fae5; color: #059669; }
.qd-pos-decision .decision-tag.sell { background: #fee2e2; color: #dc2626; }
.qd-pos-decision .decision-tag.hold { background: #fef3c7; color: #d97706; }
.qd-pos-decision .confidence { font-size: 12px; color: #666; margin-top: 4px; }
.qd-pos-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: #e2e8f0; }
.qd-pos-stats .stat { background: #fff; padding: 12px; text-align: center; }
.qd-pos-stats .stat .label { font-size: 11px; color: #666; margin-bottom: 4px; }
.qd-pos-stats .stat .value { font-size: 14px; font-weight: 600; color: #1a1a2e; }
.qd-pos-stats .stat .value.positive { color: #10b981; }
.qd-pos-stats .stat .value.negative { color: #ef4444; }
.qd-pos-reasoning { padding: 16px; background: #fff; border-top: 1px solid #e2e8f0; }
.qd-pos-reasoning .label { font-size: 12px; font-weight: 600; color: #666; margin-bottom: 6px; }
.qd-pos-reasoning .text { font-size: 13px; color: #374151; line-height: 1.6; }
.qd-collapsible { border-top: 1px solid #e2e8f0; }
.qd-collapsible input[type="checkbox"] { display: none; }
.qd-collapsible-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: #f1f5f9; cursor: pointer; user-select: none; }
.qd-collapsible-header:hover { background: #e2e8f0; }
.qd-collapsible-header .title { font-size: 13px; font-weight: 600; color: #475569; }
.qd-collapsible-header .arrow { transition: transform 0.2s; color: #94a3b8; display: inline-block; }
.qd-collapsible-content { display: none; padding: 16px; background: #fff; font-size: 13px; color: #475569; line-height: 1.7; border-top: 1px solid #e2e8f0; }
.qd-collapsible input[type="checkbox"]:checked ~ .qd-collapsible-content { display: block; }
.qd-collapsible input[type="checkbox"]:checked + .qd-collapsible-header .arrow { transform: rotate(180deg); }
.qd-user-focus { background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%); border-radius: 10px; padding: 16px; font-size: 13px; color: #5b21b6; line-height: 1.6; }
.qd-footer { text-align: center; padding: 20px 0 0; font-size: 12px; color: #666; border-top: 1px solid #e2e8f0; margin-top: 24px; }
.qd-footer .time { margin-bottom: 4px; }
.qd-footer .disclaimer { opacity: 0.8; }
.qd-error { background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 12px; color: #dc2626; font-size: 13px; }
@media (max-width: 600px) {
.qd-report { padding: 12px; border-radius: 0; }
.qd-overview-grid { grid-template-columns: repeat(2, 1fr); }
.qd-rec-grid { grid-template-columns: repeat(3, 1fr); }
.qd-pos-stats { grid-template-columns: repeat(2, 1fr); }
}
</style>
2026-04-09 14:30:51 +07:00
"""
2026-01-12 22:36:10 +08:00
# Build HTML
2026-04-09 14:30:51 +07:00
pnl_class = "positive" if total_pnl >= 0 else "negative"
pnl_sign = "+" if total_pnl >= 0 else ""
html = f """
2026-01-12 22:36:10 +08:00
{ css }
<div class="qd-report">
<div class="qd-header">
2026-04-09 14:30:51 +07:00
<h1> { texts [ "title" ] } </h1>
<div class="subtitle"> { texts [ "subtitle" ] } </div>
2026-01-12 22:36:10 +08:00
</div>
<div class="qd-content">
<!-- Overview Section -->
<div class="qd-section">
2026-04-09 14:30:51 +07:00
<h2 class="qd-section-title"> { texts [ "overview" ] } </h2>
2026-01-12 22:36:10 +08:00
<div class="qd-overview-grid">
<div class="qd-stat-card">
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "positions" ] } </div>
2026-01-12 22:36:10 +08:00
<div class="value"> { len ( positions ) } </div>
</div>
<div class="qd-stat-card">
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "total_value" ] } </div>
2026-01-12 22:36:10 +08:00
<div class="value">$ { total_market_value : ,.2f } </div>
</div>
<div class="qd-stat-card">
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "total_cost" ] } </div>
2026-01-12 22:36:10 +08:00
<div class="value">$ { total_cost : ,.2f } </div>
</div>
<div class="qd-stat-card">
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "total_pnl" ] } </div>
2026-01-12 22:36:10 +08:00
<div class="value { pnl_class } "> { pnl_sign } $ { total_pnl : ,.2f } <span class="percent">( { pnl_sign }{ total_pnl_percent : .1f } %)</span></div>
</div>
</div>
</div>
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
<!-- AI Recommendations Section -->
<div class="qd-section">
2026-04-09 14:30:51 +07:00
<h2 class="qd-section-title"> { texts [ "ai_recommendations" ] } </h2>
2026-01-12 22:36:10 +08:00
<div class="qd-rec-grid">
<div class="qd-rec-card buy">
<div class="emoji">🟢</div>
<div class="count"> { buy_count } </div>
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "buy" ] } </div>
2026-01-12 22:36:10 +08:00
</div>
<div class="qd-rec-card sell">
<div class="emoji">🔴</div>
<div class="count"> { sell_count } </div>
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "sell" ] } </div>
2026-01-12 22:36:10 +08:00
</div>
<div class="qd-rec-card hold">
<div class="emoji">🟡</div>
<div class="count"> { hold_count } </div>
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "hold" ] } </div>
2026-01-12 22:36:10 +08:00
</div>
</div>
</div>
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
<!-- Position Analysis Section -->
<div class="qd-section">
2026-04-09 14:30:51 +07:00
<h2 class="qd-section-title"> { texts [ "position_analysis" ] } </h2>
"""
2026-01-12 22:36:10 +08:00
for pa in position_analyses :
2026-04-09 14:30:51 +07:00
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 """
2026-01-12 22:36:10 +08:00
<div class="qd-position">
<div class="qd-pos-header">
<div class="qd-pos-symbol">
<div class="icon hold">⚠️</div>
<div>
<div class="name"> { name } </div>
<div class="market"> { market } / { symbol } </div>
</div>
</div>
</div>
2026-04-09 14:30:51 +07:00
<div class="qd-error" style="margin: 16px;"> { texts [ "analysis_failed" ] } : { pa . get ( "error" ) } </div>
2026-01-12 22:36:10 +08:00
</div>
2026-04-09 14:30:51 +07:00
"""
2026-01-12 22:36:10 +08:00
continue
2026-04-09 14:30:51 +07:00
decision = pa . get ( "final_decision" , "HOLD" )
2026-01-12 22:36:10 +08:00
decision_lower = decision . lower ()
decision_text = texts . get ( decision_lower , decision )
2026-04-09 14:30:51 +07:00
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 """
2026-01-12 22:36:10 +08:00
<div class="qd-position">
<div class="qd-pos-header">
<div class="qd-pos-symbol">
<div class="icon { decision_lower } "> { decision [ 0 ] } </div>
<div>
<div class="name"> { name } </div>
<div class="market"> { market } / { symbol } </div>
</div>
</div>
<div class="qd-pos-decision">
<div class="decision-tag { decision_lower } "> { decision_text } </div>
2026-04-09 14:30:51 +07:00
<div class="confidence"> { texts [ "confidence" ] } : { confidence } %</div>
2026-01-12 22:36:10 +08:00
</div>
</div>
<div class="qd-pos-stats">
<div class="stat">
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "current_price" ] } </div>
2026-01-12 22:36:10 +08:00
<div class="value">$ { current_price : .4f } </div>
</div>
<div class="stat">
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "entry_price" ] } </div>
2026-01-12 22:36:10 +08:00
<div class="value">$ { entry_price : .4f } </div>
</div>
<div class="stat">
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "pnl" ] } </div>
2026-01-12 22:36:10 +08:00
<div class="value { pnl_class } "> { pnl_sign } $ { pnl : .2f } ( { pnl_sign }{ pnl_pct : .1f } %)</div>
</div>
<div class="stat">
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "quantity" ] } / { texts [ "side" ] } </div>
2026-01-12 22:36:10 +08:00
<div class="value"> { quantity } / { side_text } </div>
</div>
</div>
2026-04-09 14:30:51 +07:00
"""
2026-01-12 22:36:10 +08:00
# Reasoning summary
if reasoning :
2026-04-09 14:30:51 +07:00
html += f """
2026-01-12 22:36:10 +08:00
<div class="qd-pos-reasoning">
2026-04-09 14:30:51 +07:00
<div class="label"> { texts [ "reasoning" ] } </div>
<div class="text"> { reasoning [: 500 ] }{ "..." if len ( reasoning ) > 500 else "" } </div>
2026-01-12 22:36:10 +08:00
</div>
2026-04-09 14:30:51 +07:00
"""
2026-01-12 22:36:10 +08:00
# Generate unique ID for collapsible sections (use symbol hash to avoid special chars)
2026-01-18 18:55:41 +08:00
section_id_base = hashlib . md5 ( f " { symbol } _ { market } _ { group_name } " . encode ()) . hexdigest ()[: 8 ]
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Collapsible: Trader Analysis
if trader_reasoning :
trader_id = f "trader_ { section_id_base } "
html += f '''
<div class="qd-collapsible">
<input type="checkbox" id=" { trader_id } ">
<label for=" { trader_id } " class="qd-collapsible-header">
2026-04-09 14:30:51 +07:00
<span class="title"> { texts [ "trader_report" ] } </span>
2026-01-12 22:36:10 +08:00
<span class="arrow">▼</span>
</label>
2026-04-09 14:30:51 +07:00
<div class="qd-collapsible-content"> { trader_reasoning . replace ( chr ( 10 ), "<br>" ) } </div>
2026-01-12 22:36:10 +08:00
</div>
'''
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Collapsible: Market Overview
if overview_report :
overview_id = f "overview_ { section_id_base } "
html += f '''
<div class="qd-collapsible">
<input type="checkbox" id=" { overview_id } ">
<label for=" { overview_id } " class="qd-collapsible-header">
2026-04-09 14:30:51 +07:00
<span class="title"> { texts [ "overview_report" ] } </span>
2026-01-12 22:36:10 +08:00
<span class="arrow">▼</span>
</label>
2026-04-09 14:30:51 +07:00
<div class="qd-collapsible-content"> { overview_report . replace ( chr ( 10 ), "<br>" ) } </div>
2026-01-12 22:36:10 +08:00
</div>
'''
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Collapsible: Risk Assessment
if risk_report :
risk_id = f "risk_ { section_id_base } "
html += f '''
<div class="qd-collapsible">
<input type="checkbox" id=" { risk_id } ">
<label for=" { risk_id } " class="qd-collapsible-header">
2026-04-09 14:30:51 +07:00
<span class="title"> { texts [ "risk_report" ] } </span>
2026-01-12 22:36:10 +08:00
<span class="arrow">▼</span>
</label>
2026-04-09 14:30:51 +07:00
<div class="qd-collapsible-content"> { risk_report . replace ( chr ( 10 ), "<br>" ) } </div>
2026-01-12 22:36:10 +08:00
</div>
'''
2026-04-09 14:30:51 +07:00
html += """
2026-01-12 22:36:10 +08:00
</div>
2026-04-09 14:30:51 +07:00
"""
2026-01-12 22:36:10 +08:00
# User focus section
if custom_prompt :
2026-04-09 14:30:51 +07:00
html += f """
2026-01-12 22:36:10 +08:00
</div>
<div class="qd-section">
2026-04-09 14:30:51 +07:00
<h2 class="qd-section-title"> { texts [ "user_focus" ] } </h2>
2026-01-12 22:36:10 +08:00
<div class="qd-user-focus"> { custom_prompt } </div>
2026-04-09 14:30:51 +07:00
"""
2026-01-12 22:36:10 +08:00
# Footer
2026-04-09 14:30:51 +07:00
html += f """
2026-01-12 22:36:10 +08:00
</div>
<div class="qd-footer">
2026-04-09 14:30:51 +07:00
<div class="time"> { texts [ "generated_at" ] } : { time . strftime ( "%Y-%m- %d %H:%M:%S" ) } </div>
<div class="disclaimer"> { texts [ "disclaimer" ] } </div>
2026-01-12 22:36:10 +08:00
</div>
</div>
</div>
2026-04-09 14:30:51 +07:00
"""
2026-01-12 22:36:10 +08:00
return html
def _build_telegram_report (
2026-04-09 14:30:51 +07:00
positions : List [ Dict [ str , Any ]], position_analyses : List [ Dict [ str , Any ]], language : str , custom_prompt : str = ""
2026-01-12 22:36:10 +08:00
) -> str :
2026-03-25 13:27:48 +08:00
"""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 :
2026-04-09 14:30:51 +07:00
return float ( pa . get ( "quantity" ) or 0 ) > 0 and float ( pa . get ( "entry_price" ) or 0 ) > 0
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
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" )]
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
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 )
2026-03-25 13:27:48 +08:00
total_pnl_pct = round ( total_pnl / total_cost * 100 , 2 ) if total_cost > 0 else 0
2026-04-09 14:30:51 +07:00
pnl_sign = "+" if total_pnl >= 0 else ""
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
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" ])
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
is_zh = language . startswith ( "zh" )
2026-03-25 13:27:48 +08:00
# ── Header / Overview ──
2026-01-12 22:36:10 +08:00
if is_zh :
2026-04-06 16:47:36 +07:00
lines : List [ str ] = [ "<b>📊 AI Asset Analysis Report</b>" , "" ]
overview = [ "<b>📈 Overview</b>" ]
2026-03-25 13:27:48 +08:00
if held :
2026-04-06 16:47:36 +07:00
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 } %)" )
2026-03-25 13:27:48 +08:00
if watched :
2026-04-06 16:47:36 +07:00
overview . append ( f "• Observations: { len ( watched ) } " )
2026-03-25 13:27:48 +08:00
lines . extend ( overview )
2026-04-09 14:30:51 +07:00
lines . extend (
[
"" ,
"<b>🤖 Summary of AI suggestions</b>" ,
f "🟢 Buy: { buy_count } | 🔴 Sell: { sell_count } | 🟡 Hold: { hold_count } " ,
]
)
2026-03-25 13:27:48 +08:00
else :
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 )
2026-04-09 14:30:51 +07:00
lines . extend (
[
"" ,
"<b>🤖 AI Recommendations</b>" ,
f "🟢 Buy: { buy_count } | 🔴 Sell: { sell_count } | 🟡 Hold: { hold_count } " ,
]
)
2026-03-25 13:27:48 +08:00
# ── Helper: render one analysis entry ──
def _render_pa ( pa : Dict [ str , Any ], show_pnl : bool ) -> None :
2026-04-09 14:30:51 +07:00
decision = pa . get ( "final_decision" , "HOLD" )
emoji = { "BUY" : "🟢" , "SELL" : "🔴" , "HOLD" : "🟡" } . get ( decision , "⚪" )
2026-03-25 13:27:48 +08:00
d_text = decision
if is_zh :
2026-04-09 14:30:51 +07:00
d_text = { "BUY" : "Buy" , "SELL" : "Sell" , "HOLD" : "Hold" } . get ( decision , "Hold" )
2026-03-25 13:27:48 +08:00
lines . append ( f " \n { emoji } <b> { pa . get ( 'name' , pa . get ( 'symbol' )) } </b> ( { pa . get ( 'market' ) } / { pa . get ( 'symbol' ) } )" )
if show_pnl :
2026-04-09 14:30:51 +07:00
pnl = pa . get ( "pnl" , 0 )
pnl_pct = pa . get ( "pnl_percent" , 0 )
ps = "+" if pnl >= 0 else ""
2026-03-25 13:27:48 +08:00
lines . append (
f " 💰 $ { pa . get ( 'current_price' , 0 ) : ,.2f } | "
2026-04-06 16:47:36 +07:00
f " { 'profit and loss' if is_zh else 'P&L' } : { ps } $ { pnl : ,.2f } ( { ps }{ pnl_pct : .1f } %)"
2026-03-25 13:27:48 +08:00
)
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> "
2026-04-06 16:47:36 +07:00
f "( { 'confidence' if is_zh else 'Conf' } : { pa . get ( 'confidence' , 50 ) } %)"
2026-03-25 13:27:48 +08:00
)
2026-04-09 14:30:51 +07:00
reasoning = pa . get ( "reasoning" , "" )
2026-03-25 13:27:48 +08:00
if reasoning :
lines . append ( f " 📝 { reasoning [: 150 ] }{ '...' if len ( reasoning ) > 150 else '' } " )
# ── Holdings section ──
if held :
2026-04-06 16:47:36 +07:00
lines . extend ([ "" , f "<b>📋 { 'position analysis' if is_zh else 'Holdings' } </b>" ])
2026-03-25 13:27:48 +08:00
for pa in held :
_render_pa ( pa , show_pnl = True )
# ── Watchlist section ──
if watched :
2026-04-06 16:47:36 +07:00
lines . extend ([ "" , f "<b>👁 { 'Watchlist' if is_zh else 'Watchlist' } </b>" ])
2026-03-25 13:27:48 +08:00
for pa in watched :
_render_pa ( pa , show_pnl = False )
# ── Errors ──
for pa in errored :
2026-04-09 14:30:51 +07:00
label = pa . get ( "name" ) or pa . get ( "symbol" ) or "?"
2026-04-06 16:47:36 +07:00
lines . append ( f " \n ⚠️ <b> { label } </b>: { 'Analysis failed' if is_zh else 'Analysis failed' } " )
2026-03-25 13:27:48 +08:00
if custom_prompt :
lines . extend ([ "" , f "<b>👤 { '关注点' if is_zh else 'Focus' } :</b> { custom_prompt } " ])
2026-04-09 14:30:51 +07:00
lines . extend (
[
"" ,
"─────────────────────" ,
f "<i>⏰ { time . strftime ( '%Y-%m- %d %H:%M' ) } </i>" ,
f "<i> { 'Generated by QuantDinger Multi-Agent System' if is_zh else 'Generated by QuantDinger Multi-Agent System' } </i>" ,
]
)
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
return " \n " . join ( lines )
2026-03-25 13:27:48 +08:00
def _build_batch_telegram_report (
monitor_results : List [ Dict [ str , Any ]],
language : str ,
) -> str :
"""Build a single Telegram report that combines multiple monitor results."""
2026-04-09 14:30:51 +07:00
is_zh = language . startswith ( "zh" )
2026-03-25 13:27:48 +08:00
def _has_holding ( pa : Dict [ str , Any ]) -> bool :
2026-04-09 14:30:51 +07:00
return float ( pa . get ( "quantity" ) or 0 ) > 0 and float ( pa . get ( "entry_price" ) or 0 ) > 0
2026-03-25 13:27:48 +08:00
all_analyses : List [ Dict [ str , Any ]] = []
monitor_sections : List [ str ] = []
for res in monitor_results :
2026-04-09 14:30:51 +07:00
meta = res . get ( "_meta" , {})
m_name = meta . get ( "monitor_name" , "?" )
m_analyses = meta . get ( "position_analyses" , [])
2026-03-25 13:27:48 +08:00
all_analyses . extend ( m_analyses )
section_lines : List [ str ] = [ f " \n <b>📋 { m_name } </b>" ]
for pa in m_analyses :
2026-04-09 14:30:51 +07:00
if pa . get ( "error" ):
label = pa . get ( "name" ) or pa . get ( "symbol" ) or "?"
2026-04-06 16:47:36 +07:00
section_lines . append ( f " ⚠️ { label } : { 'Analysis failed' if is_zh else 'Failed' } " )
2026-01-12 22:36:10 +08:00
continue
2026-04-09 14:30:51 +07:00
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 )
2026-03-25 13:27:48 +08:00
section_lines . append (
f " { emoji } <b> { pa . get ( 'name' , pa . get ( 'symbol' )) } </b> ( { pa . get ( 'market' ) } / { pa . get ( 'symbol' ) } )"
)
if _has_holding ( pa ):
2026-04-09 14:30:51 +07:00
pnl = pa . get ( "pnl" , 0 )
pnl_s = "+" if pnl >= 0 else ""
pnl_pct = pa . get ( "pnl_percent" , 0 )
2026-03-25 13:27:48 +08:00
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> "
2026-04-06 16:47:36 +07:00
f "( { 'confidence' if is_zh else 'Conf' } : { pa . get ( 'confidence' , 50 ) } %)"
2026-03-25 13:27:48 +08:00
)
2026-04-09 14:30:51 +07:00
reasoning = pa . get ( "reasoning" , "" )
2026-01-12 22:36:10 +08:00
if reasoning :
2026-03-25 13:27:48 +08:00
section_lines . append ( f " 📝 { reasoning [: 120 ] }{ '...' if len ( reasoning ) > 120 else '' } " )
2026-04-09 14:30:51 +07:00
monitor_sections . append ( " \n " . join ( section_lines ))
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
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 )
2026-03-25 13:27:48 +08:00
total_pnl_pct = round ( total_pnl / total_cost * 100 , 2 ) if total_cost else 0
2026-04-09 14:30:51 +07:00
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" ])
2026-03-25 13:27:48 +08:00
if is_zh :
header = [
2026-04-06 16:47:36 +07:00
"<b>📊 Regular asset monitoring report</b>" ,
2026-03-25 13:27:48 +08:00
"" ,
2026-04-06 16:47:36 +07:00
"<b>📈 General Overview</b>" ,
f "•Monitoring tasks: { len ( monitor_results ) } " ,
f "• Number of targets: { len ( all_analyses ) } " ,
2026-03-25 13:27:48 +08:00
]
if held :
2026-04-09 14:30:51 +07:00
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 } %)"
)
2026-03-25 13:27:48 +08:00
if watched :
2026-04-06 16:47:36 +07:00
header . append ( f "• Observations: { len ( watched ) } " )
2026-04-09 14:30:51 +07:00
header . extend (
[
"" ,
"<b>🤖 Summary of AI suggestions</b>" ,
f "🟢 Buy: { buy_c } | 🔴 Sell: { sell_c } | 🟡 Hold: { hold_c } " ,
]
)
2026-01-12 22:36:10 +08:00
else :
2026-03-25 13:27:48 +08:00
header = [
"<b>📊 Scheduled Portfolio Report</b>" ,
2026-01-12 22:36:10 +08:00
"" ,
2026-03-25 13:27:48 +08:00
"<b>📈 Summary</b>" ,
f "• Monitors: { len ( monitor_results ) } " ,
f "• Symbols: { len ( all_analyses ) } " ,
2026-01-12 22:36:10 +08:00
]
2026-03-25 13:27:48 +08:00
if held :
2026-04-09 14:30:51 +07:00
header . append (
f "• Holdings: { len ( held ) } | Cost: $ { total_cost : ,.2f } | P&L: { pnl_sign } $ { total_pnl : ,.2f } ( { pnl_sign }{ total_pnl_pct : .1f } %)"
)
2026-03-25 13:27:48 +08:00
if watched :
header . append ( f "• Watchlist: { len ( watched ) } " )
2026-04-09 14:30:51 +07:00
header . extend (
[
"" ,
"<b>🤖 AI Recommendations</b>" ,
f "🟢 Buy: { buy_c } | 🔴 Sell: { sell_c } | 🟡 Hold: { hold_c } " ,
]
)
2026-03-25 13:27:48 +08:00
footer = [
"" ,
"─────────────────────" ,
f "<i>⏰ { time . strftime ( '%Y-%m- %d %H:%M' ) } </i>" ,
2026-04-06 16:47:36 +07:00
f "<i> { 'Generated by QuantDinger Multi-Agent System' if is_zh else 'Generated by QuantDinger Multi-Agent System' } </i>" ,
2026-03-25 13:27:48 +08:00
]
2026-04-09 14:30:51 +07:00
return " \n " . join ( header + monitor_sections + footer )
2026-03-25 13:27:48 +08:00
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 :
2026-04-09 14:30:51 +07:00
report = res . get ( "analysis" , "" )
2026-03-25 13:27:48 +08:00
if report :
parts . append ( report )
if not parts :
2026-04-09 14:30:51 +07:00
return ""
2026-03-25 13:27:48 +08:00
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
2026-04-09 14:30:51 +07:00
successful = [ r for r in monitor_results if r . get ( "success" )]
2026-03-25 13:27:48 +08:00
if not successful :
for r in monitor_results :
2026-04-09 14:30:51 +07:00
meta = r . get ( "_meta" , {})
2026-03-25 13:27:48 +08:00
_send_monitor_notification (
2026-04-09 14:30:51 +07:00
monitor_name = meta . get ( "monitor_name" , "?" ),
2026-03-25 13:27:48 +08:00
result = r ,
2026-04-09 14:30:51 +07:00
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" , "" ),
2026-03-25 13:27:48 +08:00
user_id = user_id ,
)
return
2026-04-09 14:30:51 +07:00
first_meta = successful [ 0 ] . get ( "_meta" , {})
language = first_meta . get ( "language" , "en-US" )
2026-03-25 13:27:48 +08:00
# Merge channels from all monitors (union)
all_channels : set = set ()
for r in successful :
2026-04-09 14:30:51 +07:00
m = r . get ( "_meta" , {})
nc = m . get ( "notification_config" , {})
chs = nc . get ( "channels" )
2026-03-25 13:27:48 +08:00
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 :
2026-04-09 14:30:51 +07:00
all_channels = { "browser" }
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
merged_nc = { "channels" : list ( all_channels ), "targets" : {}}
2026-03-25 13:27:48 +08:00
resolved_nc = _resolve_notification_delivery ( user_id , merged_nc )
2026-04-09 14:30:51 +07:00
channels = resolved_nc . get ( "channels" ) or [ "browser" ]
targets = resolved_nc . get ( "targets" , {})
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
is_zh = language . startswith ( "zh" )
names = ", " . join ( r . get ( "_meta" , {}) . get ( "monitor_name" , "?" ) for r in successful )
2026-04-06 16:47:36 +07:00
title = f "📊 Scheduled Asset Monitoring: { names } " if is_zh else f "📊 Scheduled Report: { names } "
2026-03-25 13:27:48 +08:00
if len ( title ) > 255 :
2026-04-09 14:30:51 +07:00
title = title [: 252 ] + "..."
2026-03-25 13:27:48 +08:00
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 ()
2026-04-09 14:30:51 +07:00
if ch == "browser" :
2026-03-25 13:27:48 +08:00
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())
""" ,
2026-04-09 14:30:51 +07:00
(
user_id ,
"PORTFOLIO" ,
"ai_monitor" ,
"browser" ,
title ,
html_report ,
json . dumps ({ "batch" : True , "count" : len ( successful )}, ensure_ascii = False , default = str ),
),
2026-03-25 13:27:48 +08:00
)
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
elif ch == "telegram" :
chat_id = targets . get ( "telegram" , "" )
token_override = targets . get ( "telegram_bot_token" , "" )
2026-03-25 13:27:48 +08:00
if chat_id :
notifier . _notify_telegram (
2026-04-09 14:30:51 +07:00
chat_id = chat_id ,
text = telegram_report ,
token_override = token_override ,
parse_mode = "HTML" ,
2026-03-25 13:27:48 +08:00
)
2026-04-09 14:30:51 +07:00
elif ch == "email" :
to_email = targets . get ( "email" , "" )
2026-03-25 13:27:48 +08:00
if to_email :
notifier . _notify_email (
2026-04-09 14:30:51 +07:00
to_email = to_email ,
subject = title ,
body_text = html_report ,
body_html = html_report ,
2026-03-25 13:27:48 +08:00
)
2026-04-09 14:30:51 +07:00
elif ch == "webhook" :
url = targets . get ( "webhook" , "" )
2026-03-25 13:27:48 +08:00
if url :
2026-04-09 14:30:51 +07:00
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 ,
},
)
2026-03-25 13:27:48 +08:00
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 } " )
2026-01-12 22:36:10 +08:00
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 ,
2026-04-09 14:30:51 +07:00
language : str = "en-US" ,
custom_prompt : str = "" ,
user_id : int = None ,
2026-01-12 22:36:10 +08:00
) -> None :
"""Send notification with analysis result using appropriate format for each channel."""
try :
notifier = SignalNotifier ()
2026-01-14 05:29:55 +08:00
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
2026-03-25 13:27:48 +08:00
notification_config = _resolve_notification_delivery ( effective_user_id , notification_config )
2026-01-14 05:29:55 +08:00
2026-04-09 14:30:51 +07:00
channels = notification_config . get ( "channels" ) or [ "browser" ]
targets = notification_config . get ( "targets" , {})
2026-01-14 05:29:55 +08:00
2026-04-09 14:30:51 +07:00
title = (
f "📊 Asset Monitor: { monitor_name } "
if language . startswith ( "zh" )
else f "📊 Portfolio Monitor: { monitor_name } "
)
2026-03-25 13:27:48 +08:00
if len ( title ) > 255 :
2026-04-09 14:30:51 +07:00
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 } "
)
2026-03-25 13:27:48 +08:00
if len ( error_title ) > 255 :
2026-04-09 14:30:51 +07:00
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' ) } "
)
2026-01-12 22:36:10 +08:00
for channel in channels :
try :
ch = str ( channel ) . strip () . lower ()
2026-04-09 14:30:51 +07:00
if ch == "browser" :
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
INSERT INTO qd_strategy_notifications
2026-01-14 05:29:55 +08:00
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
2026-01-14 05:58:08 +08:00
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW())
2026-01-12 22:36:10 +08:00
""" ,
2026-04-09 14:30:51 +07:00
(
effective_user_id ,
"PORTFOLIO" ,
"ai_monitor" ,
"browser" ,
error_title ,
error_msg ,
json . dumps ( result , ensure_ascii = False , default = str ),
),
2026-01-12 22:36:10 +08:00
)
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
elif ch == "telegram" :
chat_id = targets . get ( "telegram" , "" )
token_override = targets . get ( "telegram_bot_token" , "" )
2026-01-12 22:36:10 +08:00
if chat_id :
2026-04-09 14:30:51 +07:00
notifier . _notify_telegram (
chat_id = chat_id ,
text = f "<b> { error_title } </b> \n\n { error_msg } " ,
token_override = token_override ,
parse_mode = "HTML" ,
)
elif ch == "email" :
to_email = targets . get ( "email" , "" )
2026-01-12 22:36:10 +08:00
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
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Generate reports for different channels
2026-04-09 14:30:51 +07:00
html_report = result . get ( "analysis" , "" ) # This is already HTML from _build_html_report
2026-01-12 22:36:10 +08:00
# Generate Telegram-specific report if we have the data
2026-04-09 14:30:51 +07:00
telegram_report = ""
2026-01-12 22:36:10 +08:00
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
2026-04-09 14:30:51 +07:00
telegram_report = re . sub ( r "<[^>]+>" , "" , html_report )
2026-01-12 22:36:10 +08:00
if len ( telegram_report ) > 4000 :
2026-04-09 14:30:51 +07:00
telegram_report = telegram_report [: 4000 ] + "..."
2026-01-12 22:36:10 +08:00
# Send to each channel
for channel in channels :
try :
ch = str ( channel ) . strip () . lower ()
2026-04-09 14:30:51 +07:00
if ch == "browser" :
2026-01-12 22:36:10 +08:00
# Browser notification uses HTML report
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
INSERT INTO qd_strategy_notifications
2026-01-14 05:29:55 +08:00
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
2026-01-14 05:58:08 +08:00
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW())
2026-01-12 22:36:10 +08:00
""" ,
2026-04-09 14:30:51 +07:00
(
effective_user_id ,
"PORTFOLIO" ,
"ai_monitor" ,
"browser" ,
title ,
html_report ,
json . dumps ( result , ensure_ascii = False , default = str ),
),
2026-01-12 22:36:10 +08:00
)
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
elif ch == "telegram" :
chat_id = targets . get ( "telegram" , "" )
token_override = targets . get ( "telegram_bot_token" , "" )
2026-01-12 22:36:10 +08:00
if chat_id :
# Use Telegram-optimized format
notifier . _notify_telegram (
2026-04-09 14:30:51 +07:00
chat_id = chat_id , text = telegram_report , token_override = token_override , parse_mode = "HTML"
2026-01-12 22:36:10 +08:00
)
2026-04-09 14:30:51 +07:00
elif ch == "email" :
to_email = targets . get ( "email" , "" )
2026-01-12 22:36:10 +08:00
if to_email :
# Email uses full HTML report
notifier . _notify_email (
to_email = to_email ,
subject = title ,
body_text = html_report ,
2026-04-09 14:30:51 +07:00
body_html = html_report , # Send as HTML email
2026-01-12 22:36:10 +08:00
)
2026-04-09 14:30:51 +07:00
elif ch == "webhook" :
url = targets . get ( "webhook" , "" )
2026-01-12 22:36:10 +08:00
if url :
notifier . _notify_webhook (
url = url ,
payload = {
2026-04-09 14:30:51 +07:00
"type" : "portfolio_monitor" ,
"monitor_name" : monitor_name ,
"result" : result ,
"html_report" : html_report ,
},
2026-01-12 22:36:10 +08:00
)
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . warning ( f "Failed to send notification to { channel } : { e } " )
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "_send_monitor_notification failed: { e } " )
2026-03-25 13:27:48 +08:00
def run_single_monitor (
monitor_id : int ,
override_language : str = None ,
user_id : int = None ,
skip_notification : bool = False ,
) -> Dict [ str , Any ]:
2026-01-12 22:36:10 +08:00
"""Run a single monitor and return the result.
2026-03-25 13:27:48 +08:00
2026-01-12 22:36:10 +08:00
Args:
monitor_id: The monitor ID to run
override_language: Optional language override (e.g., 'zh-CN', 'en-US')
2026-01-14 05:29:55 +08:00
user_id: Optional user ID for user isolation
2026-03-25 13:27:48 +08:00
skip_notification: If True, do NOT send a notification (caller will batch-send later)
2026-01-12 22:36:10 +08:00
"""
try :
2026-01-14 05:29:55 +08:00
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
2026-03-25 13:27:48 +08:00
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
2026-01-14 05:29:55 +08:00
SELECT id, user_id, name, position_ids, monitor_type, config, notification_config
2026-01-12 22:36:10 +08:00
FROM qd_position_monitors
WHERE id = ? AND user_id = ?
""" ,
2026-04-09 14:30:51 +07:00
( monitor_id , effective_user_id ),
2026-01-12 22:36:10 +08:00
)
row = cur . fetchone ()
cur . close ()
if not row :
2026-04-09 14:30:51 +07:00
return { "success" : False , "error" : "Monitor not found" }
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
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" ), {})
2026-03-25 13:27:48 +08:00
2026-01-12 22:36:10 +08:00
if override_language :
2026-04-09 14:30:51 +07:00
config [ "language" ] = override_language
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
interval_minutes = int ( config . get ( "run_interval_minutes" ) or config . get ( "interval_minutes" ) or 60 )
2026-03-23 23:01:04 +08:00
2026-03-25 13:27:48 +08:00
if position_ids :
positions = _get_positions_for_monitor ( position_ids , user_id = monitor_user_id )
2026-04-09 14:30:51 +07:00
elif config . get ( "symbol" ):
target_sym = config [ "symbol" ] . strip () . upper ()
target_mkt = ( config . get ( "market" ) or "" ) . strip ()
2026-03-25 13:27:48 +08:00
# 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" )
2026-04-09 14:30:51 +07:00
return { "success" : False , "error" : "Symbol removed from watchlist" }
2026-03-25 13:27:48 +08:00
# Rules 1&2: match real position if exists, otherwise virtual observation
matched = _get_positions_for_monitor ( None , user_id = monitor_user_id )
positions = [
2026-04-09 14:30:51 +07:00
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 )
2026-03-25 13:27:48 +08:00
]
if not positions :
2026-04-09 14:30:51 +07:00
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 ,
}
]
2026-03-25 13:27:48 +08:00
else :
# Rule 5: no position_ids, no config.symbol → nothing to analyze
positions = []
2026-03-23 23:01:04 +08:00
2026-01-12 22:36:10 +08:00
if not positions :
2026-03-25 13:27:48 +08:00
logger . info ( f "Monitor # { monitor_id } skipped: no matching positions found" )
2026-04-09 14:30:51 +07:00
return { "success" : False , "error" : "No matching positions found" }
2026-03-25 13:27:48 +08:00
# ── Billing ──
2026-03-23 23:01:04 +08:00
billing = get_billing_service ()
symbol_count = len ( positions )
2026-04-09 14:30:51 +07:00
per_symbol_cost = billing . get_feature_cost ( "ai_analysis" )
2026-03-23 23:01:04 +08:00
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)"
)
2026-04-09 14:30:51 +07:00
return { "success" : False , "error" : f "Insufficient credits: need { total_cost } , have { user_credits } " }
2026-03-23 23:01:04 +08:00
for i in range ( symbol_count ):
pos = positions [ i ]
ok , msg = billing . check_and_consume (
user_id = monitor_user_id ,
2026-04-09 14:30:51 +07:00
feature = "ai_analysis" ,
reference_id = f "monitor_ { monitor_id } _ { pos . get ( 'symbol' , '' ) } " ,
2026-03-23 23:01:04 +08:00
)
if not ok :
2026-04-09 14:30:51 +07:00
logger . warning ( f "Monitor # { monitor_id } billing failed at symbol # { i + 1 } : { msg } " )
2026-03-23 23:01:04 +08:00
break
2026-04-09 14:30:51 +07:00
if monitor_type == "ai" :
2026-03-25 13:27:48 +08:00
result = _run_ai_analysis ( positions , config , user_id = monitor_user_id )
2026-01-12 22:36:10 +08:00
else :
2026-04-09 14:30:51 +07:00
result = { "success" : False , "error" : f "Unsupported monitor type: { monitor_type } " }
2026-03-25 13:27:48 +08:00
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
UPDATE qd_position_monitors
2026-03-25 13:27:48 +08:00
SET last_run_at = NOW(),
next_run_at = NOW() + INTERVAL ' %s minutes',
last_result = ?,
run_count = run_count + 1,
2026-01-14 05:29:55 +08:00
updated_at = NOW()
2026-01-12 22:36:10 +08:00
WHERE id = ?
""" ,
2026-04-09 14:30:51 +07:00
( interval_minutes , json . dumps ( result , ensure_ascii = False , default = str ), monitor_id ),
2026-01-12 22:36:10 +08:00
)
db . commit ()
cur . close ()
2026-03-25 13:27:48 +08:00
2026-04-09 14:30:51 +07:00
language = config . get ( "language" , "en-US" )
custom_prompt = config . get ( "prompt" , "" )
position_analyses = result . get ( "position_analyses" , [])
deduped_positions = result . get ( "positions" , positions )
2026-03-25 13:27:48 +08:00
# Attach metadata used by batch notification / history
2026-04-09 14:30:51 +07:00
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 ,
2026-03-25 13:27:48 +08:00
}
if not skip_notification :
2026-01-12 22:36:10 +08:00
_send_monitor_notification (
monitor_name = name ,
result = result ,
notification_config = notification_config ,
2026-03-25 13:27:48 +08:00
positions = deduped_positions ,
2026-01-12 22:36:10 +08:00
position_analyses = position_analyses ,
language = language ,
2026-01-14 05:29:55 +08:00
custom_prompt = custom_prompt ,
2026-03-25 13:27:48 +08:00
user_id = monitor_user_id ,
2026-01-12 22:36:10 +08:00
)
2026-03-25 13:27:48 +08:00
2026-01-12 22:36:10 +08:00
return result
except Exception as e :
logger . error ( f "run_single_monitor failed: { e } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return { "success" : False , "error" : str ( e )}
2026-01-12 22:36:10 +08:00
def _check_position_alerts ():
"""Check all active alerts and trigger notifications if conditions are met."""
2026-01-14 05:29:55 +08:00
from datetime import datetime , timezone
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
try :
kline_service = KlineService ()
notifier = SignalNotifier ()
2026-01-14 05:29:55 +08:00
now = datetime . now ( timezone . utc )
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
2026-01-14 05:29:55 +08:00
# Get active alerts for all users that haven't been triggered (or can repeat)
2026-01-12 22:36:10 +08:00
cur . execute (
"""
2026-01-14 05:29:55 +08:00
SELECT a.id, a.user_id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold,
2026-01-12 22:36:10 +08:00
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
2026-01-14 05:29:55 +08:00
WHERE a.is_active = 1
"""
2026-01-12 22:36:10 +08:00
)
alerts = cur . fetchall () or []
cur . close ()
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
for alert in alerts :
try :
2026-04-09 14:30:51 +07:00
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" ), {})
2026-01-12 22:36:10 +08:00
# Check if we can trigger (not triggered yet, or repeat interval passed)
can_trigger = not is_triggered
2026-01-14 05:29:55 +08:00
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 :
2026-01-12 22:36:10 +08:00
can_trigger = True
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
if not can_trigger :
continue
2026-04-09 14:30:51 +07:00
2026-01-13 00:21:33 +08:00
# Get current price (use realtime price API)
2026-01-12 22:36:10 +08:00
current_price = 0
try :
2026-01-13 00:21:33 +08:00
price_data = kline_service . get_realtime_price ( market , symbol )
2026-04-09 14:30:51 +07:00
current_price = float ( price_data . get ( "price" ) or 0 )
2026-01-12 22:36:10 +08:00
except Exception :
continue
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
if current_price <= 0 :
continue
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
triggered = False
alert_message = ""
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Get language from notification_config (saved when alert was created)
2026-04-09 14:30:51 +07:00
alert_language = notification_config . get ( "language" , "en-US" )
if alert_type == "price_above" :
2026-01-12 22:36:10 +08:00
if current_price >= threshold :
triggered = True
alert_message = _get_alert_message (
2026-04-09 14:30:51 +07:00
"price_above" ,
alert_language ,
symbol = symbol ,
current_price = current_price ,
threshold = threshold ,
2026-01-12 22:36:10 +08:00
)
2026-04-09 14:30:51 +07:00
elif alert_type == "price_below" :
2026-01-12 22:36:10 +08:00
if current_price <= threshold :
triggered = True
alert_message = _get_alert_message (
2026-04-09 14:30:51 +07:00
"price_below" ,
alert_language ,
symbol = symbol ,
current_price = current_price ,
threshold = threshold ,
2026-01-12 22:36:10 +08:00
)
2026-04-09 14:30:51 +07:00
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"
2026-01-12 22:36:10 +08:00
if entry_price > 0 and quantity > 0 :
2026-04-09 14:30:51 +07:00
if side == "long" :
2026-01-12 22:36:10 +08:00
pnl = ( current_price - entry_price ) * quantity
else :
pnl = ( entry_price - current_price ) * quantity
pnl_percent = pnl / ( entry_price * quantity ) * 100
2026-04-09 14:30:51 +07:00
if alert_type == "pnl_above" and pnl_percent >= threshold :
2026-01-12 22:36:10 +08:00
triggered = True
alert_message = _get_alert_message (
2026-04-09 14:30:51 +07:00
"pnl_above" , alert_language , symbol = symbol , pnl_percent = pnl_percent , threshold = threshold
2026-01-12 22:36:10 +08:00
)
2026-04-09 14:30:51 +07:00
elif alert_type == "pnl_below" and pnl_percent <= threshold :
2026-01-12 22:36:10 +08:00
triggered = True
alert_message = _get_alert_message (
2026-04-09 14:30:51 +07:00
"pnl_below" , alert_language , symbol = symbol , pnl_percent = pnl_percent , threshold = threshold
2026-01-12 22:36:10 +08:00
)
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
if triggered :
logger . info ( f "Alert # { alert_id } triggered: { alert_message } " )
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Update alert status
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
UPDATE qd_position_alerts
2026-01-14 05:29:55 +08:00
SET is_triggered = 1, last_triggered_at = NOW(), trigger_count = trigger_count + 1, updated_at = NOW()
2026-01-12 22:36:10 +08:00
WHERE id = ?
""" ,
2026-04-09 14:30:51 +07:00
( alert_id ,),
2026-01-12 22:36:10 +08:00
)
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
2026-04-06 16:47:36 +07:00
# Send notification (merge personal center notification configuration, consistent with asset monitoring tasks)
2026-03-25 13:27:48 +08:00
resolved = _resolve_notification_delivery ( alert_user_id , notification_config )
2026-04-09 14:30:51 +07:00
channels = resolved . get ( "channels" ) or [ "browser" ]
targets = resolved . get ( "targets" , {})
2026-01-12 22:36:10 +08:00
alert_title = _get_alert_title ( alert_language )
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
for channel in channels :
try :
ch = str ( channel ) . strip () . lower ()
2026-04-09 14:30:51 +07:00
if ch == "browser" :
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
INSERT INTO qd_strategy_notifications
2026-01-14 05:29:55 +08:00
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
2026-01-14 05:58:08 +08:00
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW())
2026-01-12 22:36:10 +08:00
""" ,
2026-04-09 14:30:51 +07:00
(
alert_user_id ,
symbol ,
"price_alert" ,
"browser" ,
alert_title ,
alert_message ,
json . dumps (
{ "alert_id" : alert_id , "alert_type" : alert_type }, ensure_ascii = False
),
),
2026-01-12 22:36:10 +08:00
)
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
elif ch == "telegram" :
chat_id = targets . get ( "telegram" , "" )
token_override = targets . get ( "telegram_bot_token" , "" )
2026-01-12 22:36:10 +08:00
if chat_id :
2026-04-09 14:30:51 +07:00
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" , "" )
2026-01-12 22:36:10 +08:00
if to_email :
2026-04-09 14:30:51 +07:00
notifier . _notify_email (
to_email = to_email , subject = alert_title , body_text = alert_message
)
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . warning ( f "Failed to send alert notification: { e } " )
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . warning ( f "Error processing alert: { e } " )
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "_check_position_alerts failed: { e } " )
2026-04-09 14:30:51 +07:00
def notify_strategy_signal_for_positions (
market : str , symbol : str , signal_type : str , signal_detail : str , user_id : int = None
):
2026-01-12 22:36:10 +08:00
"""
2026-04-09 14:30:51 +07:00
Called when a strategy signal is triggered.
2026-01-12 22:36:10 +08:00
Check if user has manual positions in this symbol and send notification.
"""
try :
2026-04-09 14:30:51 +07:00
symbol = ( symbol or "" ) . strip () . upper ()
2026-01-12 22:36:10 +08:00
if not symbol :
return
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
2026-01-14 05:29:55 +08:00
# 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 = ?
""" ,
2026-04-09 14:30:51 +07:00
( user_id , symbol ),
2026-01-14 05:29:55 +08:00
)
else :
cur . execute (
"""
SELECT id, user_id, market, symbol, name, side, quantity, entry_price, group_name
FROM qd_manual_positions
WHERE symbol = ?
""" ,
2026-04-09 14:30:51 +07:00
( symbol ,),
2026-01-14 05:29:55 +08:00
)
2026-01-12 22:36:10 +08:00
positions = cur . fetchall () or []
cur . close ()
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
if not positions :
return
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# User has positions in this symbol - send notification
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
for pos in positions :
2026-04-09 14:30:51 +07:00
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 )
2026-04-06 16:47:36 +07:00
title = f "🔗Strategy signal linkage: { pos_name } "
message = f """The strategy emits { signal_type } signal!
2026-01-12 22:36:10 +08:00
2026-04-06 16:47:36 +07:00
Target: { market } / { symbol }
Your position: { pos_side . upper () } { quantity } @ { entry_price : .4f }
2026-01-12 22:36:10 +08:00
2026-04-06 16:47:36 +07:00
Signal details:
2026-01-12 22:36:10 +08:00
{ signal_detail }
2026-04-06 16:47:36 +07:00
Please check whether your position needs adjustment. """
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Save browser notification
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
INSERT INTO qd_strategy_notifications
2026-01-14 05:29:55 +08:00
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
2026-01-14 05:58:08 +08:00
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NOW())
2026-01-12 22:36:10 +08:00
""" ,
2026-04-09 14:30:51 +07:00
(
pos_user_id ,
symbol ,
"strategy_linkage" ,
"browser" ,
title ,
message ,
json . dumps ({ "signal_type" : signal_type }, ensure_ascii = False ),
),
2026-01-12 22:36:10 +08:00
)
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
logger . info ( f "Strategy signal linkage: notified { len ( positions ) } position(s) for { symbol } " )
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "notify_strategy_signal_for_positions failed: { e } " )
def _monitor_loop ():
2026-03-25 13:27:48 +08:00
"""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.
"""
2026-01-12 22:36:10 +08:00
logger . info ( "Portfolio monitor background loop started" )
2026-03-25 13:27:48 +08:00
2026-01-12 22:36:10 +08:00
while not _stop_event . is_set ():
try :
_check_position_alerts ()
2026-03-25 13:27:48 +08:00
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
2026-01-14 05:29:55 +08:00
SELECT id, user_id FROM qd_position_monitors
WHERE is_active = 1 AND next_run_at <= NOW()
2026-01-12 22:36:10 +08:00
ORDER BY next_run_at ASC
2026-03-25 13:27:48 +08:00
LIMIT 20
2026-01-14 05:29:55 +08:00
"""
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
cur . close ()
2026-03-25 13:27:48 +08:00
# Collect results per user
user_results : Dict [ int , List [ Dict [ str , Any ]]] = {}
2026-01-12 22:36:10 +08:00
for row in rows :
if _stop_event . is_set ():
break
2026-04-09 14:30:51 +07:00
monitor_id = row . get ( "id" )
monitor_user_id = int ( row . get ( "user_id" ) or 1 )
2026-03-25 13:27:48 +08:00
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 :
2026-04-09 14:30:51 +07:00
meta = results [ 0 ] . get ( "_meta" , {})
2026-03-25 13:27:48 +08:00
_send_monitor_notification (
2026-04-09 14:30:51 +07:00
monitor_name = meta . get ( "monitor_name" , "?" ),
2026-03-25 13:27:48 +08:00
result = results [ 0 ],
2026-04-09 14:30:51 +07:00
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" , "" ),
2026-03-25 13:27:48 +08:00
user_id = uid ,
)
else :
_send_batch_notification ( uid , results )
except Exception as e :
logger . error ( f "Batch notification for user # { uid } failed: { e } " )
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "Monitor loop error: { e } " )
2026-03-25 13:27:48 +08:00
2026-01-12 22:36:10 +08:00
_stop_event . wait ( 30 )
2026-03-25 13:27:48 +08:00
2026-01-12 22:36:10 +08:00
logger . info ( "Portfolio monitor background loop stopped" )
def start_monitor_service ():
"""Start the background monitor service."""
global _monitor_thread
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
if _monitor_thread and _monitor_thread . is_alive ():
logger . info ( "Portfolio monitor service already running" )
return
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
_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
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
_stop_event . set ()
if _monitor_thread :
_monitor_thread . join ( timeout = 5 )
_monitor_thread = None
logger . info ( "Portfolio monitor service stopped" )