feat: add Portfolio Management module with AI monitoring
This commit is contained in:
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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**:点击显示后复制(妥善保管)
|
||||
|
||||

|
||||
|
||||
> ⚠️ **安全提示**: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)
|
||||
@@ -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)
|
||||
@@ -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】
|
||||

|
||||
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)
|
||||
|
||||
@@ -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 url,replace your bot token,token like【123:abc】
|
||||

|
||||
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)
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// 标题
|
||||
.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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user