feat: add Portfolio Management module with AI monitoring

This commit is contained in:
TIANHE
2026-01-12 22:36:10 +08:00
parent bb45248982
commit ec56b59530
24 changed files with 7940 additions and 90 deletions
+25
View File
@@ -43,6 +43,30 @@ def get_reflection_worker():
return _reflection_worker
def start_portfolio_monitor():
"""Start the portfolio monitor service if enabled.
To enable it, set ENABLE_PORTFOLIO_MONITOR=true.
"""
import os
enabled = os.getenv("ENABLE_PORTFOLIO_MONITOR", "true").lower() == "true"
if not enabled:
logger.info("Portfolio monitor is disabled. Set ENABLE_PORTFOLIO_MONITOR=true to enable.")
return
# Avoid running twice with Flask reloader
debug = os.getenv("PYTHON_API_DEBUG", "false").lower() == "true"
if debug:
if os.environ.get("WERKZEUG_RUN_MAIN") != "true":
return
try:
from app.services.portfolio_monitor import start_monitor_service
start_monitor_service()
except Exception as e:
logger.error(f"Failed to start portfolio monitor: {e}")
def start_reflection_worker():
"""
Start the reflection worker if enabled.
@@ -163,6 +187,7 @@ def create_app(config_name='default'):
with app.app_context():
start_pending_order_worker()
start_reflection_worker()
start_portfolio_monitor()
restore_running_strategies()
return app
@@ -18,6 +18,7 @@ def register_routes(app: Flask):
from app.routes.indicator import indicator_bp
from app.routes.dashboard import dashboard_bp
from app.routes.settings import settings_bp
from app.routes.portfolio import portfolio_bp
app.register_blueprint(health_bp)
app.register_blueprint(auth_bp, url_prefix='/api/user') # 兼容前端 /api/user/login
@@ -31,4 +32,5 @@ def register_routes(app: Flask):
app.register_blueprint(credentials_bp, url_prefix='/api/credentials')
app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard')
app.register_blueprint(settings_bp, url_prefix='/api/settings')
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
+991
View File
@@ -0,0 +1,991 @@
"""
Portfolio API routes (local-only).
Manages manual positions (user's existing holdings) and AI monitoring tasks.
"""
from flask import Blueprint, request, jsonify
import json
import traceback
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from app.services.kline import KlineService
from app.utils.logger import get_logger
from app.utils.cache import CacheManager
from app.utils.db import get_db_connection
from app.services.symbol_name import resolve_symbol_name
from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
logger = get_logger(__name__)
portfolio_bp = Blueprint('portfolio', __name__)
kline_service = KlineService()
cache = CacheManager()
# Thread pool for parallel price fetching
executor = ThreadPoolExecutor(max_workers=10)
DEFAULT_USER_ID = 1
def _now_ts() -> int:
return int(time.time())
def _normalize_symbol(symbol: str) -> str:
return (symbol or '').strip().upper()
def _safe_json_loads(value, default=None):
"""Safely parse JSON string."""
if default is None:
default = {}
if isinstance(value, dict):
return value
if isinstance(value, list):
return value
if isinstance(value, str) and value.strip():
try:
return json.loads(value)
except Exception:
return default
return default
def _get_single_price(market: str, symbol: str) -> dict:
"""Get price data for a single symbol."""
try:
cache_key = f"portfolio_price:{market}:{symbol}"
cached_data = cache.get(cache_key)
if cached_data:
return {
'market': market,
'symbol': symbol,
'price': cached_data.get('price', 0),
'change': cached_data.get('change', 0),
'changePercent': cached_data.get('changePercent', 0)
}
klines = kline_service.get_kline(market, symbol, '1D', 2)
if klines and len(klines) > 0:
latest = klines[-1]
prev_close = klines[-2]['close'] if len(klines) > 1 else latest.get('open', 0)
current_price = latest.get('close', 0)
change = round(current_price - prev_close, 4) if prev_close else 0
change_percent = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0
result = {
'market': market,
'symbol': symbol,
'price': current_price,
'change': change,
'changePercent': change_percent
}
cache.set(cache_key, result, 60)
return result
else:
return {
'market': market,
'symbol': symbol,
'price': 0,
'change': 0,
'changePercent': 0
}
except Exception as e:
logger.error(f"Failed to fetch price {market}:{symbol} - {str(e)}")
return {
'market': market,
'symbol': symbol,
'price': 0,
'change': 0,
'changePercent': 0
}
# ==================== Position CRUD ====================
@portfolio_bp.route('/positions', methods=['GET'])
def get_positions():
"""Get all manual positions with current prices."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at
FROM qd_manual_positions
WHERE user_id = ?
ORDER BY id DESC
""",
(DEFAULT_USER_ID,)
)
rows = cur.fetchall() or []
cur.close()
positions = []
price_futures = {}
# Prepare positions and submit price fetch tasks
for row in rows:
pos = {
'id': row.get('id'),
'market': row.get('market'),
'symbol': row.get('symbol'),
'name': row.get('name') or row.get('symbol'),
'side': row.get('side') or 'long',
'quantity': float(row.get('quantity') or 0),
'entry_price': float(row.get('entry_price') or 0),
'entry_time': row.get('entry_time'),
'notes': row.get('notes') or '',
'tags': _safe_json_loads(row.get('tags'), []),
'group_name': row.get('group_name') or '',
'created_at': row.get('created_at'),
'updated_at': row.get('updated_at'),
# Will be filled later
'current_price': 0,
'price_change': 0,
'price_change_percent': 0,
'market_value': 0,
'cost_value': 0,
'pnl': 0,
'pnl_percent': 0
}
positions.append(pos)
# Submit price fetch task
market = row.get('market')
symbol = row.get('symbol')
if market and symbol:
key = f"{market}:{symbol}"
if key not in price_futures:
future = executor.submit(_get_single_price, market, symbol)
price_futures[key] = future
# Collect price results
price_map = {}
for key, future in price_futures.items():
try:
result = future.result(timeout=10)
price_map[key] = result
except Exception as e:
logger.warning(f"Price fetch failed for {key}: {e}")
# Calculate PnL for each position
for pos in positions:
key = f"{pos['market']}:{pos['symbol']}"
price_data = price_map.get(key, {})
current_price = float(price_data.get('price') or 0)
entry_price = pos['entry_price']
quantity = pos['quantity']
side = pos['side']
pos['current_price'] = current_price
pos['price_change'] = price_data.get('change', 0)
pos['price_change_percent'] = price_data.get('changePercent', 0)
# Calculate values
pos['market_value'] = current_price * quantity
pos['cost_value'] = entry_price * quantity
# Calculate PnL based on side
if side == 'long':
pos['pnl'] = (current_price - entry_price) * quantity
else: # short
pos['pnl'] = (entry_price - current_price) * quantity
if pos['cost_value'] > 0:
pos['pnl_percent'] = round(pos['pnl'] / pos['cost_value'] * 100, 2)
return jsonify({'code': 1, 'msg': 'success', 'data': positions})
except Exception as e:
logger.error(f"get_positions failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
@portfolio_bp.route('/positions', methods=['POST'])
def add_position():
"""Add a new manual position."""
try:
data = request.get_json() or {}
market = (data.get('market') or '').strip()
symbol = _normalize_symbol(data.get('symbol'))
name_in = (data.get('name') or '').strip()
side = (data.get('side') or 'long').strip().lower()
quantity = float(data.get('quantity') or 0)
entry_price = float(data.get('entry_price') or 0)
entry_time = data.get('entry_time') or _now_ts()
notes = (data.get('notes') or '').strip()
tags = data.get('tags') or []
group_name = (data.get('group_name') or '').strip()
if not market or not symbol:
return jsonify({'code': 0, 'msg': 'Missing market or symbol', 'data': None}), 400
if quantity <= 0:
return jsonify({'code': 0, 'msg': 'Quantity must be positive', 'data': None}), 400
if entry_price <= 0:
return jsonify({'code': 0, 'msg': 'Entry price must be positive', 'data': None}), 400
if side not in ('long', 'short'):
side = 'long'
# Resolve display name
resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol)
name = name_in or resolved or symbol
tags_json = json.dumps(tags if isinstance(tags, list) else [], ensure_ascii=False)
now = _now_ts()
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_manual_positions
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, market, symbol, side) DO UPDATE SET
name = excluded.name,
quantity = excluded.quantity,
entry_price = excluded.entry_price,
entry_time = excluded.entry_time,
notes = excluded.notes,
tags = excluded.tags,
group_name = excluded.group_name,
updated_at = excluded.updated_at
""",
(DEFAULT_USER_ID, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name, now, now)
)
position_id = cur.lastrowid
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'id': position_id}})
except Exception as e:
logger.error(f"add_position failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@portfolio_bp.route('/positions/<int:position_id>', methods=['PUT'])
def update_position(position_id):
"""Update an existing position."""
try:
data = request.get_json() or {}
updates = []
params = []
if 'name' in data:
updates.append('name = ?')
params.append((data.get('name') or '').strip())
if 'quantity' in data:
quantity = float(data.get('quantity') or 0)
if quantity <= 0:
return jsonify({'code': 0, 'msg': 'Quantity must be positive', 'data': None}), 400
updates.append('quantity = ?')
params.append(quantity)
if 'entry_price' in data:
entry_price = float(data.get('entry_price') or 0)
if entry_price <= 0:
return jsonify({'code': 0, 'msg': 'Entry price must be positive', 'data': None}), 400
updates.append('entry_price = ?')
params.append(entry_price)
if 'entry_time' in data:
updates.append('entry_time = ?')
params.append(data.get('entry_time'))
if 'notes' in data:
updates.append('notes = ?')
params.append((data.get('notes') or '').strip())
if 'tags' in data:
tags = data.get('tags') or []
updates.append('tags = ?')
params.append(json.dumps(tags if isinstance(tags, list) else [], ensure_ascii=False))
if 'group_name' in data:
updates.append('group_name = ?')
params.append((data.get('group_name') or '').strip())
if not updates:
return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400
updates.append('updated_at = ?')
params.append(_now_ts())
params.append(position_id)
params.append(DEFAULT_USER_ID)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
f"UPDATE qd_manual_positions SET {', '.join(updates)} WHERE id = ? AND user_id = ?",
params
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"update_position failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@portfolio_bp.route('/positions/<int:position_id>', methods=['DELETE'])
def delete_position(position_id):
"""Delete a position."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"DELETE FROM qd_manual_positions WHERE id = ? AND user_id = ?",
(position_id, DEFAULT_USER_ID)
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"delete_position failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@portfolio_bp.route('/summary', methods=['GET'])
def get_portfolio_summary():
"""Get portfolio summary with total value, PnL, and market distribution."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, market, symbol, side, quantity, entry_price
FROM qd_manual_positions
WHERE user_id = ?
""",
(DEFAULT_USER_ID,)
)
rows = cur.fetchall() or []
cur.close()
if not rows:
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'total_cost': 0,
'total_market_value': 0,
'total_pnl': 0,
'total_pnl_percent': 0,
'position_count': 0,
'market_distribution': []
}
})
# Fetch prices in parallel
price_futures = {}
for row in rows:
market = row.get('market')
symbol = row.get('symbol')
key = f"{market}:{symbol}"
if key not in price_futures:
future = executor.submit(_get_single_price, market, symbol)
price_futures[key] = future
price_map = {}
for key, future in price_futures.items():
try:
result = future.result(timeout=10)
price_map[key] = result
except Exception:
pass
# Calculate totals
total_cost = 0
total_market_value = 0
total_pnl = 0
market_values = {} # {market: market_value}
for row in rows:
market = row.get('market')
symbol = row.get('symbol')
side = row.get('side') or 'long'
quantity = float(row.get('quantity') or 0)
entry_price = float(row.get('entry_price') or 0)
key = f"{market}:{symbol}"
price_data = price_map.get(key, {})
current_price = float(price_data.get('price') or 0)
cost = entry_price * quantity
market_val = current_price * quantity
if side == 'long':
pnl = (current_price - entry_price) * quantity
else:
pnl = (entry_price - current_price) * quantity
total_cost += cost
total_market_value += market_val
total_pnl += pnl
# Market distribution
if market not in market_values:
market_values[market] = 0
market_values[market] += market_val
total_pnl_percent = round(total_pnl / total_cost * 100, 2) if total_cost > 0 else 0
# Build market distribution
market_distribution = []
for market, value in market_values.items():
percent = round(value / total_market_value * 100, 2) if total_market_value > 0 else 0
market_distribution.append({
'market': market,
'value': round(value, 2),
'percent': percent
})
market_distribution.sort(key=lambda x: x['value'], reverse=True)
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'total_cost': round(total_cost, 2),
'total_market_value': round(total_market_value, 2),
'total_pnl': round(total_pnl, 2),
'total_pnl_percent': total_pnl_percent,
'position_count': len(rows),
'market_distribution': market_distribution
}
})
except Exception as e:
logger.error(f"get_portfolio_summary failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==================== Monitor CRUD ====================
@portfolio_bp.route('/monitors', methods=['GET'])
def get_monitors():
"""Get all position monitors."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, name, position_ids, monitor_type, config, notification_config,
is_active, last_run_at, next_run_at, last_result, run_count, created_at, updated_at
FROM qd_position_monitors
WHERE user_id = ?
ORDER BY id DESC
""",
(DEFAULT_USER_ID,)
)
rows = cur.fetchall() or []
cur.close()
monitors = []
for row in rows:
monitors.append({
'id': row.get('id'),
'name': row.get('name') or '',
'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'), {}),
'is_active': bool(row.get('is_active')),
'last_run_at': row.get('last_run_at'),
'next_run_at': row.get('next_run_at'),
'last_result': _safe_json_loads(row.get('last_result'), {}),
'run_count': row.get('run_count') or 0,
'created_at': row.get('created_at'),
'updated_at': row.get('updated_at')
})
return jsonify({'code': 1, 'msg': 'success', 'data': monitors})
except Exception as e:
logger.error(f"get_monitors failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
@portfolio_bp.route('/monitors', methods=['POST'])
def add_monitor():
"""Add a new position monitor."""
try:
data = request.get_json() or {}
name = (data.get('name') or '').strip()
position_ids = data.get('position_ids') or []
monitor_type = (data.get('monitor_type') or 'ai').strip()
config = data.get('config') or {}
notification_config = data.get('notification_config') or {}
is_active = bool(data.get('is_active', True))
if not name:
return jsonify({'code': 0, 'msg': 'Monitor name is required', 'data': None}), 400
if monitor_type not in ('ai', 'price_alert', 'pnl_alert'):
monitor_type = 'ai'
# Calculate next_run_at based on interval
now = _now_ts()
interval_minutes = int(config.get('interval_minutes') or 60)
next_run_at = now + (interval_minutes * 60)
position_ids_json = json.dumps(position_ids if isinstance(position_ids, list) else [], ensure_ascii=False)
config_json = json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False)
notification_config_json = json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_position_monitors
(user_id, name, position_ids, monitor_type, config, notification_config, is_active, next_run_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(DEFAULT_USER_ID, name, position_ids_json, monitor_type, config_json, notification_config_json,
1 if is_active else 0, next_run_at, now, now)
)
monitor_id = cur.lastrowid
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'id': monitor_id}})
except Exception as e:
logger.error(f"add_monitor failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@portfolio_bp.route('/monitors/<int:monitor_id>', methods=['PUT'])
def update_monitor(monitor_id):
"""Update an existing monitor."""
try:
data = request.get_json() or {}
updates = []
params = []
if 'name' in data:
updates.append('name = ?')
params.append((data.get('name') or '').strip())
if 'position_ids' in data:
position_ids = data.get('position_ids') or []
updates.append('position_ids = ?')
params.append(json.dumps(position_ids if isinstance(position_ids, list) else [], ensure_ascii=False))
if 'monitor_type' in data:
updates.append('monitor_type = ?')
params.append((data.get('monitor_type') or 'ai').strip())
if 'config' in data:
config = data.get('config') or {}
updates.append('config = ?')
params.append(json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False))
# Recalculate next_run_at if interval changed
interval_minutes = int(config.get('interval_minutes') or 60)
updates.append('next_run_at = ?')
params.append(_now_ts() + (interval_minutes * 60))
if 'notification_config' in data:
notification_config = data.get('notification_config') or {}
updates.append('notification_config = ?')
params.append(json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False))
if 'is_active' in data:
updates.append('is_active = ?')
params.append(1 if data.get('is_active') else 0)
if not updates:
return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400
updates.append('updated_at = ?')
params.append(_now_ts())
params.append(monitor_id)
params.append(DEFAULT_USER_ID)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
f"UPDATE qd_position_monitors SET {', '.join(updates)} WHERE id = ? AND user_id = ?",
params
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"update_monitor failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@portfolio_bp.route('/monitors/<int:monitor_id>', methods=['DELETE'])
def delete_monitor(monitor_id):
"""Delete a monitor."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"DELETE FROM qd_position_monitors WHERE id = ? AND user_id = ?",
(monitor_id, DEFAULT_USER_ID)
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"delete_monitor failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@portfolio_bp.route('/monitors/<int:monitor_id>/run', methods=['POST'])
def run_monitor_now(monitor_id):
"""Manually trigger a monitor to run immediately."""
try:
from app.services.portfolio_monitor import run_single_monitor
# Get language from request body or Accept-Language header
data = request.get_json(force=True, silent=True) or {}
language = data.get('language')
# Fallback to Accept-Language header
if not language:
accept_lang = request.headers.get('Accept-Language', '')
if 'zh' in accept_lang.lower():
language = 'en-US'
else:
language = 'en-US'
result = run_single_monitor(monitor_id, override_language=language)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"run_monitor_now failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==================== Alerts CRUD ====================
@portfolio_bp.route('/alerts', methods=['GET'])
def get_alerts():
"""Get all position alerts."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT a.id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold,
a.notification_config, a.is_active, a.is_triggered, a.last_triggered_at,
a.trigger_count, a.repeat_interval, a.notes, a.created_at, a.updated_at,
p.name as position_name, p.side as position_side
FROM qd_position_alerts a
LEFT JOIN qd_manual_positions p ON a.position_id = p.id
WHERE a.user_id = ?
ORDER BY a.id DESC
""",
(DEFAULT_USER_ID,)
)
rows = cur.fetchall() or []
cur.close()
alerts = []
for row in rows:
alerts.append({
'id': row.get('id'),
'position_id': row.get('position_id'),
'market': row.get('market') or '',
'symbol': row.get('symbol') or '',
'alert_type': row.get('alert_type') or 'price_above',
'threshold': float(row.get('threshold') or 0),
'notification_config': _safe_json_loads(row.get('notification_config'), {}),
'is_active': bool(row.get('is_active')),
'is_triggered': bool(row.get('is_triggered')),
'last_triggered_at': row.get('last_triggered_at'),
'trigger_count': row.get('trigger_count') or 0,
'repeat_interval': row.get('repeat_interval') or 0,
'notes': row.get('notes') or '',
'created_at': row.get('created_at'),
'updated_at': row.get('updated_at'),
'position_name': row.get('position_name') or '',
'position_side': row.get('position_side') or 'long'
})
return jsonify({'code': 1, 'msg': 'success', 'data': alerts})
except Exception as e:
logger.error(f"get_alerts failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
@portfolio_bp.route('/alerts', methods=['POST'])
def add_alert():
"""Add a new position alert."""
try:
data = request.get_json() or {}
position_id = data.get('position_id') # Can be None for symbol-level alerts
market = (data.get('market') or '').strip()
symbol = _normalize_symbol(data.get('symbol'))
alert_type = (data.get('alert_type') or 'price_above').strip()
threshold = float(data.get('threshold') or 0)
notification_config = data.get('notification_config') or {}
is_active = bool(data.get('is_active', True))
repeat_interval = int(data.get('repeat_interval') or 0)
notes = (data.get('notes') or '').strip()
# Validate alert_type
valid_types = ('price_above', 'price_below', 'pnl_above', 'pnl_below')
if alert_type not in valid_types:
return jsonify({'code': 0, 'msg': f'Invalid alert_type. Must be one of: {valid_types}', 'data': None}), 400
# If position_id provided, get market/symbol from position
if position_id:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"SELECT market, symbol FROM qd_manual_positions WHERE id = ? AND user_id = ?",
(position_id, DEFAULT_USER_ID)
)
pos = cur.fetchone()
cur.close()
if pos:
market = pos.get('market') or market
symbol = pos.get('symbol') or symbol
if not market or not symbol:
return jsonify({'code': 0, 'msg': 'Market and symbol are required', 'data': None}), 400
if threshold <= 0 and alert_type.startswith('price_'):
return jsonify({'code': 0, 'msg': 'Threshold must be positive for price alerts', 'data': None}), 400
now = _now_ts()
notification_config_json = json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False)
with get_db_connection() as db:
cur = db.cursor()
# Check if alert already exists for this position (unique constraint)
existing_alert_id = None
if position_id:
cur.execute(
"SELECT id FROM qd_position_alerts WHERE position_id = ? AND user_id = ?",
(position_id, DEFAULT_USER_ID)
)
existing = cur.fetchone()
if existing:
existing_alert_id = existing.get('id')
if existing_alert_id:
# Update existing alert instead of creating a new one
cur.execute(
"""
UPDATE qd_position_alerts
SET alert_type = ?, threshold = ?, notification_config = ?,
is_active = ?, is_triggered = 0, repeat_interval = ?, notes = ?, updated_at = ?
WHERE id = ?
""",
(alert_type, threshold, notification_config_json,
1 if is_active else 0, repeat_interval, notes, now, existing_alert_id)
)
alert_id = existing_alert_id
else:
# Create new alert
cur.execute(
"""
INSERT INTO qd_position_alerts
(user_id, position_id, market, symbol, alert_type, threshold, notification_config,
is_active, repeat_interval, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(DEFAULT_USER_ID, position_id, market, symbol, alert_type, threshold, notification_config_json,
1 if is_active else 0, repeat_interval, notes, now, now)
)
alert_id = cur.lastrowid
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'id': alert_id}})
except Exception as e:
logger.error(f"add_alert failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@portfolio_bp.route('/alerts/<int:alert_id>', methods=['PUT'])
def update_alert(alert_id):
"""Update an existing alert."""
try:
data = request.get_json() or {}
updates = []
params = []
if 'alert_type' in data:
updates.append('alert_type = ?')
params.append((data.get('alert_type') or 'price_above').strip())
if 'threshold' in data:
updates.append('threshold = ?')
params.append(float(data.get('threshold') or 0))
if 'notification_config' in data:
notification_config = data.get('notification_config') or {}
updates.append('notification_config = ?')
params.append(json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False))
if 'is_active' in data:
updates.append('is_active = ?')
params.append(1 if data.get('is_active') else 0)
# Reset triggered state when re-activating
if data.get('is_active'):
updates.append('is_triggered = ?')
params.append(0)
if 'repeat_interval' in data:
updates.append('repeat_interval = ?')
params.append(int(data.get('repeat_interval') or 0))
if 'notes' in data:
updates.append('notes = ?')
params.append((data.get('notes') or '').strip())
if not updates:
return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400
updates.append('updated_at = ?')
params.append(_now_ts())
params.append(alert_id)
params.append(DEFAULT_USER_ID)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
f"UPDATE qd_position_alerts SET {', '.join(updates)} WHERE id = ? AND user_id = ?",
params
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"update_alert failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@portfolio_bp.route('/alerts/<int:alert_id>', methods=['DELETE'])
def delete_alert(alert_id):
"""Delete an alert."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"DELETE FROM qd_position_alerts WHERE id = ? AND user_id = ?",
(alert_id, DEFAULT_USER_ID)
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"delete_alert failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==================== Groups ====================
@portfolio_bp.route('/groups', methods=['GET'])
def get_groups():
"""Get list of all groups with position counts."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT group_name, COUNT(*) as count
FROM qd_manual_positions
WHERE user_id = ? AND group_name != ''
GROUP BY group_name
ORDER BY group_name
""",
(DEFAULT_USER_ID,)
)
rows = cur.fetchall() or []
# Also get count of ungrouped
cur.execute(
"SELECT COUNT(*) as count FROM qd_manual_positions WHERE user_id = ? AND (group_name IS NULL OR group_name = '')",
(DEFAULT_USER_ID,)
)
ungrouped = cur.fetchone()
cur.close()
groups = []
for row in rows:
groups.append({
'name': row.get('group_name'),
'count': row.get('count') or 0
})
# Add ungrouped count
ungrouped_count = (ungrouped.get('count') or 0) if ungrouped else 0
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'groups': groups,
'ungrouped_count': ungrouped_count
}
})
except Exception as e:
logger.error(f"get_groups failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@portfolio_bp.route('/groups/rename', methods=['POST'])
def rename_group():
"""Rename a group."""
try:
data = request.get_json() or {}
old_name = (data.get('old_name') or '').strip()
new_name = (data.get('new_name') or '').strip()
if not old_name:
return jsonify({'code': 0, 'msg': 'old_name is required', 'data': None}), 400
now = _now_ts()
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"UPDATE qd_manual_positions SET group_name = ?, updated_at = ? WHERE user_id = ? AND group_name = ?",
(new_name, now, DEFAULT_USER_ID, old_name)
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"rename_group failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
+57 -1
View File
@@ -752,4 +752,60 @@ def get_strategy_notifications():
except Exception as e:
logger.error(f"get_strategy_notifications failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500
return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500
@strategy_bp.route('/strategies/notifications/read', methods=['POST'])
def mark_notification_read():
"""Mark a single notification as read."""
try:
data = request.get_json(force=True, silent=True) or {}
notification_id = data.get('id')
if not notification_id:
return jsonify({'code': 0, 'msg': 'Missing id'}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"UPDATE qd_strategy_notifications SET is_read = 1 WHERE id = ?",
(int(notification_id),)
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success'})
except Exception as e:
logger.error(f"mark_notification_read failed: {str(e)}")
return jsonify({'code': 0, 'msg': str(e)}), 500
@strategy_bp.route('/strategies/notifications/read-all', methods=['POST'])
def mark_all_notifications_read():
"""Mark all notifications as read."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("UPDATE qd_strategy_notifications SET is_read = 1")
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success'})
except Exception as e:
logger.error(f"mark_all_notifications_read failed: {str(e)}")
return jsonify({'code': 0, 'msg': str(e)}), 500
@strategy_bp.route('/strategies/notifications/clear', methods=['DELETE'])
def clear_notifications():
"""Clear all notifications (delete from database)."""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("DELETE FROM qd_strategy_notifications")
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success'})
except Exception as e:
logger.error(f"clear_notifications failed: {str(e)}")
return jsonify({'code': 0, 'msg': str(e)}), 500
+1 -1
View File
@@ -37,7 +37,7 @@ class LLMService:
config = load_addon_config()
openrouter_config = config.get('openrouter', {})
default_model = openrouter_config.get('model', 'google/gemini-3-pro-preview')
default_model = openrouter_config.get('model', 'openai/gpt-4o')
if model is None:
model = default_model
File diff suppressed because it is too large Load Diff
@@ -964,6 +964,17 @@ class TradingExecutor:
)
if ok:
logger.info(f"Strategy {strategy_id} signal executed: {signal_type} @ {execute_price}")
# Notify portfolio positions linked to this symbol
try:
from app.services.portfolio_monitor import notify_strategy_signal_for_positions
notify_strategy_signal_for_positions(
market=market_type or 'Crypto',
symbol=symbol,
signal_type=signal_type,
signal_detail=f"策略: {strategy_name}\n信号: {signal_type}\n价格: {execute_price:.4f}"
)
except Exception as link_e:
logger.warning(f"Strategy signal linkage notification failed: {link_e}")
else:
logger.warning(f"Strategy {strategy_id} signal rejected/failed: {signal_type}")
+114
View File
@@ -242,6 +242,7 @@ def _init_db_schema(conn):
"message": "TEXT DEFAULT ''",
"payload_json": "TEXT DEFAULT ''",
"created_at": "INTEGER",
"is_read": "INTEGER DEFAULT 0",
})
# 4. 指标代码表(参考 MySQL: qd_indicator_codes
@@ -424,6 +425,119 @@ def _init_db_schema(conn):
"updated_at": "INTEGER"
})
# 11. Manual positions (user's existing holdings outside the system)
cursor.execute("""
CREATE TABLE IF NOT EXISTS qd_manual_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
market TEXT NOT NULL, -- Crypto/USStock/AShare/HShare/Forex/Futures
symbol TEXT NOT NULL,
name TEXT DEFAULT '', -- Display name
side TEXT DEFAULT 'long', -- long/short
quantity REAL NOT NULL DEFAULT 0,
entry_price REAL NOT NULL DEFAULT 0,
entry_time INTEGER, -- Position open timestamp
notes TEXT DEFAULT '', -- User notes
tags TEXT DEFAULT '', -- JSON array of tags
group_name TEXT DEFAULT '', -- Group name for categorization
created_at INTEGER,
updated_at INTEGER,
UNIQUE(user_id, market, symbol, side)
)
""")
ensure_columns("qd_manual_positions", {
"user_id": "INTEGER NOT NULL DEFAULT 1",
"market": "TEXT NOT NULL DEFAULT ''",
"symbol": "TEXT NOT NULL DEFAULT ''",
"name": "TEXT DEFAULT ''",
"side": "TEXT DEFAULT 'long'",
"quantity": "REAL NOT NULL DEFAULT 0",
"entry_price": "REAL NOT NULL DEFAULT 0",
"entry_time": "INTEGER",
"notes": "TEXT DEFAULT ''",
"tags": "TEXT DEFAULT ''",
"group_name": "TEXT DEFAULT ''",
"created_at": "INTEGER",
"updated_at": "INTEGER"
})
# 11.1 Position alerts (price alerts and PnL alerts for manual positions)
cursor.execute("""
CREATE TABLE IF NOT EXISTS qd_position_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
position_id INTEGER, -- FK to qd_manual_positions (NULL = symbol-level alert)
market TEXT DEFAULT '',
symbol TEXT DEFAULT '',
alert_type TEXT NOT NULL, -- price_above / price_below / pnl_above / pnl_below
threshold REAL NOT NULL DEFAULT 0,
notification_config TEXT DEFAULT '', -- JSON: channels, targets
is_active INTEGER DEFAULT 1,
is_triggered INTEGER DEFAULT 0,
last_triggered_at INTEGER,
trigger_count INTEGER DEFAULT 0,
repeat_interval INTEGER DEFAULT 0, -- 0=once, >0=repeat every N seconds
notes TEXT DEFAULT '',
created_at INTEGER,
updated_at INTEGER
)
""")
ensure_columns("qd_position_alerts", {
"user_id": "INTEGER NOT NULL DEFAULT 1",
"position_id": "INTEGER",
"market": "TEXT DEFAULT ''",
"symbol": "TEXT DEFAULT ''",
"alert_type": "TEXT NOT NULL DEFAULT 'price_above'",
"threshold": "REAL NOT NULL DEFAULT 0",
"notification_config": "TEXT DEFAULT ''",
"is_active": "INTEGER DEFAULT 1",
"is_triggered": "INTEGER DEFAULT 0",
"last_triggered_at": "INTEGER",
"trigger_count": "INTEGER DEFAULT 0",
"repeat_interval": "INTEGER DEFAULT 0",
"notes": "TEXT DEFAULT ''",
"created_at": "INTEGER",
"updated_at": "INTEGER"
})
# 12. Position monitors (AI analysis tasks for manual positions)
cursor.execute("""
CREATE TABLE IF NOT EXISTS qd_position_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
name TEXT DEFAULT '', -- Monitor name
position_ids TEXT DEFAULT '', -- JSON array of position IDs (empty = all positions)
monitor_type TEXT DEFAULT 'ai', -- ai / price_alert / pnl_alert
config TEXT DEFAULT '', -- JSON config (interval_minutes, prompt, thresholds, etc.)
notification_config TEXT DEFAULT '', -- JSON notification config (channels, targets)
is_active INTEGER DEFAULT 1,
last_run_at INTEGER,
next_run_at INTEGER,
last_result TEXT DEFAULT '', -- Last analysis result (JSON)
run_count INTEGER DEFAULT 0,
created_at INTEGER,
updated_at INTEGER
)
""")
ensure_columns("qd_position_monitors", {
"user_id": "INTEGER NOT NULL DEFAULT 1",
"name": "TEXT DEFAULT ''",
"position_ids": "TEXT DEFAULT ''",
"monitor_type": "TEXT DEFAULT 'ai'",
"config": "TEXT DEFAULT ''",
"notification_config": "TEXT DEFAULT ''",
"is_active": "INTEGER DEFAULT 1",
"last_run_at": "INTEGER",
"next_run_at": "INTEGER",
"last_result": "TEXT DEFAULT ''",
"run_count": "INTEGER DEFAULT 0",
"created_at": "INTEGER",
"updated_at": "INTEGER"
})
conn.commit()
logger.info("Database schema initialized (SQLite)")
+8
View File
@@ -31,6 +31,14 @@ SQLITE_DATABASE_FILE=
# Local mode default is enabled in code, but you can override here.
ENABLE_PENDING_ORDER_WORKER=true
# =========================
# Portfolio monitor (optional)
# =========================
# Background service that runs scheduled AI analysis on manual positions
# and sends notifications (email/telegram/browser).
# Default: enabled. Set to false to disable.
ENABLE_PORTFOLIO_MONITOR=true
# Reclaim orders stuck in status=processing after worker crashes (seconds).
PENDING_ORDER_STALE_SEC=90
+230
View File
@@ -0,0 +1,230 @@
# 📧 邮箱 SMTP 通知配置指南
> QuantDinger 支持通过邮件推送策略信号通知,适合需要详细通知记录的用户。
---
## 📋 目录
- [前置要求](#前置要求)
- [支持的邮件服务商](#支持的邮件服务商)
- [配置步骤](#配置步骤)
- [第一步:获取 SMTP 服务信息](#第一步获取-smtp-服务信息)
- [第二步:配置环境变量](#第二步配置环境变量)
- [第三步:策略中启用邮件通知](#第三步策略中启用邮件通知)
- [常见邮件服务商配置示例](#常见邮件服务商配置示例)
- [常见问题](#常见问题)
---
## 前置要求
- 拥有一个支持 SMTP 发送的邮箱账号
- 邮箱已开启 SMTP 服务(部分服务商需手动开启)
- 获取邮箱的授权码或应用专用密码(非登录密码)
- QuantDinger 后端服务已部署并运行
---
## 支持的邮件服务商
QuantDinger 支持任何标准 SMTP 协议的邮件服务商,包括但不限于:
| 服务商 | SMTP 服务器 | 端口 | 加密方式 |
|--------|------------|------|----------|
| Gmail | smtp.gmail.com | 587 (TLS) / 465 (SSL) | TLS / SSL |
| Outlook/Hotmail | smtp.office365.com | 587 | TLS |
| QQ 邮箱 | smtp.qq.com | 587 (TLS) / 465 (SSL) | TLS / SSL |
| 163 邮箱 | smtp.163.com | 465 (SSL) / 25 | SSL |
| 阿里企业邮箱 | smtp.qiye.aliyun.com | 465 | SSL |
| 腾讯企业邮箱 | smtp.exmail.qq.com | 465 | SSL |
| SendGrid | smtp.sendgrid.net | 587 | TLS |
| Mailgun | smtp.mailgun.org | 587 | TLS |
| Amazon SES | email-smtp.{region}.amazonaws.com | 587 | TLS |
---
## 配置步骤
### 第一步:获取 SMTP 服务信息
**QQ 邮箱** 为例:
1. 登录 QQ 邮箱网页版 → 设置 → 账户
2. 找到「POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务」
3. 开启「SMTP 服务」
4. 按提示生成**授权码**(16位字母)
> ⚠️ **重要**:授权码不是 QQ 密码,请妥善保管。
**Gmail** 为例:
1. 访问 [Google 账号安全设置](https://myaccount.google.com/security)
2. 开启两步验证
3. 生成「应用专用密码」(App Password
4. 选择「邮件」和设备类型,获取 16 位密码
---
### 第二步:配置环境变量
`backend_api_python/.env` 文件中配置 SMTP 参数:
```bash
# =========================
# Email / SMTP 配置
# =========================
# SMTP 服务器地址(必填)
SMTP_HOST=smtp.qq.com
# SMTP 端口(必填)
# 常用端口:587 (TLS) / 465 (SSL) / 25 (不加密,不推荐)
SMTP_PORT=465
# SMTP 登录用户名(必填,通常是邮箱地址)
SMTP_USER=your_email@qq.com
# SMTP 密码或授权码(必填)
SMTP_PASSWORD=your_authorization_code
# 发件人地址(可选,默认使用 SMTP_USER)
SMTP_FROM=your_email@qq.com
# 是否使用 STARTTLS(端口 587 通常设为 true
SMTP_USE_TLS=false
# 是否使用 SSL(端口 465 通常设为 true
SMTP_USE_SSL=true
```
配置完成后重启后端服务使配置生效。
---
### 第三步:策略中启用邮件通知
在策略配置页面的「信号通知」设置中:
1. 勾选启用 **Email** 通知渠道
2. 在 **收件邮箱** 字段填入接收通知的邮箱地址
> 💡 **提示**:支持填入多个邮箱地址(逗号分隔),实现多人通知。
---
## 常见邮件服务商配置示例
### QQ 邮箱
```bash
SMTP_HOST=smtp.qq.com
SMTP_PORT=465
SMTP_USER=123456789@qq.com
SMTP_PASSWORD=abcdefghijklmnop # 16位授权码
SMTP_FROM=123456789@qq.com
SMTP_USE_TLS=false
SMTP_USE_SSL=true
```
### 163 邮箱
```bash
SMTP_HOST=smtp.163.com
SMTP_PORT=465
SMTP_USER=your_email@163.com
SMTP_PASSWORD=your_authorization_code # 授权码
SMTP_FROM=your_email@163.com
SMTP_USE_TLS=false
SMTP_USE_SSL=true
```
### Gmail
```bash
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your_email@gmail.com
SMTP_PASSWORD=your_app_password # 应用专用密码
SMTP_FROM=your_email@gmail.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
```
### Outlook / Office 365
```bash
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_USER=your_email@outlook.com
SMTP_PASSWORD=your_password
SMTP_FROM=your_email@outlook.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
```
### SendGrid
```bash
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=SG.xxxxxxxxxxxxxxxxxxxxx # API Key
SMTP_FROM=verified_sender@yourdomain.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
```
### Amazon SES
```bash
SMTP_HOST=email-smtp.us-east-1.amazonaws.com # 替换为您的区域
SMTP_PORT=587
SMTP_USER=AKIAIOSFODNN7EXAMPLE # SMTP 凭证用户名
SMTP_PASSWORD=your_smtp_password # SMTP 凭证密码
SMTP_FROM=verified@yourdomain.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
```
---
## 常见问题
### Q: 发送失败,提示认证错误?
1. 检查用户名和密码(授权码)是否正确
2. 确认已开启邮箱的 SMTP 服务
3. 部分邮箱需要使用授权码而非登录密码
### Q: 连接超时?
1. 检查 SMTP 服务器地址和端口是否正确
2. 确认服务器防火墙是否放行对应端口
3. 如使用代理,确认代理配置正确
### Q: TLS 和 SSL 如何选择?
| 端口 | 加密方式 | 配置 |
|------|---------|------|
| 587 | STARTTLS | `SMTP_USE_TLS=true`, `SMTP_USE_SSL=false` |
| 465 | 隐式 SSL | `SMTP_USE_TLS=false`, `SMTP_USE_SSL=true` |
| 25 | 无加密 | `SMTP_USE_TLS=false`, `SMTP_USE_SSL=false` (不推荐)|
### Q: 邮件被标记为垃圾邮件?
1. 使用与 `SMTP_USER` 一致的 `SMTP_FROM` 地址
2. 考虑使用专业邮件服务(如 SendGrid、Mailgun
3. 配置域名 SPF、DKIM、DMARC 记录(企业邮箱)
### Q: 可以发送 HTML 格式邮件吗?
是的,QuantDinger 自动发送包含格式化信息的 HTML 邮件,同时附带纯文本备选内容以确保兼容性。
---
## 相关文档
- [Telegram 通知配置](./NOTIFICATION_TELEGRAM_CONFIG_CH.md)
- [手机短信通知配置](./NOTIFICATION_SMS_CONFIG_CH.md)
- [策略开发指南](./STRATEGY_DEV_GUIDE_CN.md)
+252
View File
@@ -0,0 +1,252 @@
# 📧 Email SMTP Notification Configuration Guide
> QuantDinger supports strategy signal notifications via email, ideal for users who need detailed notification records.
---
## 📋 Table of Contents
- [Prerequisites](#prerequisites)
- [Supported Email Providers](#supported-email-providers)
- [Configuration Steps](#configuration-steps)
- [Step 1: Obtain SMTP Service Information](#step-1-obtain-smtp-service-information)
- [Step 2: Configure Environment Variables](#step-2-configure-environment-variables)
- [Step 3: Enable Email Notifications in Strategy](#step-3-enable-email-notifications-in-strategy)
- [Provider Configuration Examples](#provider-configuration-examples)
- [Troubleshooting](#troubleshooting)
---
## Prerequisites
- An email account with SMTP sending capability
- SMTP service enabled (some providers require manual activation)
- App password or authorization code (not your login password)
- QuantDinger backend service deployed and running
---
## Supported Email Providers
QuantDinger supports any standard SMTP protocol email provider, including:
| Provider | SMTP Server | Port | Encryption |
|----------|-------------|------|------------|
| Gmail | smtp.gmail.com | 587 (TLS) / 465 (SSL) | TLS / SSL |
| Outlook/Hotmail | smtp.office365.com | 587 | TLS |
| Yahoo Mail | smtp.mail.yahoo.com | 587 | TLS |
| SendGrid | smtp.sendgrid.net | 587 | TLS |
| Mailgun | smtp.mailgun.org | 587 | TLS |
| Amazon SES | email-smtp.{region}.amazonaws.com | 587 | TLS |
| Zoho Mail | smtp.zoho.com | 587 | TLS |
| ProtonMail Bridge | 127.0.0.1 | 1025 | STARTTLS |
---
## Configuration Steps
### Step 1: Obtain SMTP Service Information
**For Gmail:**
1. Visit [Google Account Security Settings](https://myaccount.google.com/security)
2. Enable 2-Step Verification
3. Generate an "App Password"
4. Select "Mail" and your device type to get a 16-character password
**For Outlook/Hotmail:**
1. Use your regular email and password
2. Ensure "SMTP AUTH" is enabled in account settings
3. May require app password if 2FA is enabled
> ⚠️ **Important**: App passwords are different from your login password. Keep them secure.
---
### Step 2: Configure Environment Variables
Add SMTP parameters to your `backend_api_python/.env` file:
```bash
# =========================
# Email / SMTP Configuration
# =========================
# SMTP server address (required)
SMTP_HOST=smtp.gmail.com
# SMTP port (required)
# Common ports: 587 (TLS) / 465 (SSL) / 25 (unencrypted, not recommended)
SMTP_PORT=587
# SMTP username (required, usually your email address)
SMTP_USER=your_email@gmail.com
# SMTP password or app password (required)
SMTP_PASSWORD=your_app_password
# Sender address (optional, defaults to SMTP_USER)
SMTP_FROM=your_email@gmail.com
# Enable STARTTLS (typically true for port 587)
SMTP_USE_TLS=true
# Enable implicit SSL (typically true for port 465)
SMTP_USE_SSL=false
```
Restart the backend service after configuration to apply changes.
---
### Step 3: Enable Email Notifications in Strategy
In the strategy configuration page under "Signal Notifications":
1. Enable the **Email** notification channel
2. Enter the recipient email address in the designated field
> 💡 **Tip**: You can enter multiple email addresses (comma-separated) for multi-recipient notifications.
---
## Provider Configuration Examples
### Gmail
```bash
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your_email@gmail.com
SMTP_PASSWORD=your_app_password # 16-character app password
SMTP_FROM=your_email@gmail.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
```
### Outlook / Office 365
```bash
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_USER=your_email@outlook.com
SMTP_PASSWORD=your_password
SMTP_FROM=your_email@outlook.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
```
### Yahoo Mail
```bash
SMTP_HOST=smtp.mail.yahoo.com
SMTP_PORT=587
SMTP_USER=your_email@yahoo.com
SMTP_PASSWORD=your_app_password
SMTP_FROM=your_email@yahoo.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
```
### SendGrid
```bash
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=SG.xxxxxxxxxxxxxxxxxxxxx # Your API Key
SMTP_FROM=verified_sender@yourdomain.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
```
### Mailgun
```bash
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USER=postmaster@your-domain.mailgun.org
SMTP_PASSWORD=your_mailgun_smtp_password
SMTP_FROM=noreply@yourdomain.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
```
### Amazon SES
```bash
SMTP_HOST=email-smtp.us-east-1.amazonaws.com # Replace with your region
SMTP_PORT=587
SMTP_USER=AKIAIOSFODNN7EXAMPLE # SMTP credential username
SMTP_PASSWORD=your_smtp_password # SMTP credential password
SMTP_FROM=verified@yourdomain.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
```
### Zoho Mail
```bash
SMTP_HOST=smtp.zoho.com
SMTP_PORT=587
SMTP_USER=your_email@zoho.com
SMTP_PASSWORD=your_password
SMTP_FROM=your_email@zoho.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
```
---
## Troubleshooting
### Q: Authentication error?
1. Verify username and password (app password) are correct
2. Confirm SMTP service is enabled in your email settings
3. Some providers require app passwords instead of login passwords
4. Check if "Less secure app access" needs to be enabled (legacy option)
### Q: Connection timeout?
1. Verify SMTP server address and port are correct
2. Check if server firewall allows the port
3. If using a proxy, ensure proxy configuration is correct
4. Some networks block SMTP ports; try port 587 or 465
### Q: How to choose between TLS and SSL?
| Port | Encryption | Configuration |
|------|------------|---------------|
| 587 | STARTTLS | `SMTP_USE_TLS=true`, `SMTP_USE_SSL=false` |
| 465 | Implicit SSL | `SMTP_USE_TLS=false`, `SMTP_USE_SSL=true` |
| 25 | None | `SMTP_USE_TLS=false`, `SMTP_USE_SSL=false` (not recommended) |
### Q: Emails marked as spam?
1. Use a `SMTP_FROM` address matching your `SMTP_USER`
2. Consider professional email services (SendGrid, Mailgun, Amazon SES)
3. Configure SPF, DKIM, DMARC records for your domain (business email)
4. Avoid spam trigger words in subject/content
### Q: Does it support HTML emails?
Yes, QuantDinger automatically sends formatted HTML emails with a plain text fallback for maximum compatibility.
### Q: Rate limits?
Most providers have sending limits:
- Gmail: ~500/day (personal), 2000/day (Workspace)
- Outlook: ~300/day
- SendGrid: Based on your plan
For high-volume notifications, consider dedicated email services.
---
## Related Documentation
- [Telegram Notification Configuration](./NOTIFICATION_TELEGRAM_CONFIG_EN.md)
- [SMS Notification Configuration](./NOTIFICATION_SMS_CONFIG_EN.md)
- [Strategy Development Guide](./STRATEGY_DEV_GUIDE.md)
+211
View File
@@ -0,0 +1,211 @@
# 📲 手机短信通知配置指南
> QuantDinger 支持通过 Twilio 发送 SMS 短信通知,确保您在任何情况下都能收到重要的交易信号。
---
## 📋 目录
- [前置要求](#前置要求)
- [Twilio 简介](#twilio-简介)
- [配置步骤](#配置步骤)
- [第一步:注册 Twilio 账号](#第一步注册-twilio-账号)
- [第二步:获取 API 凭证](#第二步获取-api-凭证)
- [第三步:获取发送号码](#第三步获取发送号码)
- [第四步:配置环境变量](#第四步配置环境变量)
- [第五步:策略中启用短信通知](#第五步策略中启用短信通知)
- [费用说明](#费用说明)
- [常见问题](#常见问题)
---
## 前置要求
- 有效的手机号码用于接收短信
- 可用的信用卡/借记卡用于 Twilio 充值(试用账户有免费额度)
- QuantDinger 后端服务已部署并运行
---
## Twilio 简介
[Twilio](https://www.twilio.com) 是全球领先的云通信平台,提供可靠的 SMS 短信服务。
**为什么选择 Twilio**
- ✅ 全球覆盖 180+ 国家和地区
- ✅ 高送达率和可靠性
- ✅ 按量计费,无月费
- ✅ 新用户有免费试用额度
- ✅ 完善的 API 文档和技术支持
---
## 配置步骤
### 第一步:注册 Twilio 账号
1. 访问 [Twilio 官网](https://www.twilio.com/try-twilio)
2. 点击 **Sign Up** 注册新账号
3. 填写邮箱、密码等基本信息
4. 验证您的邮箱和手机号码
5. 完成账号激活
> 💡 **提示**:新注册用户可获得 $15 美元的免费试用额度。
---
### 第二步:获取 API 凭证
注册完成后,进入 Twilio 控制台:
1. 登录 [Twilio Console](https://console.twilio.com)
2. 在 Dashboard 页面找到 **Account Info** 区域
3. 记录以下信息:
- **Account SID**:格式为 `ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
- **Auth Token**:点击显示后复制(妥善保管)
![Twilio Console](https://www.twilio.com/docs/static/img/console-account-info.png)
> ⚠️ **安全提示**Auth Token 相当于 API 密码,请勿泄露。如泄露请立即在控制台重新生成。
---
### 第三步:获取发送号码
您需要一个 Twilio 电话号码作为短信发送方:
1. 在 Twilio Console 左侧菜单选择 **Phone Numbers****Manage** → **Buy a number**
2. 选择国家/地区,勾选 **SMS** 功能
3. 选择一个号码并购买(试用账户可免费获取一个号码)
4. 记录您的 Twilio 电话号码(格式:`+1xxxxxxxxxx`
**号码选择建议:**
- 选择与接收方同一国家的号码可降低费用
- 如需发送到中国大陆,建议使用 Alphanumeric Sender ID 或购买支持中国的号码
---
### 第四步:配置环境变量
`backend_api_python/.env` 文件中配置 Twilio 参数:
```bash
# =========================
# Phone / SMS 配置 (Twilio)
# =========================
# Twilio Account SID(必填)
# 格式:ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Twilio Auth Token(必填)
# 从 Twilio Console 获取
TWILIO_AUTH_TOKEN=your_auth_token_here
# Twilio 发送号码(必填)
# 格式:+国家代码+号码,如 +14155552671
TWILIO_FROM_NUMBER=+14155552671
```
配置完成后重启后端服务使配置生效。
---
### 第五步:策略中启用短信通知
在策略配置页面的「信号通知」设置中:
1. 勾选启用 **Phone** 通知渠道
2. 在 **手机号码** 字段填入接收短信的号码
**号码格式要求:**
- 必须包含国家代码
- 格式:`+国家代码号码`
- 示例:
- 美国:`+14155552671`
- 中国大陆:`+8613812345678`
- 香港:`+85212345678`
> 💡 **提示**:支持填入多个号码(逗号分隔),实现多人通知。
---
## 费用说明
Twilio 采用按量计费模式,不同国家/地区的短信费用不同:
| 接收地区 | 大约费用(美元/条) |
|---------|-------------------|
| 美国 | $0.0079 |
| 加拿大 | $0.0075 |
| 英国 | $0.04 |
| 德国 | $0.07 |
| 日本 | $0.08 |
| 中国大陆 | $0.06 - $0.10 |
| 香港 | $0.05 |
> 💰 **提示**:实际费用请参考 [Twilio 定价页面](https://www.twilio.com/sms/pricing),价格可能随时调整。
**试用账户限制:**
- $15 美元免费额度
- 只能发送到已验证的手机号码
- 短信会带有 "Sent from your Twilio trial account" 前缀
升级为正式账户后可解除这些限制。
---
## 常见问题
### Q: 试用账户可以发短信到任意号码吗?
不可以。试用账户只能发送到已验证的手机号码。您需要在 Twilio Console 的 **Verified Caller IDs** 中添加并验证接收号码。升级为正式账户后可发送到任意号码。
### Q: 发送失败,提示号码无效?
1. 确保号码格式正确(含国家代码,如 `+8613812345678`
2. 去掉号码中的空格、横线等特殊字符
3. 确认接收号码可以接收国际短信
### Q: 中国大陆号码收不到短信?
1. 部分运营商可能拦截国际短信
2. 尝试使用 Alphanumeric Sender ID
3. 确认手机未开启国际短信拦截
4. 联系 Twilio 支持确认中国短信服务状态
### Q: 如何查看发送记录和状态?
登录 Twilio Console → **Monitor****Logs****Messaging** 可查看所有短信发送记录、状态和错误信息。
### Q: Auth Token 泄露了怎么办?
立即登录 Twilio Console → **Account****API Credentials** → 点击 **Regenerate Auth Token** 重新生成。
### Q: 有替代 Twilio 的方案吗?
QuantDinger 目前仅支持 Twilio 作为 SMS 提供商。如需其他服务商支持,可通过 Webhook 通道自行集成:
- 阿里云短信
- 腾讯云短信
- Nexmo (Vonage)
- AWS SNS
---
## 环境变量完整参考
```bash
# Twilio SMS Configuration
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Account SID
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Auth Token
TWILIO_FROM_NUMBER=+14155552671 # 发送号码
```
---
## 相关文档
- [Telegram 通知配置](./NOTIFICATION_TELEGRAM_CONFIG_CH.md)
- [邮箱 SMTP 通知配置](./NOTIFICATION_EMAIL_CONFIG_CH.md)
- [策略开发指南](./STRATEGY_DEV_GUIDE_CN.md)
- [Twilio 官方文档](https://www.twilio.com/docs/sms)
+211
View File
@@ -0,0 +1,211 @@
# 📲 SMS Notification Configuration Guide
> QuantDinger supports SMS notifications via Twilio, ensuring you receive critical trading signals anywhere.
---
## 📋 Table of Contents
- [Prerequisites](#prerequisites)
- [About Twilio](#about-twilio)
- [Configuration Steps](#configuration-steps)
- [Step 1: Create a Twilio Account](#step-1-create-a-twilio-account)
- [Step 2: Obtain API Credentials](#step-2-obtain-api-credentials)
- [Step 3: Get a Phone Number](#step-3-get-a-phone-number)
- [Step 4: Configure Environment Variables](#step-4-configure-environment-variables)
- [Step 5: Enable SMS Notifications in Strategy](#step-5-enable-sms-notifications-in-strategy)
- [Pricing Information](#pricing-information)
- [Troubleshooting](#troubleshooting)
---
## Prerequisites
- A valid phone number to receive SMS messages
- A credit/debit card for Twilio billing (trial accounts include free credits)
- QuantDinger backend service deployed and running
---
## About Twilio
[Twilio](https://www.twilio.com) is a leading cloud communications platform providing reliable SMS services worldwide.
**Why Twilio?**
- ✅ Global coverage in 180+ countries
- ✅ High deliverability and reliability
- ✅ Pay-as-you-go pricing, no monthly fees
- ✅ Free trial credits for new users
- ✅ Comprehensive API documentation and support
---
## Configuration Steps
### Step 1: Create a Twilio Account
1. Visit [Twilio Sign Up](https://www.twilio.com/try-twilio)
2. Click **Sign Up** to create a new account
3. Fill in your email, password, and basic information
4. Verify your email and phone number
5. Complete account activation
> 💡 **Tip**: New users receive $15 USD in free trial credits.
---
### Step 2: Obtain API Credentials
After registration, access the Twilio Console:
1. Log in to [Twilio Console](https://console.twilio.com)
2. Locate the **Account Info** section on the Dashboard
3. Note the following information:
- **Account SID**: Format `ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
- **Auth Token**: Click to reveal and copy (keep this secure)
> ⚠️ **Security Notice**: The Auth Token is essentially your API password. Never share it publicly. If compromised, regenerate it immediately in the console.
---
### Step 3: Get a Phone Number
You need a Twilio phone number as the SMS sender:
1. In Twilio Console, navigate to **Phone Numbers****Manage** → **Buy a number**
2. Select a country and check the **SMS** capability
3. Choose and purchase a number (trial accounts get one free number)
4. Note your Twilio phone number (format: `+1xxxxxxxxxx`)
**Number Selection Tips:**
- Choose a number from the same country as recipients to reduce costs
- For international recipients, consider the destination country's regulations
- Some countries require sender ID registration
---
### Step 4: Configure Environment Variables
Add Twilio parameters to your `backend_api_python/.env` file:
```bash
# =========================
# Phone / SMS Configuration (Twilio)
# =========================
# Twilio Account SID (required)
# Format: ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Twilio Auth Token (required)
# Obtained from Twilio Console
TWILIO_AUTH_TOKEN=your_auth_token_here
# Twilio Sender Number (required)
# Format: +CountryCodeNumber, e.g., +14155552671
TWILIO_FROM_NUMBER=+14155552671
```
Restart the backend service after configuration to apply changes.
---
### Step 5: Enable SMS Notifications in Strategy
In the strategy configuration page under "Signal Notifications":
1. Enable the **Phone** notification channel
2. Enter the recipient phone number in the designated field
**Number Format Requirements:**
- Must include country code
- Format: `+CountryCodeNumber`
- Examples:
- United States: `+14155552671`
- United Kingdom: `+447911123456`
- Germany: `+4915112345678`
- Australia: `+61412345678`
> 💡 **Tip**: You can enter multiple numbers (comma-separated) for multi-recipient notifications.
---
## Pricing Information
Twilio uses pay-as-you-go pricing. SMS costs vary by destination:
| Destination | Approx. Cost (USD/message) |
|-------------|---------------------------|
| United States | $0.0079 |
| Canada | $0.0075 |
| United Kingdom | $0.04 |
| Germany | $0.07 |
| Australia | $0.05 |
| Japan | $0.08 |
| India | $0.04 |
> 💰 **Note**: For current pricing, visit [Twilio SMS Pricing](https://www.twilio.com/sms/pricing). Prices may change.
**Trial Account Limitations:**
- $15 USD free credits
- Can only send to verified phone numbers
- Messages include "Sent from your Twilio trial account" prefix
Upgrade to a paid account to remove these limitations.
---
## Troubleshooting
### Q: Can trial accounts send to any number?
No. Trial accounts can only send to verified phone numbers. Add and verify recipient numbers in Twilio Console under **Verified Caller IDs**. Upgrade to a paid account for unrestricted sending.
### Q: Send failed with invalid number error?
1. Ensure correct format with country code (e.g., `+14155552671`)
2. Remove spaces, dashes, or special characters from the number
3. Verify the recipient can receive international SMS
### Q: Messages not delivered to certain countries?
1. Some carriers may block international SMS
2. Check country-specific regulations (some require sender ID registration)
3. Verify the destination country is supported by Twilio
4. Contact Twilio support for country-specific issues
### Q: How to check delivery status?
Log in to Twilio Console → **Monitor****Logs****Messaging** to view all SMS records, delivery status, and error details.
### Q: Auth Token was compromised?
Immediately log in to Twilio Console → **Account****API Credentials** → Click **Regenerate Auth Token**.
### Q: Are there alternatives to Twilio?
QuantDinger currently only supports Twilio as the SMS provider. For other services, use the Webhook channel to integrate:
- Nexmo (Vonage)
- AWS SNS
- MessageBird
- Plivo
---
## Complete Environment Variable Reference
```bash
# Twilio SMS Configuration
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Account SID
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Auth Token
TWILIO_FROM_NUMBER=+14155552671 # Sender number
```
---
## Related Documentation
- [Telegram Notification Configuration](./NOTIFICATION_TELEGRAM_CONFIG_EN.md)
- [Email SMTP Notification Configuration](./NOTIFICATION_EMAIL_CONFIG_EN.md)
- [Strategy Development Guide](./STRATEGY_DEV_GUIDE.md)
- [Twilio Official Documentation](https://www.twilio.com/docs/sms)
+122 -10
View File
@@ -1,14 +1,126 @@
# TELEGRAM通知配置
# 📱 Telegram 通知配置指南
## 1.创建telegram机器人并且获取token
<img src="./screenshots/notification_telegram_token.png" alt="QuantDinger Dashboard" width="100%" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
> QuantDinger 支持通过 Telegram Bot 推送策略信号通知,实时获取交易提醒。
## 2.1 在策略中配置UserID
<img src="./screenshots/notification_telegram_userid.png" alt="QuantDinger Dashboard" width="100%" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
---
## 📋 目录
- [前置要求](#前置要求)
- [第一步:创建 Telegram Bot](#第一步创建-telegram-bot)
- [第二步:获取 Bot Token](#第二步获取-bot-token)
- [第三步:获取您的 User ID](#第三步获取您的-user-id)
- [第四步:配置环境变量](#第四步配置环境变量)
- [第五步:策略中启用 Telegram 通知](#第五步策略中启用-telegram-通知)
- [常见问题](#常见问题)
---
## 前置要求
- 已安装 Telegram 客户端(手机或桌面版)
- 拥有 Telegram 账号
- QuantDinger 后端服务已部署并运行
---
## 第一步:创建 Telegram Bot
1. 在 Telegram 中搜索 **@BotFather**(官方机器人管理工具)
2. 发送 `/newbot` 命令开始创建新机器人
3. 按照提示输入机器人名称(如:`QuantDinger Signal Bot`
4. 输入机器人用户名(必须以 `bot` 结尾,如:`quantdinger_signal_bot`
<img src="./screenshots/notification_telegram_token.png" alt="创建 Telegram Bot" width="100%" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
---
## 第二步:获取 Bot Token
创建成功后,BotFather 会返回一个 **HTTP API Token**,格式如下:
## 2.2 UserID的获取
```aiignore
https://api.telegram.org/bot【你的哪个token】/getUpdates
```
UserID从这个链接获取,把你的token填进去,token格式是【123:abc】
![notification_telegram_userid_get.png](screenshots/notification_telegram_userid_get.png)
123456789:ABCdefGHIjklMNOpqrsTUVwxyz
```
> ⚠️ **安全提示**:请妥善保管此 Token,不要泄露给他人。如果 Token 泄露,请立即在 BotFather 中使用 `/revoke` 命令重新生成。
---
## 第三步:获取您的 User ID
### 方法一:通过 API 获取(推荐)
1. 先向您刚创建的 Bot 发送任意消息(如 `/start`
2. 在浏览器中访问以下 URL(将 `YOUR_BOT_TOKEN` 替换为您的 Token):
```
https://api.telegram.org/bot{YOUR_BOT_TOKEN}/getUpdates
```
**示例**
```
https://api.telegram.org/bot123456789:ABCdefGHIjklMNOpqrsTUVwxyz/getUpdates
```
3. 在返回的 JSON 中找到 `chat.id` 字段,这就是您的 User ID
<img src="./screenshots/notification_telegram_userid_get.png" alt="获取 User ID" width="100%" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
### 方法二:通过 @userinfobot 获取
1. 在 Telegram 中搜索 **@userinfobot**
2. 发送任意消息,机器人会返回您的 User ID
---
## 第四步:配置环境变量
`backend_api_python/.env` 文件中配置 Bot Token
```bash
# Telegram Bot Token(必填)
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
```
配置完成后重启后端服务使配置生效。
---
## 第五步:策略中启用 Telegram 通知
在策略配置页面的「信号通知」设置中:
1. 勾选启用 **Telegram** 通知渠道
2. 在 **User ID** 字段填入您的 Telegram User ID
<img src="./screenshots/notification_telegram_userid.png" alt="配置 User ID" width="100%" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
> 💡 **提示**:支持填入多个 User ID(逗号分隔)或群组/频道 ID,实现多人通知。
---
## 常见问题
### Q: 收不到通知怎么办?
1. 确认已向 Bot 发送过消息(Bot 需要先被激活)
2. 检查 `TELEGRAM_BOT_TOKEN` 环境变量是否正确配置
3. 确认 User ID 填写正确
4. 查看后端日志是否有错误信息
### Q: 可以发送到群组吗?
可以。将 Bot 添加到群组后,使用群组 ID(负数)作为 User ID 即可。获取群组 ID 的方法与获取个人 ID 类似。
### Q: Token 格式是什么?
Token 格式为 `数字:字母数字字符串`,例如 `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`
---
## 相关文档
- [邮箱 SMTP 通知配置](./NOTIFICATION_EMAIL_CONFIG_CH.md)
- [手机短信通知配置](./NOTIFICATION_SMS_CONFIG_CH.md)
- [策略开发指南](./STRATEGY_DEV_GUIDE_CN.md)
+122 -10
View File
@@ -1,14 +1,126 @@
# TELEGRAM NOTIFICATION CONFIG
# 📱 Telegram Notification Configuration Guide
## 1.Create telegram Bot and get Bot token
<img src="./screenshots/notification_telegram_token.png" alt="QuantDinger Dashboard" width="100%" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
> QuantDinger supports real-time strategy signal notifications via Telegram Bot.
## 2.1 configuration UserID in signal config
<img src="./screenshots/notification_telegram_userid.png" alt="QuantDinger Dashboard" width="100%" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
---
## 📋 Table of Contents
- [Prerequisites](#prerequisites)
- [Step 1: Create a Telegram Bot](#step-1-create-a-telegram-bot)
- [Step 2: Obtain Bot Token](#step-2-obtain-bot-token)
- [Step 3: Get Your User ID](#step-3-get-your-user-id)
- [Step 4: Configure Environment Variables](#step-4-configure-environment-variables)
- [Step 5: Enable Telegram Notifications in Strategy](#step-5-enable-telegram-notifications-in-strategy)
- [Troubleshooting](#troubleshooting)
---
## Prerequisites
- Telegram client installed (mobile or desktop)
- Active Telegram account
- QuantDinger backend service deployed and running
---
## Step 1: Create a Telegram Bot
1. Search for **@BotFather** in Telegram (official bot management tool)
2. Send the `/newbot` command to create a new bot
3. Enter a display name for your bot (e.g., `QuantDinger Signal Bot`)
4. Choose a unique username ending with `bot` (e.g., `quantdinger_signal_bot`)
<img src="./screenshots/notification_telegram_token.png" alt="Create Telegram Bot" width="100%" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
---
## Step 2: Obtain Bot Token
Upon successful creation, BotFather will provide an **HTTP API Token** in this format:
## 2.2 UserID edit and get
```aiignore
https://api.telegram.org/bot【your bot token】/getUpdates
```
UserID from this urlreplace your bot tokentoken like【123:abc】
![notification_telegram_userid_get.png](screenshots/notification_telegram_userid_get.png)
123456789:ABCdefGHIjklMNOpqrsTUVwxyz
```
> ⚠️ **Security Notice**: Keep this token secure and never share it publicly. If compromised, use `/revoke` command in BotFather to regenerate immediately.
---
## Step 3: Get Your User ID
### Method 1: Via Telegram API (Recommended)
1. First, send any message to your newly created bot (e.g., `/start`)
2. Visit the following URL in your browser (replace `YOUR_BOT_TOKEN` with your actual token):
```
https://api.telegram.org/bot{YOUR_BOT_TOKEN}/getUpdates
```
**Example**:
```
https://api.telegram.org/bot123456789:ABCdefGHIjklMNOpqrsTUVwxyz/getUpdates
```
3. Locate the `chat.id` field in the JSON response — this is your User ID
<img src="./screenshots/notification_telegram_userid_get.png" alt="Get User ID" width="100%" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
### Method 2: Via @userinfobot
1. Search for **@userinfobot** in Telegram
2. Send any message, and it will reply with your User ID
---
## Step 4: Configure Environment Variables
Add the Bot Token to your `backend_api_python/.env` file:
```bash
# Telegram Bot Token (required)
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
```
Restart the backend service after configuration to apply changes.
---
## Step 5: Enable Telegram Notifications in Strategy
In the strategy configuration page under "Signal Notifications":
1. Enable the **Telegram** notification channel
2. Enter your Telegram User ID in the designated field
<img src="./screenshots/notification_telegram_userid.png" alt="Configure User ID" width="100%" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
> 💡 **Tip**: You can enter multiple User IDs (comma-separated) or group/channel IDs for multi-recipient notifications.
---
## Troubleshooting
### Q: Not receiving notifications?
1. Ensure you've sent a message to the bot first (bot must be activated)
2. Verify `TELEGRAM_BOT_TOKEN` environment variable is correctly configured
3. Double-check the User ID is correct
4. Review backend logs for error messages
### Q: Can I send to groups?
Yes. Add the bot to a group, then use the group ID (negative number) as the target. Obtain the group ID using the same method as personal ID.
### Q: What's the token format?
Token format is `numbers:alphanumeric_string`, e.g., `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`
---
## Related Documentation
- [Email SMTP Notification Configuration](./NOTIFICATION_EMAIL_CONFIG_EN.md)
- [SMS Notification Configuration](./NOTIFICATION_SMS_CONFIG_EN.md)
- [Strategy Development Guide](./STRATEGY_DEV_GUIDE.md)
+149
View File
@@ -0,0 +1,149 @@
/**
* Portfolio API - Manual positions and monitoring
*/
import request from '@/utils/request'
// ==================== Positions ====================
export function getPositions () {
return request({
url: '/api/portfolio/positions',
method: 'get'
})
}
export function addPosition (data) {
return request({
url: '/api/portfolio/positions',
method: 'post',
data
})
}
export function updatePosition (id, data) {
return request({
url: `/api/portfolio/positions/${id}`,
method: 'put',
data
})
}
export function deletePosition (id) {
return request({
url: `/api/portfolio/positions/${id}`,
method: 'delete'
})
}
export function getPortfolioSummary () {
return request({
url: '/api/portfolio/summary',
method: 'get'
})
}
// ==================== Monitors ====================
export function getMonitors () {
return request({
url: '/api/portfolio/monitors',
method: 'get'
})
}
export function addMonitor (data) {
return request({
url: '/api/portfolio/monitors',
method: 'post',
data
})
}
export function updateMonitor (id, data) {
return request({
url: `/api/portfolio/monitors/${id}`,
method: 'put',
data
})
}
export function deleteMonitor (id) {
return request({
url: `/api/portfolio/monitors/${id}`,
method: 'delete'
})
}
export function runMonitor (id, params = {}) {
return request({
url: `/api/portfolio/monitors/${id}/run`,
method: 'post',
data: params
})
}
// ==================== Alerts ====================
export function getAlerts () {
return request({
url: '/api/portfolio/alerts',
method: 'get'
})
}
export function addAlert (data) {
return request({
url: '/api/portfolio/alerts',
method: 'post',
data
})
}
export function updateAlert (id, data) {
return request({
url: `/api/portfolio/alerts/${id}`,
method: 'put',
data
})
}
export function deleteAlert (id) {
return request({
url: `/api/portfolio/alerts/${id}`,
method: 'delete'
})
}
// ==================== Groups ====================
export function getGroups () {
return request({
url: '/api/portfolio/groups',
method: 'get'
})
}
export function renameGroup (data) {
return request({
url: '/api/portfolio/groups/rename',
method: 'post',
data
})
}
// ==================== Market (reuse from market.js) ====================
export function searchSymbols (data) {
return request({
url: '/api/market/symbols/search',
method: 'post',
data
})
}
export function getMarketTypes () {
return request({
url: '/api/market/types',
method: 'get'
})
}
@@ -1,6 +1,7 @@
<template>
<div :class="wrpCls">
<!-- User avatar/name removed for local OSS build -->
<notice-icon :class="prefixCls" />
<select-lang :class="prefixCls" />
<a-tooltip :title="$t('app.setting.tooltip')">
<span :class="prefixCls" @click="handleSettingClick">
@@ -12,11 +13,13 @@
<script>
import SelectLang from '@/components/SelectLang'
import NoticeIcon from '@/components/NoticeIcon'
export default {
name: 'RightContent',
components: {
SelectLang
SelectLang,
NoticeIcon
},
props: {
prefixCls: {
@@ -1,90 +1,789 @@
<template>
<a-popover
v-model="visible"
trigger="click"
placement="bottomRight"
overlayClassName="header-notice-wrapper"
:getPopupContainer="() => $refs.noticeRef.parentElement"
:autoAdjustOverflow="true"
:arrowPointAtCenter="true"
:overlayStyle="{ width: '300px', top: '50px' }"
>
<template slot="content">
<a-spin :spinning="loading">
<a-tabs>
<a-tab-pane tab="通知" key="1">
<a-list>
<a-list-item>
<a-list-item-meta title="你收到了 14 份新周报" description="一年前">
<a-avatar style="background-color: white" slot="avatar" src="https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png"/>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta title="你推荐的 曲妮妮 已通过第三轮面试" description="一年前">
<a-avatar style="background-color: white" slot="avatar" src="https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png"/>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta title="这种模板可以区分多种通知类型" description="一年前">
<a-avatar style="background-color: white" slot="avatar" src="https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png"/>
</a-list-item-meta>
</a-list-item>
</a-list>
</a-tab-pane>
<a-tab-pane tab="消息" key="2">
123
</a-tab-pane>
<a-tab-pane tab="待办" key="3">
123
</a-tab-pane>
</a-tabs>
</a-spin>
</template>
<span @click="fetchNotice" class="header-notice" ref="noticeRef" style="padding: 0 18px">
<a-badge count="12">
<a-icon style="font-size: 16px; padding: 4px" type="bell" />
</a-badge>
</span>
</a-popover>
<div class="notice-icon-wrapper">
<a-popover
v-model="visible"
trigger="click"
placement="bottomRight"
overlayClassName="header-notice-wrapper"
:getPopupContainer="() => $refs.noticeRef.parentElement"
:autoAdjustOverflow="true"
:arrowPointAtCenter="true"
:overlayStyle="{ width: '380px', top: '50px' }"
>
<template slot="content">
<div class="notice-header">
<span class="notice-title">{{ $t('notice.title') }}</span>
<a v-if="notifications.length > 0" @click="markAllRead" class="notice-action">
{{ $t('notice.markAllRead') }}
</a>
</div>
<a-spin :spinning="loading">
<div class="notice-list" v-if="notifications.length > 0">
<div
v-for="item in notifications"
:key="item.id"
class="notice-item"
:class="{ unread: !item.is_read }"
@click="handleNoticeClick(item)"
>
<div class="notice-item-icon">
<a-icon :type="getNoticeIcon(item.signal_type)" :style="{ color: getNoticeColor(item.signal_type) }" />
</div>
<div class="notice-item-content">
<div class="notice-item-title">{{ item.title }}</div>
<div class="notice-item-desc">{{ truncateMessage(item.message) }}</div>
<div class="notice-item-time">{{ formatTime(item.created_at) }}</div>
</div>
</div>
</div>
<div class="notice-empty" v-else>
<a-empty :description="$t('notice.empty')" />
</div>
</a-spin>
<div class="notice-footer" v-if="notifications.length > 0">
<a @click="clearNotifications">{{ $t('notice.clear') }}</a>
</div>
</template>
<span @click="fetchNotice" class="header-notice" ref="noticeRef">
<a-badge :count="unreadCount" :overflowCount="99">
<a-icon style="font-size: 16px; padding: 4px" type="bell" />
</a-badge>
</span>
</a-popover>
<!-- 通知详情弹窗 -->
<a-modal
v-model="detailVisible"
:title="detailNotice ? detailNotice.title : ''"
:footer="null"
:width="isHtmlReport ? 900 : 600"
:wrapClassName="isHtmlReport ? 'notice-detail-modal html-report-modal' : 'notice-detail-modal'"
centered
>
<div v-if="detailNotice" class="notice-detail">
<div class="notice-detail-meta">
<div class="notice-detail-type">
<a-icon :type="getNoticeIcon(detailNotice.signal_type)" :style="{ color: getNoticeColor(detailNotice.signal_type) }" />
<span class="type-label">{{ getNoticeTypeLabel(detailNotice.signal_type) }}</span>
</div>
<div class="notice-detail-time">
<a-icon type="clock-circle" />
<span>{{ formatFullTime(detailNotice.created_at) }}</span>
</div>
</div>
<a-divider />
<!-- 消息内容 - 支持 HTML 报告或 Markdown 格式 -->
<div class="notice-detail-content" :class="{ 'html-report': isHtmlReport }">
<div v-html="formatMessageHtml(detailNotice.message)" class="message-body"></div>
</div>
<!-- 如果有额外的 payload 信息 HTML 报告时显示 -->
<template v-if="!isHtmlReport && detailNotice.payload && Object.keys(detailNotice.payload).length > 0">
<a-divider />
<div class="notice-detail-extra">
<div class="extra-title">{{ $t('notice.detailInfo') }}</div>
<!-- AI分析结果 -->
<template v-if="detailNotice.signal_type === 'ai_monitor'">
<div v-if="detailNotice.payload.final_decision" class="extra-item decision">
<span class="label">{{ $t('notice.aiDecision') }}:</span>
<a-tag :color="getDecisionColor(detailNotice.payload.final_decision)">
{{ detailNotice.payload.final_decision }}
</a-tag>
<span v-if="detailNotice.payload.confidence" class="confidence">
({{ $t('notice.confidence') }}: {{ detailNotice.payload.confidence }}%)
</span>
</div>
<div v-if="detailNotice.payload.reasoning" class="extra-item">
<span class="label">{{ $t('notice.reasoning') }}:</span>
<span class="value">{{ detailNotice.payload.reasoning }}</span>
</div>
</template>
<!-- 价格提醒 -->
<template v-if="detailNotice.signal_type === 'price_alert'">
<div v-if="detailNotice.payload.symbol" class="extra-item">
<span class="label">{{ $t('notice.symbol') }}:</span>
<span class="value">{{ detailNotice.payload.symbol }}</span>
</div>
<div v-if="detailNotice.payload.price" class="extra-item">
<span class="label">{{ $t('notice.currentPrice') }}:</span>
<span class="value">${{ detailNotice.payload.price }}</span>
</div>
<div v-if="detailNotice.payload.trigger_price" class="extra-item">
<span class="label">{{ $t('notice.triggerPrice') }}:</span>
<span class="value">${{ detailNotice.payload.trigger_price }}</span>
</div>
</template>
<!-- 交易信号 -->
<template v-if="detailNotice.signal_type === 'signal' || detailNotice.signal_type === 'trade'">
<div v-if="detailNotice.payload.symbol" class="extra-item">
<span class="label">{{ $t('notice.symbol') }}:</span>
<span class="value">{{ detailNotice.payload.symbol }}</span>
</div>
<div v-if="detailNotice.payload.action" class="extra-item">
<span class="label">{{ $t('notice.action') }}:</span>
<a-tag :color="detailNotice.payload.action === 'BUY' ? 'green' : 'red'">
{{ detailNotice.payload.action }}
</a-tag>
</div>
<div v-if="detailNotice.payload.quantity" class="extra-item">
<span class="label">{{ $t('notice.quantity') }}:</span>
<span class="value">{{ detailNotice.payload.quantity }}</span>
</div>
</template>
</div>
</template>
<!-- 操作按钮 -->
<div class="notice-detail-actions">
<a-button v-if="detailNotice.payload && detailNotice.payload.monitor_id" type="primary" @click="goToPortfolio">
<a-icon type="fund" />
{{ $t('notice.viewPortfolio') }}
</a-button>
<a-button @click="detailVisible = false">
{{ $t('notice.close') }}
</a-button>
</div>
</div>
</a-modal>
</div>
</template>
<script>
import { getStrategyNotifications } from '@/api/strategy'
import request from '@/utils/request'
export default {
name: 'HeaderNotice',
data () {
return {
loading: false,
visible: false
visible: false,
detailVisible: false,
detailNotice: null,
notifications: [],
lastFetchId: 0,
pollingTimer: null
}
},
computed: {
unreadCount () {
return this.notifications.filter(n => !n.is_read).length
},
isHtmlReport () {
if (!this.detailNotice || !this.detailNotice.message) return false
return this.detailNotice.message.includes('<div class="qd-report">') ||
this.detailNotice.message.includes('<style>')
}
},
mounted () {
this.fetchNotifications()
this.startPolling()
},
beforeDestroy () {
this.stopPolling()
},
methods: {
fetchNotice () {
if (!this.visible) {
startPolling () {
this.stopPolling()
// 30
this.pollingTimer = setInterval(() => {
this.fetchNotifications(true)
}, 30000)
},
stopPolling () {
if (this.pollingTimer) {
clearInterval(this.pollingTimer)
this.pollingTimer = null
}
},
async fetchNotifications (silent = false) {
if (!silent) {
this.loading = true
setTimeout(() => {
this.loading = false
}, 2000)
} else {
}
try {
const res = await getStrategyNotifications({ limit: 50 })
if (res.code === 1 && res.data?.items) {
// payload_json
this.notifications = res.data.items.map(item => {
let payload = item.payload_json
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload)
} catch (e) {
payload = {}
}
}
return {
...item,
payload,
is_read: item.is_read === 1 || item.is_read === true
}
})
if (this.notifications.length > 0) {
this.lastFetchId = Math.max(...this.notifications.map(n => n.id))
}
}
} catch (e) {
console.error('Failed to fetch notifications:', e)
} finally {
this.loading = false
}
},
fetchNotice () {
if (!this.visible) {
this.fetchNotifications()
}
this.visible = !this.visible
},
getNoticeIcon (signalType) {
const iconMap = {
'ai_monitor': 'robot',
'price_alert': 'bell',
'signal': 'thunderbolt',
'buy': 'rise',
'sell': 'fall',
'hold': 'pause-circle',
'trade': 'swap'
}
return iconMap[signalType] || 'notification'
},
getNoticeColor (signalType) {
const colorMap = {
'ai_monitor': '#722ed1',
'price_alert': '#faad14',
'signal': '#1890ff',
'buy': '#52c41a',
'sell': '#f5222d',
'hold': '#faad14',
'trade': '#13c2c2'
}
return colorMap[signalType] || '#1890ff'
},
getNoticeTypeLabel (signalType) {
const labelMap = {
'ai_monitor': this.$t('notice.type.aiMonitor'),
'price_alert': this.$t('notice.type.priceAlert'),
'signal': this.$t('notice.type.signal'),
'buy': this.$t('notice.type.buy'),
'sell': this.$t('notice.type.sell'),
'hold': this.$t('notice.type.hold'),
'trade': this.$t('notice.type.trade')
}
return labelMap[signalType] || this.$t('notice.type.notification')
},
getDecisionColor (decision) {
const colorMap = {
'BUY': 'green',
'SELL': 'red',
'HOLD': 'orange'
}
return colorMap[decision] || 'blue'
},
truncateMessage (message) {
if (!message) return ''
return message.length > 80 ? message.substring(0, 80) + '...' : message
},
formatTime (timestamp) {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
const diff = now - date
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) {
return this.$t('notice.justNow')
} else if (minutes < 60) {
return `${minutes} ${this.$t('notice.minutesAgo')}`
} else if (hours < 24) {
return `${hours} ${this.$t('notice.hoursAgo')}`
} else if (days < 7) {
return `${days} ${this.$t('notice.daysAgo')}`
} else {
return date.toLocaleDateString()
}
},
formatFullTime (timestamp) {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
return date.toLocaleString()
},
formatMessageHtml (message) {
if (!message) return ''
// HTML AI Monitor
if (message.includes('<div class="qd-report">') || message.includes('<style>')) {
// HTML
return message
}
// Markdown
const html = message
// HTML
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
//
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
//
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
//
.replace(/\*(.+?)\*/g, '<em>$1</em>')
//
.replace(/^- (.+)$/gm, '<li>$1</li>')
//
.replace(/\n/g, '<br>')
return html
},
handleNoticeClick (item) {
//
this.markAsRead(item.id)
//
this.detailNotice = item
this.detailVisible = true
this.visible = false
},
goToPortfolio () {
this.detailVisible = false
this.$router.push({ path: '/portfolio' }).catch(() => {})
},
async markAsRead (id) {
const item = this.notifications.find(n => n.id === id)
if (item) {
item.is_read = true
}
// API
try {
await request({
url: '/api/strategies/notifications/read',
method: 'post',
data: { id }
})
} catch (e) {
//
}
},
async markAllRead () {
this.notifications.forEach(n => { n.is_read = true })
try {
await request({
url: '/api/strategies/notifications/read-all',
method: 'post'
})
} catch (e) {
//
}
},
async clearNotifications () {
this.notifications = []
try {
await request({
url: '/api/strategies/notifications/clear',
method: 'delete'
})
} catch (e) {
//
}
this.visible = false
}
}
}
</script>
<style lang="css">
.header-notice-wrapper {
top: 50px !important;
}
</style>
<style lang="less" scoped>
.header-notice{
display: inline-block;
transition: all 0.3s;
.notice-icon-wrapper {
display: inline-block;
}
span {
vertical-align: initial;
.header-notice {
display: inline-block;
transition: all 0.3s;
cursor: pointer;
padding: 0 12px;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
span {
vertical-align: initial;
}
}
.notice-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
.notice-title {
font-weight: 500;
font-size: 14px;
}
.notice-action {
font-size: 12px;
color: #1890ff;
cursor: pointer;
&:hover {
color: #40a9ff;
}
}
}
.notice-list {
max-height: 400px;
overflow-y: auto;
}
.notice-item {
display: flex;
padding: 12px 16px;
cursor: pointer;
transition: background 0.3s;
&:hover {
background: #f5f5f5;
}
&.unread {
background: #e6f7ff;
&:hover {
background: #bae7ff;
}
}
.notice-item-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
border-radius: 50%;
margin-right: 12px;
font-size: 16px;
}
.notice-item-content {
flex: 1;
min-width: 0;
.notice-item-title {
font-weight: 500;
font-size: 13px;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notice-item-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
margin-bottom: 4px;
}
.notice-item-time {
font-size: 11px;
color: rgba(0, 0, 0, 0.25);
}
}
}
.notice-empty {
padding: 48px 0;
}
.notice-footer {
text-align: center;
padding: 12px;
border-top: 1px solid #f0f0f0;
a {
color: #1890ff;
cursor: pointer;
&:hover {
color: #40a9ff;
}
}
}
/* 详情弹窗内容 */
.notice-detail {
.notice-detail-meta {
display: flex;
justify-content: space-between;
align-items: center;
.notice-detail-type {
display: flex;
align-items: center;
gap: 8px;
.type-label {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
}
.notice-detail-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
}
.notice-detail-content {
.message-body {
font-size: 14px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.85);
max-height: 300px;
overflow-y: auto;
padding: 8px 0;
h2, h3, h4 {
margin: 12px 0 8px;
font-weight: 600;
}
h2 { font-size: 18px; }
h3 { font-size: 16px; }
h4 { font-size: 14px; }
li {
margin-left: 20px;
list-style: disc;
}
strong {
font-weight: 600;
}
}
// HTML
&.html-report {
.message-body {
max-height: 70vh;
padding: 0;
margin: -16px -24px;
overflow-y: auto;
}
}
}
.notice-detail-extra {
.extra-title {
font-weight: 500;
font-size: 14px;
margin-bottom: 12px;
color: rgba(0, 0, 0, 0.85);
}
.extra-item {
display: flex;
align-items: flex-start;
margin-bottom: 8px;
font-size: 13px;
.label {
flex-shrink: 0;
color: rgba(0, 0, 0, 0.45);
margin-right: 8px;
}
.value {
color: rgba(0, 0, 0, 0.85);
word-break: break-word;
}
&.decision {
align-items: center;
.confidence {
margin-left: 8px;
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
}
}
}
.notice-detail-actions {
margin-top: 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
}
</style>
<style lang="less">
.header-notice-wrapper {
top: 50px !important;
.ant-popover-inner-content {
padding: 0;
}
}
/* 详情弹窗样式 */
.notice-detail-modal {
.ant-modal-header {
border-bottom: 1px solid #f0f0f0;
}
.ant-modal-body {
padding: 16px 24px;
}
// HTML
&.html-report-modal {
.ant-modal-body {
padding: 0;
}
}
}
/* 暗黑主题支持 */
body.dark,
body.realdark,
.ant-layout.dark,
.ant-layout.realdark {
.header-notice-wrapper {
.ant-popover-inner {
background: #1f1f1f;
}
.ant-popover-arrow {
border-color: #1f1f1f;
}
.notice-header {
border-color: #303030;
.notice-title {
color: rgba(255, 255, 255, 0.85);
}
}
.notice-item {
&:hover {
background: #303030;
}
&.unread {
background: rgba(24, 144, 255, 0.15);
&:hover {
background: rgba(24, 144, 255, 0.25);
}
}
.notice-item-icon {
background: #303030;
}
.notice-item-content {
.notice-item-title {
color: rgba(255, 255, 255, 0.85);
}
.notice-item-desc {
color: rgba(255, 255, 255, 0.45);
}
.notice-item-time {
color: rgba(255, 255, 255, 0.25);
}
}
}
.notice-footer {
border-color: #303030;
}
.ant-empty-description {
color: rgba(255, 255, 255, 0.45);
}
}
/* 详情弹窗暗黑主题 */
.notice-detail-modal {
.ant-modal-content {
background: #1f1f1f;
}
.ant-modal-header {
background: #1f1f1f;
border-color: #303030;
.ant-modal-title {
color: rgba(255, 255, 255, 0.85);
}
}
.ant-modal-close-x {
color: rgba(255, 255, 255, 0.45);
}
.ant-divider {
border-color: #303030;
}
.notice-detail {
.notice-detail-meta {
.notice-detail-type .type-label {
color: rgba(255, 255, 255, 0.65);
}
.notice-detail-time {
color: rgba(255, 255, 255, 0.45);
}
}
.notice-detail-content .message-body {
color: rgba(255, 255, 255, 0.85);
}
.notice-detail-extra {
.extra-title {
color: rgba(255, 255, 255, 0.85);
}
.extra-item {
.label {
color: rgba(255, 255, 255, 0.45);
}
.value {
color: rgba(255, 255, 255, 0.85);
}
.confidence {
color: rgba(255, 255, 255, 0.45);
}
}
}
}
}
}
</style>
@@ -37,6 +37,13 @@ export const asyncRouterMap = [
component: () => import('@/views/trading-assistant'),
meta: { title: 'menu.dashboard.tradingAssistant', keepAlive: true, icon: 'robot', permission: ['dashboard'] }
},
// 资产监测
{
path: '/portfolio',
name: 'Portfolio',
component: () => import('@/views/portfolio'),
meta: { title: 'menu.dashboard.portfolio', keepAlive: true, icon: 'fund', permission: ['dashboard'] }
},
// 指标社区(keepAlive disabled intentionally for iframe page)
{
path: '/indicator-community',
+2
View File
@@ -48,6 +48,7 @@ import {
Space,
Empty,
Rate,
AutoComplete,
message,
notification
} from 'ant-design-vue'
@@ -107,6 +108,7 @@ Vue.use(Descriptions)
Vue.use(Space)
Vue.use(Empty)
Vue.use(Rate)
Vue.use(AutoComplete)
// Textarea 是 Input 组件的一部分,通过 Vue.use(Input) 已自动注册
Vue.prototype.$confirm = Modal.confirm
+174 -1
View File
@@ -8,6 +8,15 @@ const components = {
}
const locale = {
// Common
'common.confirm': 'Confirm',
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.add': 'Add',
'common.close': 'Close',
'common.ok': 'OK',
'submit': 'Submit',
'save': 'Save',
'submit.ok': 'Submit successfully',
@@ -19,6 +28,7 @@ const locale = {
'menu.dashboard.indicator': 'Indicator Analysis',
'menu.dashboard.community': 'Indicator Community',
'menu.dashboard.tradingAssistant': 'Trading Assistant',
'menu.dashboard.portfolio': 'Portfolio',
'menu.settings': 'Settings',
'menu.dashboard.aiTradingAssistant': 'AI Trading Assistant',
'menu.dashboard.signalRobot': 'Signal Robot',
@@ -97,6 +107,36 @@ const locale = {
'app.setting.themecolor.geekblue': 'Geek Blue',
'app.setting.themecolor.purple': 'Golden Purple',
'app.setting.tooltip': 'Page Settings',
// Notification Center
'notice.title': 'Notifications',
'notice.empty': 'No notifications',
'notice.markAllRead': 'Mark all as read',
'notice.clear': 'Clear all',
'notice.close': 'Close',
'notice.justNow': 'Just now',
'notice.minutesAgo': 'minutes ago',
'notice.hoursAgo': 'hours ago',
'notice.daysAgo': 'days ago',
'notice.detailInfo': 'Details',
'notice.aiDecision': 'AI Decision',
'notice.confidence': 'Confidence',
'notice.reasoning': 'Reasoning',
'notice.symbol': 'Symbol',
'notice.currentPrice': 'Current Price',
'notice.triggerPrice': 'Trigger Price',
'notice.action': 'Action',
'notice.quantity': 'Quantity',
'notice.viewPortfolio': 'View Portfolio',
'notice.type.aiMonitor': 'AI Monitor',
'notice.type.priceAlert': 'Price Alert',
'notice.type.signal': 'Trade Signal',
'notice.type.buy': 'Buy Signal',
'notice.type.sell': 'Sell Signal',
'notice.type.hold': 'Hold Suggestion',
'notice.type.trade': 'Trade Execution',
'notice.type.notification': 'Notification',
'user.login.userName': 'userName',
'user.login.password': 'password',
'user.login.username.placeholder': 'Account: admin',
@@ -2049,7 +2089,140 @@ const locale = {
'settings.desc.MAKER_WAIT_SEC': 'Wait time for limit order fill before switching to market order',
'settings.desc.MAKER_OFFSET_BPS': 'Price offset in basis points. Buy: price*(1-offset), Sell: price*(1+offset)',
'settings.desc.TIINGO_API_KEY': 'Tiingo API key for Forex/Metals data (free tier does not support 1-minute data)',
'settings.desc.TIINGO_TIMEOUT': 'Tiingo API request timeout'
'settings.desc.TIINGO_TIMEOUT': 'Tiingo API request timeout',
// Portfolio
'portfolio.summary.totalValue': 'Total Value',
'portfolio.summary.totalCost': 'Total Cost',
'portfolio.summary.totalPnl': 'Total P&L',
'portfolio.summary.positionCount': 'Positions',
'portfolio.summary.profitLossRatio': 'Profit/Loss',
'portfolio.summary.today': 'Today',
'portfolio.summary.todayPnl': 'Today P&L',
'portfolio.summary.bestPerformer': 'Best Performer',
'portfolio.summary.worstPerformer': 'Worst Performer',
'portfolio.summary.priceSync': 'Price Sync',
'portfolio.summary.syncInterval': 'Refresh',
'portfolio.summary.justNow': 'Just now',
'portfolio.summary.ago': ' ago',
'portfolio.positions.title': 'My Positions',
'portfolio.positions.add': 'Add Position',
'portfolio.positions.addFirst': 'Add Your First Position',
'portfolio.positions.empty': 'No positions yet',
'portfolio.positions.deleteConfirm': 'Are you sure to delete this position?',
'portfolio.positions.currentPrice': 'Current',
'portfolio.positions.entryPrice': 'Entry',
'portfolio.positions.quantity': 'Quantity',
'portfolio.positions.side': 'Side',
'portfolio.positions.long': 'Long',
'portfolio.positions.short': 'Short',
'portfolio.positions.marketValue': 'Value',
'portfolio.positions.pnl': 'P&L',
'portfolio.positions.items': 'positions',
'portfolio.monitors.title': 'AI Monitors',
'portfolio.monitors.add': 'Add Monitor',
'portfolio.monitors.addFirst': 'Add AI Monitor',
'portfolio.monitors.empty': 'No monitors yet',
'portfolio.monitors.deleteConfirm': 'Are you sure to delete this monitor?',
'portfolio.monitors.interval': 'Interval',
'portfolio.monitors.lastRun': 'Last Run',
'portfolio.monitors.nextRun': 'Next Run',
'portfolio.monitors.channels': 'Channels',
'portfolio.monitors.runNow': 'Run Now',
'portfolio.monitors.analysisResult': 'AI Analysis Result',
'portfolio.modal.addPosition': 'Add Position',
'portfolio.modal.editPosition': 'Edit Position',
'portfolio.modal.addMonitor': 'Add Monitor',
'portfolio.modal.editMonitor': 'Edit Monitor',
'portfolio.form.market': 'Market',
'portfolio.form.marketRequired': 'Please select a market',
'portfolio.form.selectMarket': 'Select Market',
'portfolio.form.symbol': 'Symbol',
'portfolio.form.symbolRequired': 'Please enter symbol',
'portfolio.form.searchSymbol': 'Search or enter symbol',
'portfolio.form.useAsSymbol': 'Use',
'portfolio.form.asSymbolCode': 'as symbol code',
'portfolio.form.symbolHint': 'Search symbols or enter any code directly',
'portfolio.form.side': 'Side',
'portfolio.form.quantity': 'Quantity',
'portfolio.form.quantityRequired': 'Please enter quantity',
'portfolio.form.enterQuantity': 'Enter quantity',
'portfolio.form.entryPrice': 'Entry Price',
'portfolio.form.entryPriceRequired': 'Please enter entry price',
'portfolio.form.enterEntryPrice': 'Enter entry price',
'portfolio.form.notes': 'Notes',
'portfolio.form.enterNotes': 'Optional: Add notes',
'portfolio.form.monitorName': 'Monitor Name',
'portfolio.form.monitorNameRequired': 'Please enter monitor name',
'portfolio.form.enterMonitorName': 'e.g. Daily Portfolio Analysis',
'portfolio.form.interval': 'Interval',
'portfolio.form.minutes': 'minutes',
'portfolio.form.hour': 'hour',
'portfolio.form.hours': 'hours',
'portfolio.form.notifyChannels': 'Notify Channels',
'portfolio.form.browser': 'Browser',
'portfolio.form.email': 'Email',
'portfolio.form.telegramChatId': 'Telegram Chat ID',
'portfolio.form.enterTelegramChatId': 'Enter Telegram Chat ID',
'portfolio.form.telegramRequired': 'Please enter Telegram Chat ID',
'portfolio.form.emailAddress': 'Email Address',
'portfolio.form.enterEmail': 'Enter email address',
'portfolio.form.emailRequired': 'Please enter email address',
'portfolio.form.emailInvalid': 'Please enter a valid email address',
'portfolio.form.customPrompt': 'Custom Prompt',
'portfolio.form.customPromptPlaceholder': 'Optional: Add focus areas, e.g. "Focus on tech stock risks"',
'portfolio.form.monitorScope': 'Monitor Scope',
'portfolio.form.allPositions': 'All Positions',
'portfolio.form.selectedPositions': 'Selected Positions',
'portfolio.form.selectPositions': 'Select Positions',
'portfolio.form.selectAll': 'Select All',
'portfolio.form.deselectAll': 'Deselect All',
'portfolio.form.selectedCount': '{count} of {total} selected',
'portfolio.form.pleaseSelectPositions': 'Please select at least one position to monitor',
'portfolio.message.loadFailed': 'Failed to load data',
'portfolio.message.saveSuccess': 'Saved successfully',
'portfolio.message.saveFailed': 'Failed to save',
'portfolio.message.deleteSuccess': 'Deleted successfully',
'portfolio.message.deleteFailed': 'Failed to delete',
'portfolio.message.updateFailed': 'Failed to update',
'portfolio.message.monitorEnabled': 'Monitor enabled',
'portfolio.message.monitorDisabled': 'Monitor paused',
'portfolio.message.monitorRunSuccess': 'Analysis completed',
'portfolio.message.monitorRunFailed': 'Analysis failed',
// Portfolio - Groups
'portfolio.groups.all': 'All Positions',
'portfolio.groups.ungrouped': 'Ungrouped',
'portfolio.form.group': 'Group',
'portfolio.form.enterGroup': 'Enter or select group',
// Portfolio - Alerts
'portfolio.alerts.title': 'Price/PnL Alerts',
'portfolio.alerts.addAlert': 'Add Alert',
'portfolio.alerts.editAlert': 'Edit Alert',
'portfolio.alerts.alertType': 'Alert Type',
'portfolio.alerts.priceAbove': 'Price Above',
'portfolio.alerts.priceBelow': 'Price Below',
'portfolio.alerts.pnlAbove': 'Profit Above (%)',
'portfolio.alerts.pnlBelow': 'Loss Below (%)',
'portfolio.alerts.threshold': 'Threshold',
'portfolio.alerts.thresholdRequired': 'Please enter threshold',
'portfolio.alerts.enterPrice': 'Enter price',
'portfolio.alerts.enterPercent': 'Enter percentage',
'portfolio.alerts.currentPrice': 'Current Price',
'portfolio.alerts.currentPriceHint': 'Current price',
'portfolio.alerts.repeatInterval': 'Repeat Alert',
'portfolio.alerts.noRepeat': 'No repeat (trigger once)',
'portfolio.alerts.every5min': 'Every 5 minutes',
'portfolio.alerts.every15min': 'Every 15 minutes',
'portfolio.alerts.every30min': 'Every 30 minutes',
'portfolio.alerts.every1hour': 'Every 1 hour',
'portfolio.alerts.every4hours': 'Every 4 hours',
'portfolio.alerts.onceDaily': 'Once daily',
'portfolio.alerts.enabled': 'Enable Alert',
'portfolio.alerts.enabledDesc': 'Auto-monitor and trigger notifications',
'portfolio.alerts.delete': 'Delete',
'portfolio.alerts.deleteConfirm': 'Are you sure you want to delete this alert?',
'portfolio.modal.addAlert': 'Add Alert',
'portfolio.modal.editAlert': 'Edit Alert'
}
export default {
+174 -1
View File
@@ -8,6 +8,15 @@ momentLocale: momentCN
}
const locale = {
// 通用
'common.confirm': '确定',
'common.cancel': '取消',
'common.save': '保存',
'common.delete': '删除',
'common.edit': '编辑',
'common.add': '添加',
'common.close': '关闭',
'common.ok': '确定',
'submit': '提交',
'save': '保存',
'submit.ok': '提交成功',
@@ -19,6 +28,7 @@ const locale = {
'menu.dashboard.community': '指标社区',
'menu.dashboard.analysis': 'AI 分析',
'menu.dashboard.tradingAssistant': '交易助手',
'menu.dashboard.portfolio': '资产监测',
'menu.settings': '系统设置',
'menu.dashboard.aiTradingAssistant': 'AI交易助手',
'menu.dashboard.signalRobot': '信号机器人',
@@ -97,6 +107,36 @@ const locale = {
'app.setting.themecolor.geekblue': '极客蓝',
'app.setting.themecolor.purple': '酱紫',
'app.setting.tooltip': '页面设置',
// 通知中心
'notice.title': '通知中心',
'notice.empty': '暂无通知',
'notice.markAllRead': '全部已读',
'notice.clear': '清空通知',
'notice.close': '关闭',
'notice.justNow': '刚刚',
'notice.minutesAgo': '分钟前',
'notice.hoursAgo': '小时前',
'notice.daysAgo': '天前',
'notice.detailInfo': '详细信息',
'notice.aiDecision': 'AI决策',
'notice.confidence': '置信度',
'notice.reasoning': '分析理由',
'notice.symbol': '标的代码',
'notice.currentPrice': '当前价格',
'notice.triggerPrice': '触发价格',
'notice.action': '操作',
'notice.quantity': '数量',
'notice.viewPortfolio': '查看持仓',
'notice.type.aiMonitor': 'AI监控',
'notice.type.priceAlert': '价格提醒',
'notice.type.signal': '交易信号',
'notice.type.buy': '买入信号',
'notice.type.sell': '卖出信号',
'notice.type.hold': '持有建议',
'notice.type.trade': '交易执行',
'notice.type.notification': '系统通知',
'user.login.userName': '用户名',
'user.login.password': '密码',
'user.login.username.placeholder': '账户: admin',
@@ -1858,7 +1898,140 @@ const locale = {
'settings.desc.RATE_LIMIT': '每IP每分钟的API请求限制',
'settings.desc.ENABLE_CACHE': '启用响应缓存以提高性能',
'settings.desc.ENABLE_REQUEST_LOG': '记录所有API请求日志,用于调试',
'settings.desc.ENABLE_AI_ANALYSIS': '启用AI驱动的市场分析功能'
'settings.desc.ENABLE_AI_ANALYSIS': '启用AI驱动的市场分析功能',
// Portfolio - 资产监测
'portfolio.summary.totalValue': '总市值',
'portfolio.summary.totalCost': '总成本',
'portfolio.summary.totalPnl': '总盈亏',
'portfolio.summary.positionCount': '持仓数量',
'portfolio.summary.profitLossRatio': '盈利/亏损',
'portfolio.summary.today': '今日',
'portfolio.summary.todayPnl': '今日盈亏',
'portfolio.summary.bestPerformer': '最佳表现',
'portfolio.summary.worstPerformer': '最差表现',
'portfolio.summary.priceSync': '价格同步',
'portfolio.summary.syncInterval': '刷新间隔',
'portfolio.summary.justNow': '刚刚',
'portfolio.summary.ago': '前',
'portfolio.positions.title': '我的持仓',
'portfolio.positions.add': '添加持仓',
'portfolio.positions.addFirst': '添加第一笔持仓',
'portfolio.positions.empty': '暂无持仓记录',
'portfolio.positions.deleteConfirm': '确定删除这笔持仓吗?',
'portfolio.positions.currentPrice': '现价',
'portfolio.positions.entryPrice': '买入价',
'portfolio.positions.quantity': '数量',
'portfolio.positions.side': '方向',
'portfolio.positions.long': '做多',
'portfolio.positions.short': '做空',
'portfolio.positions.marketValue': '市值',
'portfolio.positions.pnl': '盈亏',
'portfolio.positions.items': '个持仓',
'portfolio.monitors.title': 'AI 监控',
'portfolio.monitors.add': '添加监控',
'portfolio.monitors.addFirst': '添加 AI 监控',
'portfolio.monitors.empty': '暂无监控任务',
'portfolio.monitors.deleteConfirm': '确定删除这个监控任务吗?',
'portfolio.monitors.interval': '执行间隔',
'portfolio.monitors.lastRun': '上次执行',
'portfolio.monitors.nextRun': '下次执行',
'portfolio.monitors.channels': '通知渠道',
'portfolio.monitors.runNow': '立即执行',
'portfolio.monitors.analysisResult': 'AI 分析结果',
'portfolio.modal.addPosition': '添加持仓',
'portfolio.modal.editPosition': '编辑持仓',
'portfolio.modal.addMonitor': '添加监控',
'portfolio.modal.editMonitor': '编辑监控',
'portfolio.form.market': '市场',
'portfolio.form.marketRequired': '请选择市场',
'portfolio.form.selectMarket': '选择市场',
'portfolio.form.symbol': '标的代码',
'portfolio.form.symbolRequired': '请输入标的代码',
'portfolio.form.searchSymbol': '搜索或输入标的代码',
'portfolio.form.useAsSymbol': '使用',
'portfolio.form.asSymbolCode': '作为标的代码',
'portfolio.form.symbolHint': '可搜索标的库,或直接输入任意代码',
'portfolio.form.side': '方向',
'portfolio.form.quantity': '数量',
'portfolio.form.quantityRequired': '请输入数量',
'portfolio.form.enterQuantity': '输入持仓数量',
'portfolio.form.entryPrice': '买入价',
'portfolio.form.entryPriceRequired': '请输入买入价',
'portfolio.form.enterEntryPrice': '输入买入价格',
'portfolio.form.notes': '备注',
'portfolio.form.enterNotes': '可选:添加备注',
'portfolio.form.monitorName': '监控名称',
'portfolio.form.monitorNameRequired': '请输入监控名称',
'portfolio.form.enterMonitorName': '例如:每日组合分析',
'portfolio.form.interval': '执行间隔',
'portfolio.form.minutes': '分钟',
'portfolio.form.hour': '小时',
'portfolio.form.hours': '小时',
'portfolio.form.notifyChannels': '通知渠道',
'portfolio.form.browser': '浏览器通知',
'portfolio.form.email': '邮件',
'portfolio.form.telegramChatId': 'Telegram Chat ID',
'portfolio.form.enterTelegramChatId': '输入 Telegram Chat ID',
'portfolio.form.telegramRequired': '请输入 Telegram Chat ID',
'portfolio.form.emailAddress': '邮箱地址',
'portfolio.form.enterEmail': '输入邮箱地址',
'portfolio.form.emailRequired': '请输入邮箱地址',
'portfolio.form.emailInvalid': '请输入有效的邮箱地址',
'portfolio.form.customPrompt': '自定义提示',
'portfolio.form.customPromptPlaceholder': '可选:添加特别关注点,例如"重点关注科技股风险"',
'portfolio.form.monitorScope': '监控范围',
'portfolio.form.allPositions': '全部持仓',
'portfolio.form.selectedPositions': '指定持仓',
'portfolio.form.selectPositions': '选择持仓',
'portfolio.form.selectAll': '全选',
'portfolio.form.deselectAll': '全不选',
'portfolio.form.selectedCount': '已选 {count}/{total}',
'portfolio.form.pleaseSelectPositions': '请至少选择一个持仓进行监控',
'portfolio.message.loadFailed': '加载数据失败',
'portfolio.message.saveSuccess': '保存成功',
'portfolio.message.saveFailed': '保存失败',
'portfolio.message.deleteSuccess': '删除成功',
'portfolio.message.deleteFailed': '删除失败',
'portfolio.message.updateFailed': '更新失败',
'portfolio.message.monitorEnabled': '监控已启用',
'portfolio.message.monitorDisabled': '监控已暂停',
'portfolio.message.monitorRunSuccess': '分析完成',
'portfolio.message.monitorRunFailed': '分析失败',
// Portfolio - 分组
'portfolio.groups.all': '全部持仓',
'portfolio.groups.ungrouped': '未分组',
'portfolio.form.group': '分组',
'portfolio.form.enterGroup': '输入或选择分组',
// Portfolio - 预警
'portfolio.alerts.title': '价格/盈亏预警',
'portfolio.alerts.addAlert': '添加预警',
'portfolio.alerts.editAlert': '编辑预警',
'portfolio.alerts.alertType': '预警类型',
'portfolio.alerts.priceAbove': '价格高于',
'portfolio.alerts.priceBelow': '价格低于',
'portfolio.alerts.pnlAbove': '盈利高于 (%)',
'portfolio.alerts.pnlBelow': '亏损低于 (%)',
'portfolio.alerts.threshold': '阈值',
'portfolio.alerts.thresholdRequired': '请输入阈值',
'portfolio.alerts.enterPrice': '输入价格',
'portfolio.alerts.enterPercent': '输入百分比',
'portfolio.alerts.currentPrice': '当前价格',
'portfolio.alerts.currentPriceHint': '当前价格',
'portfolio.alerts.repeatInterval': '重复提醒',
'portfolio.alerts.noRepeat': '不重复 (触发一次)',
'portfolio.alerts.every5min': '每 5 分钟',
'portfolio.alerts.every15min': '每 15 分钟',
'portfolio.alerts.every30min': '每 30 分钟',
'portfolio.alerts.every1hour': '每 1 小时',
'portfolio.alerts.every4hours': '每 4 小时',
'portfolio.alerts.onceDaily': '每天一次',
'portfolio.alerts.enabled': '启用预警',
'portfolio.alerts.enabledDesc': '开启后将自动监测并触发通知',
'portfolio.alerts.delete': '删除',
'portfolio.alerts.deleteConfirm': '确定要删除此预警吗?',
'portfolio.modal.addAlert': '添加预警',
'portfolio.modal.editAlert': '编辑预警'
}
export default {
+5 -1
View File
@@ -1,7 +1,11 @@
import { isIE } from '@/utils/util'
// 本地开发已有完整后端 API,禁用 mock 以避免请求被拦截
// 如需启用 mock,将下面的 false 改为 true
const ENABLE_MOCK = false
// 判断环境不是 prod 或者 preview 是 true 时,加载 mock 服务
if (process.env.NODE_ENV !== 'production' || process.env.VUE_APP_PREVIEW === 'true') {
if (ENABLE_MOCK && (process.env.NODE_ENV !== 'production' || process.env.VUE_APP_PREVIEW === 'true')) {
if (isIE()) {
}
// 使用同步加载依赖
File diff suppressed because it is too large Load Diff