Files
DinQuant/backend_api_python/app/routes/strategy.py
T
TIANHE 63f0b118a1 new
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2026-01-12 23:10:47 +08:00

811 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
交易策略 API 路由
"""
from flask import Blueprint, request, jsonify
import traceback
import time
from app.services.strategy import StrategyService
from app.services.strategy_compiler import StrategyCompiler
from app.services.backtest import BacktestService
from app import get_trading_executor
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
from app.data_sources import DataSourceFactory
logger = get_logger(__name__)
strategy_bp = Blueprint('strategy', __name__)
# Local mode: avoid heavy initialization during module import.
# Instantiate services lazily on first use to keep startup clean.
_strategy_service = None
def get_strategy_service() -> StrategyService:
global _strategy_service
if _strategy_service is None:
_strategy_service = StrategyService()
return _strategy_service
@strategy_bp.route('/strategies', methods=['GET'])
def list_strategies():
"""
策略列表(本地版:单用户)
"""
try:
user_id = request.args.get('user_id', type=int) or 1
items = get_strategy_service().list_strategies(user_id=user_id)
return jsonify({'code': 1, 'msg': 'success', 'data': {'strategies': items}})
except Exception as e:
logger.error(f"list_strategies failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': {'strategies': []}}), 500
@strategy_bp.route('/strategies/detail', methods=['GET'])
def get_strategy_detail():
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400
st = get_strategy_service().get_strategy(strategy_id)
if not st:
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404
return jsonify({'code': 1, 'msg': 'success', 'data': st})
except Exception as e:
logger.error(f"get_strategy_detail failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/create', methods=['POST'])
def create_strategy():
try:
payload = request.get_json() or {}
# Local mode default user
payload['user_id'] = int(payload.get('user_id') or 1)
payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy'
new_id = get_strategy_service().create_strategy(payload)
return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}})
except Exception as e:
logger.error(f"create_strategy failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/batch-create', methods=['POST'])
def batch_create_strategies():
"""
批量创建策略(多币种)
请求体:
strategy_name: 策略基础名称
symbols: 币种数组,如 ["Crypto:BTC/USDT", "Crypto:ETH/USDT"]
... 其他策略配置
"""
try:
payload = request.get_json() or {}
payload['user_id'] = int(payload.get('user_id') or 1)
payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy'
result = get_strategy_service().batch_create_strategies(payload)
if result['success']:
return jsonify({
'code': 1,
'msg': f"成功创建 {result['total_created']} 个策略",
'data': result
})
else:
return jsonify({
'code': 0,
'msg': '批量创建失败',
'data': result
})
except Exception as e:
logger.error(f"batch_create_strategies failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/batch-start', methods=['POST'])
def batch_start_strategies():
"""
批量启动策略
请求体:
strategy_ids: 策略ID数组
strategy_group_id: 策略组ID
"""
try:
payload = request.get_json() or {}
strategy_ids = payload.get('strategy_ids') or []
strategy_group_id = payload.get('strategy_group_id')
# 如果提供了策略组ID,获取组内所有策略
if strategy_group_id and not strategy_ids:
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id)
if not strategy_ids:
return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400
# 先更新数据库状态
result = get_strategy_service().batch_start_strategies(strategy_ids)
# 然后启动执行器
executor = get_trading_executor()
for sid in result.get('success_ids', []):
try:
executor.start_strategy(sid)
except Exception as e:
logger.error(f"Failed to start executor for strategy {sid}: {e}")
return jsonify({
'code': 1 if result['success'] else 0,
'msg': f"成功启动 {len(result.get('success_ids', []))} 个策略",
'data': result
})
except Exception as e:
logger.error(f"batch_start_strategies failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/batch-stop', methods=['POST'])
def batch_stop_strategies():
"""
批量停止策略
请求体:
strategy_ids: 策略ID数组
strategy_group_id: 策略组ID
"""
try:
payload = request.get_json() or {}
strategy_ids = payload.get('strategy_ids') or []
strategy_group_id = payload.get('strategy_group_id')
if strategy_group_id and not strategy_ids:
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id)
if not strategy_ids:
return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400
# 先停止执行器
executor = get_trading_executor()
for sid in strategy_ids:
try:
executor.stop_strategy(sid)
except Exception as e:
logger.error(f"Failed to stop executor for strategy {sid}: {e}")
# 然后更新数据库状态
result = get_strategy_service().batch_stop_strategies(strategy_ids)
return jsonify({
'code': 1 if result['success'] else 0,
'msg': f"成功停止 {len(result.get('success_ids', []))} 个策略",
'data': result
})
except Exception as e:
logger.error(f"batch_stop_strategies failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/batch-delete', methods=['DELETE'])
def batch_delete_strategies():
"""
批量删除策略
请求体:
strategy_ids: 策略ID数组
strategy_group_id: 策略组ID
"""
try:
payload = request.get_json() or {}
strategy_ids = payload.get('strategy_ids') or []
strategy_group_id = payload.get('strategy_group_id')
if strategy_group_id and not strategy_ids:
strategy_ids = get_strategy_service().get_strategies_by_group(strategy_group_id)
if not strategy_ids:
return jsonify({'code': 0, 'msg': '请提供策略ID', 'data': None}), 400
# 先停止执行器
executor = get_trading_executor()
for sid in strategy_ids:
try:
executor.stop_strategy(sid)
except Exception as e:
pass # 忽略停止错误
# 然后删除
result = get_strategy_service().batch_delete_strategies(strategy_ids)
return jsonify({
'code': 1 if result['success'] else 0,
'msg': f"成功删除 {len(result.get('success_ids', []))} 个策略",
'data': result
})
except Exception as e:
logger.error(f"batch_delete_strategies failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/update', methods=['PUT'])
def update_strategy():
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400
payload = request.get_json() or {}
ok = get_strategy_service().update_strategy(strategy_id, payload)
if not ok:
return jsonify({'code': 0, 'msg': 'Strategy not found', 'data': None}), 404
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"update_strategy failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/delete', methods=['DELETE'])
def delete_strategy():
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': None}), 400
ok = get_strategy_service().delete_strategy(strategy_id)
return jsonify({'code': 1 if ok else 0, 'msg': 'success' if ok else 'failed', 'data': None})
except Exception as e:
logger.error(f"delete_strategy failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/trades', methods=['GET'])
def get_trades():
"""交易记录(从本地 SQLite 读取)"""
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': {'trades': [], 'items': []}}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, strategy_id, symbol, type, price, amount, value, commission, commission_ccy, profit, created_at
FROM qd_strategy_trades
WHERE strategy_id = ?
ORDER BY id DESC
""",
(strategy_id,)
)
rows = cur.fetchall() or []
cur.close()
# Frontend expects data.trades; keep data.items for compatibility with list-style components.
return jsonify({'code': 1, 'msg': 'success', 'data': {'trades': rows, 'items': rows}})
except Exception as e:
logger.error(f"get_trades failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': {'trades': [], 'items': []}}), 500
@strategy_bp.route('/strategies/positions', methods=['GET'])
def get_positions():
"""持仓记录(从本地 SQLite 读取)"""
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': {'positions': [], 'items': []}}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, strategy_id, symbol, side, size, entry_price, current_price, highest_price,
unrealized_pnl, pnl_percent, equity, updated_at
FROM qd_strategy_positions
WHERE strategy_id = ?
ORDER BY id DESC
""",
(strategy_id,)
)
rows = cur.fetchall() or []
cur.close()
# Sync current price and PnL on read (frontend polls every few seconds).
def _calc_unrealized_pnl(side: str, entry_price: float, current_price: float, size: float) -> float:
ep = float(entry_price or 0.0)
cp = float(current_price or 0.0)
sz = float(size or 0.0)
if ep <= 0 or cp <= 0 or sz <= 0:
return 0.0
s = (side or "").strip().lower()
if s == "short":
return (ep - cp) * sz
return (cp - ep) * sz
def _calc_pnl_percent(entry_price: float, size: float, pnl: float) -> float:
ep = float(entry_price or 0.0)
sz = float(size or 0.0)
denom = ep * sz
if denom <= 0:
return 0.0
return float(pnl) / denom * 100.0
now = int(time.time())
# Fetch prices once per symbol to reduce API calls.
sym_to_price: dict[str, float] = {}
ds = DataSourceFactory.get_source("Crypto")
for r in rows:
sym = (r.get("symbol") or "").strip()
if not sym:
continue
if sym in sym_to_price:
continue
try:
t = ds.get_ticker(sym) or {}
px = float(t.get("last") or t.get("close") or 0.0)
if px > 0:
sym_to_price[sym] = px
except Exception:
continue
# Apply to rows and persist best-effort
out = []
with get_db_connection() as db:
cur = db.cursor()
for r in rows:
sym = (r.get("symbol") or "").strip()
side = (r.get("side") or "").strip().lower()
entry = float(r.get("entry_price") or 0.0)
size = float(r.get("size") or 0.0)
cp = float(sym_to_price.get(sym) or r.get("current_price") or 0.0)
pnl = _calc_unrealized_pnl(side, entry, cp, size)
pct = _calc_pnl_percent(entry, size, pnl)
rr = dict(r)
rr["current_price"] = float(cp or 0.0)
rr["unrealized_pnl"] = float(pnl)
rr["pnl_percent"] = float(pct)
rr["updated_at"] = now
out.append(rr)
try:
cur.execute(
"""
UPDATE qd_strategy_positions
SET current_price = ?, unrealized_pnl = ?, pnl_percent = ?, updated_at = ?
WHERE id = ?
""",
(float(cp or 0.0), float(pnl), float(pct), int(now), int(rr.get("id"))),
)
except Exception:
pass
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'positions': out, 'items': out}})
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': {'positions': [], 'items': []}}), 500
@strategy_bp.route('/strategies/equityCurve', methods=['GET'])
def get_equity_curve():
"""净值曲线(本地简单计算:initial_capital + 累计 profit"""
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': 'Missing strategy id parameter', 'data': []}), 400
st = get_strategy_service().get_strategy(strategy_id) or {}
initial = float(st.get('initial_capital') or (st.get('trading_config') or {}).get('initial_capital') or 0)
if initial <= 0:
initial = 1000.0
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT created_at, profit
FROM qd_strategy_trades
WHERE strategy_id = ?
ORDER BY created_at ASC
""",
(strategy_id,)
)
rows = cur.fetchall() or []
cur.close()
equity = initial
curve = []
for r in rows:
try:
equity += float(r.get('profit') or 0)
except Exception:
pass
ts = int(r.get('created_at') or time.time())
curve.append({'time': ts, 'equity': equity})
return jsonify({'code': 1, 'msg': 'success', 'data': curve})
except Exception as e:
logger.error(f"get_equity_curve failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
@strategy_bp.route('/strategies/stop', methods=['POST'])
def stop_strategy():
"""
停止策略
参数:
id: 策略ID
"""
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({
'code': 0,
'msg': 'Missing strategy id parameter',
'data': None
}), 400
# 获取策略类型
strategy_type = get_strategy_service().get_strategy_type(strategy_id)
# Local backend: AI strategy executor was removed. Only indicator strategies are supported.
if strategy_type == 'PromptBasedStrategy':
return jsonify({'code': 0, 'msg': 'AI strategy has been removed; local edition does not support starting/stopping AI strategies', 'data': None}), 400
# 指标策略
get_trading_executor().stop_strategy(strategy_id)
# 更新策略状态
get_strategy_service().update_strategy_status(strategy_id, 'stopped')
return jsonify({
'code': 1,
'msg': 'Stopped successfully',
'data': None
})
except Exception as e:
logger.error(f"Failed to stop strategy: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'Failed to stop strategy: {str(e)}',
'data': None
}), 500
@strategy_bp.route('/strategies/start', methods=['POST'])
def start_strategy():
"""
启动策略
参数:
id: 策略ID
"""
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({
'code': 0,
'msg': 'Missing strategy id parameter',
'data': None
}), 400
# 获取策略类型
strategy_type = get_strategy_service().get_strategy_type(strategy_id)
# 更新策略状态
get_strategy_service().update_strategy_status(strategy_id, 'running')
# Local backend: AI strategy executor was removed. Only indicator strategies are supported.
if strategy_type == 'PromptBasedStrategy':
return jsonify({'code': 0, 'msg': 'AI strategy has been removed; local edition does not support starting AI strategies', 'data': None}), 400
# 指标策略
success = get_trading_executor().start_strategy(strategy_id)
if not success:
# 如果启动失败,恢复状态
get_strategy_service().update_strategy_status(strategy_id, 'stopped')
return jsonify({
'code': 0,
'msg': 'Failed to start strategy executor',
'data': None
}), 500
return jsonify({
'code': 1,
'msg': 'Started successfully',
'data': None
})
except Exception as e:
logger.error(f"Failed to start strategy: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'Failed to start strategy: {str(e)}',
'data': None
}), 500
@strategy_bp.route('/strategies/test-connection', methods=['POST'])
def test_connection():
"""
测试交易所连接
请求体:
exchange_config: 交易所配置
"""
try:
data = request.get_json() or {}
# 记录请求数据(用于调试,但不记录敏感信息)
logger.debug(f"Connection test request keys: {list(data.keys())}")
# 获取交易所配置
exchange_config = data.get('exchange_config', data)
# Local deployment: no encryption/decryption; accept dict or JSON string.
if isinstance(exchange_config, str):
try:
import json
exchange_config = json.loads(exchange_config)
except Exception:
pass
# 验证 exchange_config 是否为字典
if not isinstance(exchange_config, dict):
logger.error(f"Invalid exchange_config type: {type(exchange_config)}, data: {str(exchange_config)[:200]}")
# Frontend expects HTTP 200 with {code:0} for business failures.
return jsonify({'code': 0, 'msg': 'Invalid exchange config format; please check your payload', 'data': None})
# 验证必要字段
if not exchange_config.get('exchange_id'):
return jsonify({'code': 0, 'msg': 'Please select an exchange', 'data': None})
api_key = exchange_config.get('api_key', '')
secret_key = exchange_config.get('secret_key', '')
# 详细日志排查
logger.info(f"Testing connection: exchange_id={exchange_config.get('exchange_id')}")
logger.info(f"API Key: {api_key[:5]}... (len={len(api_key)})")
logger.info(f"Secret Key: {secret_key[:5]}... (len={len(secret_key)})")
# 检查是否有特殊字符
if api_key.strip() != api_key:
logger.warning("API key contains leading/trailing whitespace")
if secret_key.strip() != secret_key:
logger.warning("Secret key contains leading/trailing whitespace")
if not api_key or not secret_key:
return jsonify({'code': 0, 'msg': 'Please provide API key and secret key', 'data': None})
result = get_strategy_service().test_exchange_connection(exchange_config)
if result['success']:
return jsonify({'code': 1, 'msg': result.get('message') or 'Connection successful', 'data': result.get('data')})
# Always return HTTP 200 for business-level failures.
return jsonify({'code': 0, 'msg': result.get('message') or 'Connection failed', 'data': result.get('data')})
except Exception as e:
logger.error(f"Connection test failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'Connection test failed: {str(e)}',
'data': None
}), 500
@strategy_bp.route('/strategies/get-symbols', methods=['POST'])
def get_symbols():
"""
获取交易所交易对列表
请求体:
exchange_config: 交易所配置
"""
try:
data = request.get_json() or {}
exchange_config = data.get('exchange_config', data)
result = get_strategy_service().get_exchange_symbols(exchange_config)
if result['success']:
return jsonify({
'code': 1,
'msg': result['message'],
'data': {
'symbols': result['symbols']
}
})
else:
return jsonify({
'code': 0,
'msg': result['message'],
'data': {
'symbols': []
}
})
except Exception as e:
logger.error(f"Failed to fetch symbols: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'Failed to fetch symbols: {str(e)}',
'data': {
'symbols': []
}
}), 500
@strategy_bp.route('/strategies/preview-compile', methods=['POST'])
def preview_compile():
"""
预览编译后的策略结果
"""
try:
data = request.get_json() or {}
# strategy_config is passed as 'config'
config = data.get('config')
if not config:
return jsonify({'code': 0, 'msg': 'Missing config'}), 400
# Compile
compiler = StrategyCompiler()
try:
code = compiler.compile(config)
except Exception as e:
return jsonify({'code': 0, 'msg': f'Compilation failed: {str(e)}'}), 400
# Execute
symbol = config.get('symbol', 'BTC/USDT')
timeframe = config.get('timeframe', '4h')
backtest_service = BacktestService()
result = backtest_service.run_code_strategy(
code=code,
symbol=symbol,
timeframe=timeframe,
limit=500
)
if result.get('error'):
return jsonify({'code': 0, 'msg': f"Execution failed: {result['error']}"}), 400
return jsonify({
'code': 1,
'msg': 'Success',
'data': result
})
except Exception as e:
logger.error(f"Preview failed: {e}")
return jsonify({'code': 0, 'msg': str(e)}), 500
@strategy_bp.route('/strategies/notifications', methods=['GET'])
def get_strategy_notifications():
"""
Strategy signal notifications (browser channel persistence).
Query:
- id: strategy id (optional)
- limit: default 50, max 200
- since_id: return rows with id > since_id (optional)
"""
try:
strategy_id = request.args.get('id', type=int)
limit = request.args.get('limit', type=int) or 50
limit = max(1, min(200, int(limit)))
since_id = request.args.get('since_id', type=int) or 0
where = []
args = []
if strategy_id:
where.append("strategy_id = ?")
args.append(int(strategy_id))
if since_id:
where.append("id > ?")
args.append(int(since_id))
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
f"""
SELECT *
FROM qd_strategy_notifications
{where_sql}
ORDER BY id DESC
LIMIT ?
""",
tuple(args + [int(limit)]),
)
rows = cur.fetchall() or []
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': rows}})
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
@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