feat: Multi-user system with PostgreSQL - WIP temporary save
This commit is contained in:
@@ -79,10 +79,11 @@ def _safe_json_loads(value, default=None):
|
||||
return default
|
||||
|
||||
|
||||
def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str, Any]]:
|
||||
"""Get positions, optionally filtered by IDs."""
|
||||
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()
|
||||
@@ -94,7 +95,7 @@ def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str,
|
||||
FROM qd_manual_positions
|
||||
WHERE user_id = ? AND id IN ({placeholders})
|
||||
""",
|
||||
[DEFAULT_USER_ID] + list(position_ids)
|
||||
[effective_user_id] + list(position_ids)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
@@ -103,7 +104,7 @@ def _get_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str,
|
||||
FROM qd_manual_positions
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
(effective_user_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -726,15 +727,17 @@ def _send_monitor_notification(
|
||||
positions: List[Dict[str, Any]] = None,
|
||||
position_analyses: List[Dict[str, Any]] = None,
|
||||
language: str = 'en-US',
|
||||
custom_prompt: str = ''
|
||||
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
|
||||
|
||||
channels = notification_config.get('channels', ['browser'])
|
||||
targets = notification_config.get('targets', {})
|
||||
|
||||
|
||||
title = f"📊 资产监测: {monitor_name}" if language.startswith('zh') else f"📊 Portfolio Monitor: {monitor_name}"
|
||||
|
||||
if not result.get('success'):
|
||||
@@ -751,10 +754,10 @@ def _send_monitor_notification(
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_notifications
|
||||
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(0, 'PORTFOLIO', 'ai_monitor', 'browser', error_title, error_msg,
|
||||
(effective_user_id, 0, 'PORTFOLIO', 'ai_monitor', 'browser', error_title, error_msg,
|
||||
json.dumps(result, ensure_ascii=False), now)
|
||||
)
|
||||
db.commit()
|
||||
@@ -798,10 +801,10 @@ def _send_monitor_notification(
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_notifications
|
||||
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(0, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report,
|
||||
(effective_user_id, 0, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report,
|
||||
json.dumps(result, ensure_ascii=False), now)
|
||||
)
|
||||
db.commit()
|
||||
@@ -848,24 +851,28 @@ def _send_monitor_notification(
|
||||
logger.error(f"_send_monitor_notification failed: {e}")
|
||||
|
||||
|
||||
def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[str, Any]:
|
||||
def run_single_monitor(monitor_id: int, override_language: str = None, user_id: int = None) -> Dict[str, Any]:
|
||||
"""Run a single monitor and return the result.
|
||||
|
||||
Args:
|
||||
monitor_id: The monitor ID to run
|
||||
override_language: Optional language override (e.g., 'zh-CN', 'en-US')
|
||||
If provided, will override the language in monitor config
|
||||
user_id: Optional user ID for user isolation
|
||||
"""
|
||||
try:
|
||||
# Use provided user_id or default
|
||||
effective_user_id = user_id if user_id is not None else DEFAULT_USER_ID
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, position_ids, monitor_type, config, notification_config
|
||||
SELECT id, user_id, name, position_ids, monitor_type, config, notification_config
|
||||
FROM qd_position_monitors
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(monitor_id, DEFAULT_USER_ID)
|
||||
(monitor_id, effective_user_id)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
@@ -873,6 +880,7 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
|
||||
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'
|
||||
@@ -883,8 +891,8 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
|
||||
if override_language:
|
||||
config['language'] = override_language
|
||||
|
||||
# Get positions
|
||||
positions = _get_positions_for_monitor(position_ids if position_ids else None)
|
||||
# Get positions for this user
|
||||
positions = _get_positions_for_monitor(position_ids if position_ids else None, user_id=monitor_user_id)
|
||||
|
||||
if not positions:
|
||||
return {'success': False, 'error': 'No positions to analyze'}
|
||||
@@ -897,19 +905,21 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
|
||||
result = {'success': False, 'error': f'Unsupported monitor type: {monitor_type}'}
|
||||
|
||||
# Update monitor record
|
||||
now = _now_ts()
|
||||
interval_minutes = int(config.get('interval_minutes') or 60)
|
||||
next_run_at = now + (interval_minutes * 60)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_position_monitors
|
||||
SET last_run_at = ?, next_run_at = ?, last_result = ?, run_count = run_count + 1, updated_at = ?
|
||||
SET last_run_at = NOW(),
|
||||
next_run_at = NOW() + INTERVAL '%s minutes',
|
||||
last_result = ?,
|
||||
run_count = run_count + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now, next_run_at, json.dumps(result, ensure_ascii=False), now, monitor_id)
|
||||
(interval_minutes, json.dumps(result, ensure_ascii=False), monitor_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -926,7 +936,8 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
|
||||
positions=positions,
|
||||
position_analyses=position_analyses,
|
||||
language=language,
|
||||
custom_prompt=custom_prompt
|
||||
custom_prompt=custom_prompt,
|
||||
user_id=monitor_user_id
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -938,24 +949,24 @@ def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[s
|
||||
|
||||
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 = _now_ts()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
# Get active alerts that haven't been triggered (or can repeat)
|
||||
# Get active alerts for all users that haven't been triggered (or can repeat)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold,
|
||||
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.user_id = ? AND a.is_active = 1
|
||||
""",
|
||||
(DEFAULT_USER_ID,)
|
||||
WHERE a.is_active = 1
|
||||
"""
|
||||
)
|
||||
alerts = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -963,19 +974,24 @@ def _check_position_alerts():
|
||||
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') or 0
|
||||
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:
|
||||
if now - last_triggered_at >= repeat_interval:
|
||||
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:
|
||||
@@ -1048,10 +1064,10 @@ def _check_position_alerts():
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_position_alerts
|
||||
SET is_triggered = 1, last_triggered_at = ?, trigger_count = trigger_count + 1, updated_at = ?
|
||||
SET is_triggered = 1, last_triggered_at = NOW(), trigger_count = trigger_count + 1, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now, now, alert_id)
|
||||
(alert_id,)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
@@ -1070,10 +1086,10 @@ def _check_position_alerts():
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_notifications
|
||||
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(0, symbol, 'price_alert', 'browser', alert_title, alert_message,
|
||||
(alert_user_id, 0, symbol, 'price_alert', 'browser', alert_title, alert_message,
|
||||
json.dumps({'alert_id': alert_id, 'alert_type': alert_type}, ensure_ascii=False), now)
|
||||
)
|
||||
db.commit()
|
||||
@@ -1096,7 +1112,7 @@ def _check_position_alerts():
|
||||
logger.error(f"_check_position_alerts failed: {e}")
|
||||
|
||||
|
||||
def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: str, signal_detail: str):
|
||||
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.
|
||||
@@ -1108,14 +1124,25 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type:
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, market, symbol, name, side, quantity, entry_price, group_name
|
||||
FROM qd_manual_positions
|
||||
WHERE user_id = ? AND symbol = ?
|
||||
""",
|
||||
(DEFAULT_USER_ID, symbol)
|
||||
)
|
||||
# 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()
|
||||
|
||||
@@ -1127,6 +1154,7 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type:
|
||||
now = _now_ts()
|
||||
|
||||
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)
|
||||
@@ -1149,10 +1177,10 @@ def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_notifications
|
||||
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(0, symbol, 'strategy_linkage', 'browser', title, message,
|
||||
(pos_user_id, 0, symbol, 'strategy_linkage', 'browser', title, message,
|
||||
json.dumps({'signal_type': signal_type}, ensure_ascii=False), now)
|
||||
)
|
||||
db.commit()
|
||||
@@ -1170,22 +1198,19 @@ def _monitor_loop():
|
||||
|
||||
while not _stop_event.is_set():
|
||||
try:
|
||||
now = _now_ts()
|
||||
|
||||
# 1. Check position alerts (price/pnl alerts)
|
||||
# 1. Check position alerts (price/pnl alerts) for all users
|
||||
_check_position_alerts()
|
||||
|
||||
# 2. Find AI monitors that are due
|
||||
# 2. Find AI monitors that are due for all users
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM qd_position_monitors
|
||||
WHERE user_id = ? AND is_active = 1 AND next_run_at <= ?
|
||||
SELECT id, user_id FROM qd_position_monitors
|
||||
WHERE is_active = 1 AND next_run_at <= NOW()
|
||||
ORDER BY next_run_at ASC
|
||||
LIMIT 10
|
||||
""",
|
||||
(DEFAULT_USER_ID, now)
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
@@ -1194,10 +1219,11 @@ def _monitor_loop():
|
||||
if _stop_event.is_set():
|
||||
break
|
||||
monitor_id = row.get('id')
|
||||
monitor_user_id = int(row.get('user_id') or 1)
|
||||
if monitor_id:
|
||||
logger.info(f"Running due monitor #{monitor_id}")
|
||||
logger.info(f"Running due monitor #{monitor_id} for user #{monitor_user_id}")
|
||||
try:
|
||||
run_single_monitor(monitor_id)
|
||||
run_single_monitor(monitor_id, user_id=monitor_user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Monitor #{monitor_id} execution failed: {e}")
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user