feat: Add cross-sectional strategy support

- Add cross-sectional strategy type (single vs cross-sectional)
- Support multi-symbol portfolio management with automatic ranking
- Add portfolio size, long ratio, and rebalance frequency configuration
- Implement parallel order execution for cross-sectional strategies
- Add frontend UI for strategy type selection and configuration
- Add i18n support (Chinese and English) for cross-sectional features
- Fix decimal precision issues in exchange order quantities
- Add last_rebalance_at field to database schema
- Add comprehensive documentation and examples

Database migration required: Add last_rebalance_at column to qd_strategies_trading table
This commit is contained in:
TIANHE
2026-02-10 15:18:45 +08:00
parent a89cc9bee9
commit a51184497d
19 changed files with 1517 additions and 36 deletions
@@ -427,6 +427,11 @@ def get_positions():
pct = _calc_pnl_percent(entry, size, pnl)
rr = dict(r)
# 确保 entry_price 有值(如果数据库中是 NULL,使用计算出的 entry 值)
if not rr.get("entry_price") or float(rr.get("entry_price") or 0.0) <= 0:
rr["entry_price"] = float(entry or 0.0)
else:
rr["entry_price"] = float(rr.get("entry_price") or 0.0)
rr["current_price"] = float(cp or 0.0)
rr["unrealized_pnl"] = float(pnl)
rr["pnl_percent"] = float(pct)
@@ -47,11 +47,51 @@ class BinanceFuturesClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision.
This method ensures the output string doesn't exceed the required precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
# Normalize to remove unnecessary trailing zeros from internal representation
normalized = d.normalize()
# Convert to string using fixed-point notation
# Use a reasonable max_decimals to avoid excessive precision
# Binance typically uses 8 decimal places for most symbols
s = format(normalized, f".{max_decimals}f")
# Remove trailing zeros and decimal point if not needed
# This ensures we don't send "0.02874400" when "0.028744" is sufficient
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
# Fallback: try to convert safely
try:
# If Decimal conversion fails, try float with limited precision
f = float(d)
if f == 0:
return "0"
# Format with max_decimals and remove trailing zeros
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
# Last resort: convert to string
s = str(d)
# Try to remove scientific notation if present
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -237,12 +277,34 @@ class BinanceFuturesClient(BaseRestClient):
if step > 0:
q = self._floor_to_step(q, step)
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
# First try to get precision from metadata
qty_precision = None
try:
meta = fdict.get("_meta") or {}
q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None))
if isinstance(meta, dict):
qty_precision = meta.get("quantityPrecision")
except Exception:
pass
# If precision not available, infer from stepSize
if qty_precision is None and step > 0:
try:
# stepSize like "0.001" means 3 decimal places
step_str = str(step).rstrip('0')
if '.' in step_str:
qty_precision = len(step_str.split('.')[1])
else:
# If stepSize is 1 or larger, precision is 0
qty_precision = 0
except Exception:
pass
# Apply precision limit
if qty_precision is not None:
q = self._floor_to_precision(q, qty_precision)
if min_qty > 0 and q < min_qty:
return Decimal("0")
return q
@@ -38,11 +38,51 @@ class BinanceSpotClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision.
This method ensures the output string doesn't exceed the required precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
# Normalize to remove unnecessary trailing zeros from internal representation
normalized = d.normalize()
# Convert to string using fixed-point notation
# Use a reasonable max_decimals to avoid excessive precision
# Binance typically uses 8 decimal places for most symbols
s = format(normalized, f".{max_decimals}f")
# Remove trailing zeros and decimal point if not needed
# This ensures we don't send "0.02874400" when "0.028744" is sufficient
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
# Fallback: try to convert safely
try:
# If Decimal conversion fails, try float with limited precision
f = float(d)
if f == 0:
return "0"
# Format with max_decimals and remove trailing zeros
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
# Last resort: convert to string
s = str(d)
# Try to remove scientific notation if present
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -217,12 +257,34 @@ class BinanceSpotClient(BaseRestClient):
if step > 0:
q = self._floor_to_step(q, step)
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
# First try to get precision from metadata
qty_precision = None
try:
meta = fdict.get("_meta") or {}
q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None))
if isinstance(meta, dict):
qty_precision = meta.get("quantityPrecision")
except Exception:
pass
# If precision not available, infer from stepSize
if qty_precision is None and step > 0:
try:
# stepSize like "0.001" means 3 decimal places
step_str = str(step).rstrip('0')
if '.' in step_str:
qty_precision = len(step_str.split('.')[1])
else:
# If stepSize is 1 or larger, precision is 0
qty_precision = 0
except Exception:
pass
# Apply precision limit
if qty_precision is not None:
q = self._floor_to_precision(q, qty_precision)
if min_qty > 0 and q < min_qty:
return Decimal("0")
return q
@@ -54,11 +54,39 @@ class BitgetMixClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Bitget requires quantities to match sizeStep/sizePlace precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -54,11 +54,39 @@ class BitgetSpotClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Bitget requires quantities to match quantityStep/quantityScale precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -61,11 +61,39 @@ class BybitClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Bybit requires quantities to match qtyStep precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -74,11 +74,39 @@ class DeepcoinClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Deepcoin requires quantities to match lotSz/qtyStep precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -52,14 +52,41 @@ class OkxClient(BaseRestClient):
self._lev_cache_ttl_sec = 60.0
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to a non-scientific string (OKX expects plain decimal strings).
Convert Decimal to a non-scientific string with controlled precision.
OKX expects plain decimal strings matching lotSz precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
# Normalize to remove unnecessary trailing zeros
normalized = d.normalize()
# Format with max_decimals and remove trailing zeros
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _to_dec(x: Any) -> Decimal:
@@ -559,6 +559,21 @@ class StrategyService:
initial_capital = (trading_config or {}).get('initial_capital') or payload.get('initial_capital') or 1000
leverage = (trading_config or {}).get('leverage') or 1
market_type = (trading_config or {}).get('market_type') or 'swap'
# Cross-sectional strategy fields (store in trading_config to avoid DB schema changes)
cs_strategy_type = payload.get('cs_strategy_type') or trading_config.get('cs_strategy_type') or 'single'
symbol_list = payload.get('symbol_list') or trading_config.get('symbol_list') or []
portfolio_size = payload.get('portfolio_size') or trading_config.get('portfolio_size') or 10
long_ratio = float(payload.get('long_ratio') or trading_config.get('long_ratio') or 0.5)
rebalance_frequency = payload.get('rebalance_frequency') or trading_config.get('rebalance_frequency') or 'daily'
# Store cross-sectional config in trading_config
if cs_strategy_type == 'cross_sectional':
trading_config['cs_strategy_type'] = cs_strategy_type
trading_config['symbol_list'] = symbol_list
trading_config['portfolio_size'] = portfolio_size
trading_config['long_ratio'] = long_ratio
trading_config['rebalance_frequency'] = rebalance_frequency
with get_db_connection() as db:
cur = db.cursor()
@@ -764,6 +779,18 @@ class StrategyService:
trading_config = payload.get('trading_config') if payload.get('trading_config') is not None else (existing.get('trading_config') or {})
exchange_config = payload.get('exchange_config') if payload.get('exchange_config') is not None else (existing.get('exchange_config') or {})
ai_model_config = payload.get('ai_model_config') if payload.get('ai_model_config') is not None else (existing.get('ai_model_config') or {})
# Handle cross-sectional strategy config updates
if payload.get('cs_strategy_type') is not None:
trading_config['cs_strategy_type'] = payload.get('cs_strategy_type')
if payload.get('symbol_list') is not None:
trading_config['symbol_list'] = payload.get('symbol_list')
if payload.get('portfolio_size') is not None:
trading_config['portfolio_size'] = payload.get('portfolio_size')
if payload.get('long_ratio') is not None:
trading_config['long_ratio'] = payload.get('long_ratio')
if payload.get('rebalance_frequency') is not None:
trading_config['rebalance_frequency'] = payload.get('rebalance_frequency')
symbol = (trading_config or {}).get('symbol')
timeframe = (trading_config or {}).get('timeframe')
@@ -561,6 +561,18 @@ class TradingExecutor:
market_category = (strategy.get('market_category') or 'Crypto').strip()
logger.info(f"Strategy {strategy_id} market_category: {market_category}")
# Check if this is a cross-sectional strategy
cs_strategy_type = trading_config.get('cs_strategy_type', 'single')
if cs_strategy_type == 'cross_sectional':
# Run cross-sectional strategy loop
self._run_cross_sectional_strategy_loop(
strategy_id, strategy, trading_config, indicator_config,
ai_model_config, execution_mode, notification_config,
strategy_name, market_category, market_type, leverage,
initial_capital, indicator_code, indicator_id
)
return
# 初始化交易所连接(信号模式下无需真实连接)
exchange = None
@@ -2687,3 +2699,348 @@ class TradingExecutor:
return result['code'] if result else None
except:
return None
def _get_all_positions(self, strategy_id: int) -> List[Dict[str, Any]]:
"""获取策略的所有持仓(截面策略使用)"""
try:
with get_db_connection() as db:
cursor = db.cursor()
cursor.execute("""
SELECT id, symbol, side, size, entry_price, current_price, highest_price, lowest_price
FROM qd_strategy_positions
WHERE strategy_id = %s
""", (strategy_id,))
return cursor.fetchall() or []
except Exception as e:
logger.error(f"Failed to get all positions: {e}")
return []
def _should_rebalance(self, strategy_id: int, rebalance_frequency: str) -> bool:
"""检查是否应该调仓"""
try:
with get_db_connection() as db:
cursor = db.cursor()
cursor.execute("""
SELECT last_rebalance_at FROM qd_strategies_trading WHERE id = %s
""", (strategy_id,))
result = cursor.fetchone()
if not result or not result.get('last_rebalance_at'):
return True
last_rebalance = result['last_rebalance_at']
if isinstance(last_rebalance, str):
from datetime import datetime
last_rebalance = datetime.fromisoformat(last_rebalance.replace('Z', '+00:00'))
now = datetime.now()
delta = now - last_rebalance
if rebalance_frequency == 'daily':
return delta.days >= 1
elif rebalance_frequency == 'weekly':
return delta.days >= 7
elif rebalance_frequency == 'monthly':
return delta.days >= 30
return True
except Exception as e:
logger.error(f"Failed to check rebalance: {e}")
return True
def _update_last_rebalance(self, strategy_id: int):
"""更新上次调仓时间"""
try:
with get_db_connection() as db:
cursor = db.cursor()
# Try to update, if column doesn't exist, ignore
try:
cursor.execute("""
UPDATE qd_strategies_trading
SET last_rebalance_at = NOW()
WHERE id = %s
""", (strategy_id,))
db.commit()
except Exception:
# Column may not exist, that's OK
pass
cursor.close()
except Exception as e:
logger.warning(f"Failed to update last_rebalance_at: {e}")
def _execute_cross_sectional_indicator(
self,
indicator_code: str,
symbols: List[str],
trading_config: Dict[str, Any],
market_category: str,
timeframe: str
) -> Optional[Dict[str, Any]]:
"""
执行截面策略指标返回所有标的的评分和排序
"""
try:
# 获取所有标的的K线数据
all_data = {}
for symbol in symbols:
try:
klines = self._fetch_latest_kline(symbol, timeframe, limit=200, market_category=market_category)
if klines and len(klines) >= 2:
df = self._klines_to_dataframe(klines)
if len(df) > 0:
all_data[symbol] = df
except Exception as e:
logger.warning(f"Failed to fetch data for {symbol}: {e}")
continue
if not all_data:
logger.error("No data available for cross-sectional strategy")
return None
# 准备执行环境
exec_env = {
'symbols': list(all_data.keys()),
'data': all_data, # {symbol: df}
'scores': {}, # 用于存储评分
'rankings': [], # 用于存储排序
'np': np,
'pd': pd,
'trading_config': trading_config,
'config': trading_config,
}
# 执行指标代码
import builtins
safe_builtins = {k: getattr(builtins, k) for k in dir(builtins)
if not k.startswith('_') and k not in [
'eval', 'exec', 'compile', 'open', 'input',
'help', 'exit', 'quit', '__import__',
]}
exec_env['__builtins__'] = safe_builtins
pre_import_code = "import numpy as np\nimport pandas as pd\n"
exec(pre_import_code, exec_env)
exec(indicator_code, exec_env)
scores = exec_env.get('scores', {})
rankings = exec_env.get('rankings', [])
# 如果没有提供rankings,根据scores排序
if not rankings and scores:
rankings = sorted(scores.keys(), key=lambda x: scores.get(x, 0), reverse=True)
return {
'scores': scores,
'rankings': rankings
}
except Exception as e:
logger.error(f"Failed to execute cross-sectional indicator: {e}")
logger.error(traceback.format_exc())
return None
def _generate_cross_sectional_signals(
self,
strategy_id: int,
rankings: List[str],
scores: Dict[str, float],
trading_config: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""
根据排序结果生成截面策略信号
"""
portfolio_size = trading_config.get('portfolio_size', 10)
long_ratio = float(trading_config.get('long_ratio', 0.5))
# 选择持仓标的
long_count = int(portfolio_size * long_ratio)
short_count = portfolio_size - long_count
long_symbols = set(rankings[:long_count]) if long_count > 0 else set()
short_symbols = set(rankings[-short_count:]) if short_count > 0 and len(rankings) >= short_count else set()
# 获取当前持仓
current_positions = self._get_all_positions(strategy_id)
current_long = {p['symbol'] for p in current_positions if p.get('side') == 'long'}
current_short = {p['symbol'] for p in current_positions if p.get('side') == 'short'}
signals = []
# 生成做多信号
for symbol in long_symbols:
if symbol not in current_long:
# 如果当前没有多仓,开多
if symbol in current_short:
# 如果当前是空仓,先平空再开多
signals.append({
'symbol': symbol,
'type': 'close_short',
'score': scores.get(symbol, 0)
})
signals.append({
'symbol': symbol,
'type': 'open_long',
'score': scores.get(symbol, 0)
})
# 平掉不在做多列表中的多仓
for symbol in current_long:
if symbol not in long_symbols:
signals.append({
'symbol': symbol,
'type': 'close_long',
'score': scores.get(symbol, 0)
})
# 生成做空信号
for symbol in short_symbols:
if symbol not in current_short:
# 如果当前没有空仓,开空
if symbol in current_long:
# 如果当前是多仓,先平多再开空
signals.append({
'symbol': symbol,
'type': 'close_long',
'score': scores.get(symbol, 0)
})
signals.append({
'symbol': symbol,
'type': 'open_short',
'score': scores.get(symbol, 0)
})
# 平掉不在做空列表中的空仓
for symbol in current_short:
if symbol not in short_symbols:
signals.append({
'symbol': symbol,
'type': 'close_short',
'score': scores.get(symbol, 0)
})
return signals
def _run_cross_sectional_strategy_loop(
self,
strategy_id: int,
strategy: Dict[str, Any],
trading_config: Dict[str, Any],
indicator_config: Dict[str, Any],
ai_model_config: Dict[str, Any],
execution_mode: str,
notification_config: Dict[str, Any],
strategy_name: str,
market_category: str,
market_type: str,
leverage: float,
initial_capital: float,
indicator_code: str,
indicator_id: Optional[int]
):
"""
截面策略执行循环
"""
logger.info(f"Starting cross-sectional strategy loop for strategy {strategy_id}")
symbol_list = trading_config.get('symbol_list', [])
if not symbol_list:
logger.error(f"Strategy {strategy_id} has no symbol_list for cross-sectional strategy")
return
timeframe = trading_config.get('timeframe', '1H')
rebalance_frequency = trading_config.get('rebalance_frequency', 'daily')
tick_interval_sec = int(trading_config.get('decide_interval', 300))
last_tick_time = 0
last_rebalance_time = 0
while True:
try:
# 检查策略状态
if not self._is_strategy_running(strategy_id):
logger.info(f"Cross-sectional strategy {strategy_id} stopped")
break
current_time = time.time()
# Sleep until next tick
if last_tick_time > 0:
sleep_sec = (last_tick_time + tick_interval_sec) - current_time
if sleep_sec > 0:
time.sleep(min(sleep_sec, 1.0))
continue
last_tick_time = current_time
# 检查是否需要调仓
if not self._should_rebalance(strategy_id, rebalance_frequency):
continue
logger.info(f"Cross-sectional strategy {strategy_id} rebalancing...")
# 执行截面指标
result = self._execute_cross_sectional_indicator(
indicator_code, symbol_list, trading_config, market_category, timeframe
)
if not result:
logger.warning(f"Cross-sectional indicator returned no result")
continue
# 生成信号
signals = self._generate_cross_sectional_signals(
strategy_id, result['rankings'], result['scores'], trading_config
)
if not signals:
logger.info(f"No rebalancing needed for strategy {strategy_id}")
self._update_last_rebalance(strategy_id)
continue
logger.info(f"Generated {len(signals)} signals for cross-sectional strategy {strategy_id}")
# 批量执行交易
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=min(10, len(signals))) as executor:
futures = {}
for signal in signals:
future = executor.submit(
self._execute_signal,
strategy_id=strategy_id,
strategy_name=strategy_name,
exchange=None, # Signal mode
symbol=signal['symbol'],
current_price=0.0, # Will be fetched in _execute_signal
signal_type=signal['type'],
position_size=None,
current_positions=[],
trade_direction='both',
leverage=leverage,
initial_capital=initial_capital,
market_type=market_type,
market_category=market_category,
margin_mode='cross',
stop_loss_price=None,
take_profit_price=None,
execution_mode=execution_mode,
notification_config=notification_config,
trading_config=trading_config,
ai_model_config=ai_model_config,
signal_ts=int(current_time)
)
futures[future] = signal
# 等待所有交易完成
for future in as_completed(futures):
signal = futures[future]
try:
result = future.result(timeout=30)
if result:
logger.info(f"Successfully executed signal: {signal['symbol']} {signal['type']}")
except Exception as e:
logger.error(f"Failed to execute signal {signal['symbol']} {signal['type']}: {e}")
# 更新调仓时间
self._update_last_rebalance(strategy_id)
last_rebalance_time = current_time
except Exception as e:
logger.error(f"Cross-sectional strategy loop error: {e}")
logger.error(traceback.format_exc())
time.sleep(5) # Wait before retrying
+12
View File
@@ -162,6 +162,18 @@ CREATE INDEX IF NOT EXISTS idx_strategies_user_id ON qd_strategies_trading(user_
CREATE INDEX IF NOT EXISTS idx_strategies_status ON qd_strategies_trading(status);
CREATE INDEX IF NOT EXISTS idx_strategies_group_id ON qd_strategies_trading(strategy_group_id);
-- Add last_rebalance_at column for cross-sectional strategies (if not exists)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_strategies_trading' AND column_name = 'last_rebalance_at'
) THEN
ALTER TABLE qd_strategies_trading ADD COLUMN last_rebalance_at TIMESTAMP;
RAISE NOTICE 'Added last_rebalance_at column to qd_strategies_trading';
END IF;
END $$;
-- =============================================================================
-- 3. Strategy Positions
-- =============================================================================
+73
View File
@@ -4,6 +4,79 @@ This document records version updates, new features, bug fixes, and database mig
---
## V2.1.3 (2026-02-XX)
### 🚀 New Features
#### Cross-Sectional Strategy Support
- **Multi-Symbol Portfolio Management** - Added support for cross-sectional strategies that manage a portfolio of multiple symbols simultaneously
- Strategy type selection: Single Symbol vs Cross-Sectional
- Symbol list configuration: Select multiple symbols for portfolio management
- Portfolio size: Configure the number of symbols to hold simultaneously
- Long/Short ratio: Set the proportion of long vs short positions (0-1)
- Rebalance frequency: Daily, Weekly, or Monthly portfolio rebalancing
- Indicator execution: Indicators receive a `data` dictionary (symbol -> DataFrame) for cross-symbol analysis
- Signal generation: Automatic buy/sell/close signals based on indicator rankings
- Parallel execution: Multiple orders executed concurrently for efficiency
- **Backend Implementation**
- Cross-sectional configurations stored in `trading_config` JSON field
- New `_run_cross_sectional_strategy_loop` method in TradingExecutor
- Automatic rebalancing based on configured frequency
- Support for both long and short positions in the same portfolio
- **Frontend UI**
- Strategy type selector in strategy creation/editing form
- Conditional display of single-symbol vs cross-sectional configuration fields
- Multi-select symbol picker for cross-sectional strategies
- Full i18n support (Chinese and English)
See `docs/CROSS_SECTIONAL_STRATEGY_GUIDE_CN.md` or `docs/CROSS_SECTIONAL_STRATEGY_GUIDE_EN.md` for detailed usage instructions.
### 🐛 Bug Fixes
- Fixed decimal precision issues in exchange order quantities (Binance Spot LOT_SIZE filter errors)
- Improved `_dec_str` method across all exchange clients for accurate quantity formatting
- Enhanced quantity normalization to respect exchange precision requirements
- Fixed validation logic for cross-sectional strategies (now validates correct symbol list field)
- Fixed success message to show correct strategy count for cross-sectional strategies
### 📋 Database Migration
**Run the following SQL on your PostgreSQL database before deploying V2.1.3:**
```sql
-- ============================================================
-- QuantDinger V2.1.3 Database Migration
-- Cross-Sectional Strategy Support
-- ============================================================
-- Add last_rebalance_at column to track rebalancing time for cross-sectional strategies
-- Note: Cross-sectional strategy configurations (symbol_list, portfolio_size, long_ratio, rebalance_frequency)
-- are stored in the trading_config JSON field, not as separate database columns.
-- This migration only adds the last_rebalance_at timestamp field which is needed for rebalancing logic.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_strategies_trading'
AND column_name = 'last_rebalance_at'
) THEN
ALTER TABLE qd_strategies_trading
ADD COLUMN last_rebalance_at TIMESTAMP;
RAISE NOTICE 'Added last_rebalance_at column to qd_strategies_trading';
ELSE
RAISE NOTICE 'Column last_rebalance_at already exists';
END IF;
END $$;
```
**Migration Notes:**
- This migration is safe to run multiple times (uses IF NOT EXISTS check)
- Cross-sectional strategy configurations are stored in the `trading_config` JSON field, so no additional columns are needed
- The `last_rebalance_at` field is used to track when the last rebalancing occurred for cross-sectional strategies
- If you don't run this migration, cross-sectional strategies will still work, but rebalancing frequency checks may not function correctly
---
## V2.1.2 (2026-02-01)
### 🚀 New Features
+223
View File
@@ -0,0 +1,223 @@
# 截面策略使用指南
## 概述
截面策略(Cross-Sectional Strategy)是一种同时交易多个标的的策略类型。它根据某些因子对所有标的进行评分和排序,然后做多排名靠前的标的,做空排名靠后的标的。
## 功能特点
1. **多标的支持**:可以同时交易多个标的(股票、币种等)
2. **自动排序**:根据指标计算的评分自动排序标的
3. **组合管理**:自动管理持仓组合,保持做多/做空比例
4. **定期调仓**:支持每日/每周/每月调仓频率
5. **批量执行**:并行执行多个标的的交易,提高效率
## 配置说明
### 策略配置参数
在创建或编辑策略时,需要在 `trading_config` 中添加以下参数:
```json
{
"cs_strategy_type": "cross_sectional", // 策略类型:'single' 或 'cross_sectional'
"symbol_list": [ // 标的列表
"Crypto:BTC/USDT",
"Crypto:ETH/USDT",
"Crypto:BNB/USDT"
],
"portfolio_size": 10, // 持仓组合大小(做多+做空的总数)
"long_ratio": 0.5, // 做多比例(0-1之间,0.5表示50%做多,50%做空)
"rebalance_frequency": "daily" // 调仓频率:'daily' | 'weekly' | 'monthly'
}
```
### 参数说明
- **cs_strategy_type**:
- `'single'`: 单标的策略(默认,原有功能)
- `'cross_sectional'`: 截面策略
- **symbol_list**:
- 标的列表,格式为 `["Market:SYMBOL", ...]`
- 例如:`["Crypto:BTC/USDT", "Crypto:ETH/USDT"]`
- **portfolio_size**:
- 持仓组合大小,即同时持有的标的数量
- 例如:10 表示同时持有10个标的
- **long_ratio**:
- 做多比例,0-1之间的浮点数
- 例如:0.5 表示50%做多,50%做空
- 例如:1.0 表示100%做多(不做空)
- **rebalance_frequency**:
- 调仓频率
- `'daily'`: 每日调仓
- `'weekly'`: 每周调仓
- `'monthly'`: 每月调仓
## 指标代码编写
截面策略的指标代码需要返回所有标的的评分和排序。
### 指标代码模板
```python
# 截面策略指标模板
# 输入:data = {symbol1: df1, symbol2: df2, ...}
# 输出:scores = {symbol1: score1, symbol2: score2, ...}
# rankings = [symbol1, symbol2, ...] # 可选,如果不提供会根据scores自动排序
scores = {}
for symbol, df in data.items():
# 计算每个标的的因子值
# 例如:动量因子
momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100
# 例如:RSI指标
def calculate_rsi(prices, period=14):
delta = prices.diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
rsi = 100 - (100 / (1 + rs))
return rsi.iloc[-1]
rsi = calculate_rsi(df['close'], 14)
# 综合评分(可以根据需要调整权重)
score = momentum * 0.6 + (100 - rsi) * 0.4
scores[symbol] = score
# 可选:手动指定排序(如果不提供,系统会根据scores自动排序)
# rankings = sorted(scores.keys(), key=lambda x: scores[x], reverse=True)
```
### 指标代码环境变量
在指标代码执行时,可以使用以下变量:
- `symbols`: 标的列表 `['Crypto:BTC/USDT', 'Crypto:ETH/USDT', ...]`
- `data`: 所有标的的K线数据 `{symbol: df, ...}`
- `scores`: 用于存储评分的字典(需要在代码中填充)
- `rankings`: 用于存储排序的列表(可选,如果不提供会根据scores自动排序)
- `np`: numpy
- `pd`: pandas
- `trading_config`: 交易配置
- `config`: 交易配置(别名)
### 输出要求
指标代码需要填充 `scores` 字典:
```python
scores[symbol] = score_value # score_value 可以是任意数值
```
可选:填充 `rankings` 列表(如果不提供,系统会根据scores自动排序):
```python
rankings = [symbol1, symbol2, ...] # 按评分从高到低排序
```
## 信号生成逻辑
系统会根据以下逻辑自动生成交易信号:
1. **排序标的**:根据指标计算的评分对所有标的进行排序
2. **选择持仓**
- 排名靠前的 `portfolio_size * long_ratio` 个标的 → 做多
- 排名靠后的 `portfolio_size * (1 - long_ratio)` 个标的 → 做空
3. **生成信号**
- 新增标的:如果标的不在当前持仓中,生成开仓信号
- 移除标的:如果标的不在目标持仓中,生成平仓信号
- 方向变更:如果标的需要从多转空或从空转多,先生成平仓信号,再生成开仓信号
## 使用示例
### 1. 创建截面策略
通过API创建策略时,在请求体中包含:
```json
{
"strategy_name": "动量截面策略",
"trading_config": {
"cs_strategy_type": "cross_sectional",
"symbol_list": [
"Crypto:BTC/USDT",
"Crypto:ETH/USDT",
"Crypto:BNB/USDT",
"Crypto:ADA/USDT",
"Crypto:SOL/USDT"
],
"portfolio_size": 5,
"long_ratio": 0.6,
"rebalance_frequency": "daily",
"timeframe": "1H",
"initial_capital": 10000,
"leverage": 1,
"market_type": "swap"
},
"indicator_config": {
"indicator_id": 123,
"indicator_code": "..."
}
}
```
### 2. 指标代码示例
```python
# 动量+RSI综合评分
scores = {}
for symbol, df in data.items():
# 20周期动量
momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100
# RSI
delta = df['close'].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
rs = gain / loss
rsi = 100 - (100 / (1 + rs))
rsi_value = rsi.iloc[-1]
# 综合评分
score = momentum * 0.7 + (100 - rsi_value) * 0.3
scores[symbol] = score
```
## 注意事项
1. **数据获取**:系统会为每个标的获取K线数据,如果某个标的数据获取失败,会跳过该标的
2. **调仓频率**:系统会根据 `rebalance_frequency` 设置检查是否需要调仓,未到调仓时间时不会执行交易
3. **批量执行**:所有交易信号会并行执行,最多同时执行10个交易
4. **持仓管理**:系统会自动管理持仓,确保持仓组合符合配置要求
5. **兼容性**:截面策略功能不影响现有的单标的策略,两者可以共存
## 数据库迁移
如果需要使用数据库字段存储截面策略配置(可选),可以运行迁移脚本:
```sql
-- 运行 migrations/add_cross_sectional_strategy.sql
```
如果不运行迁移脚本,截面策略配置会存储在 `trading_config` JSON字段中,功能完全正常。
## 故障排查
1. **策略不执行**
- 检查 `cs_strategy_type` 是否为 `'cross_sectional'`
- 检查 `symbol_list` 是否不为空
- 检查调仓频率是否已到时间
2. **指标执行失败**
- 检查指标代码是否正确填充 `scores` 字典
- 检查所有标的的数据是否都能正常获取
3. **信号不生成**
- 检查 `portfolio_size` 是否小于等于 `symbol_list` 的长度
- 检查评分是否有效
+223
View File
@@ -0,0 +1,223 @@
# Cross-Sectional Strategy Guide
## Overview
Cross-Sectional Strategy is a strategy type that trades multiple symbols simultaneously. It scores and ranks all symbols based on certain factors, then goes long on top-ranked symbols and short on bottom-ranked symbols.
## Features
1. **Multi-Symbol Support**: Can trade multiple symbols (stocks, cryptocurrencies, etc.) simultaneously
2. **Automatic Ranking**: Automatically ranks symbols based on indicator-calculated scores
3. **Portfolio Management**: Automatically manages portfolio positions, maintaining long/short ratios
4. **Periodic Rebalancing**: Supports daily/weekly/monthly rebalancing frequencies
5. **Batch Execution**: Executes trades for multiple symbols in parallel for improved efficiency
## Configuration
### Strategy Configuration Parameters
When creating or editing a strategy, add the following parameters to `trading_config`:
```json
{
"cs_strategy_type": "cross_sectional", // Strategy type: 'single' or 'cross_sectional'
"symbol_list": [ // Symbol list
"Crypto:BTC/USDT",
"Crypto:ETH/USDT",
"Crypto:BNB/USDT"
],
"portfolio_size": 10, // Portfolio size (total of long + short positions)
"long_ratio": 0.5, // Long ratio (0-1, 0.5 means 50% long, 50% short)
"rebalance_frequency": "daily" // Rebalancing frequency: 'daily' | 'weekly' | 'monthly'
}
```
### Parameter Description
- **cs_strategy_type**:
- `'single'`: Single-symbol strategy (default, original functionality)
- `'cross_sectional'`: Cross-sectional strategy
- **symbol_list**:
- List of symbols, format: `["Market:SYMBOL", ...]`
- Example: `["Crypto:BTC/USDT", "Crypto:ETH/USDT"]`
- **portfolio_size**:
- Portfolio size, i.e., the number of symbols to hold simultaneously
- Example: 10 means holding 10 symbols at the same time
- **long_ratio**:
- Long ratio, a float between 0 and 1
- Example: 0.5 means 50% long, 50% short
- Example: 1.0 means 100% long (no short positions)
- **rebalance_frequency**:
- Rebalancing frequency
- `'daily'`: Daily rebalancing
- `'weekly'`: Weekly rebalancing
- `'monthly'`: Monthly rebalancing
## Indicator Code Writing
Cross-sectional strategy indicator code needs to return scores and rankings for all symbols.
### Indicator Code Template
```python
# Cross-sectional strategy indicator template
# Input: data = {symbol1: df1, symbol2: df2, ...}
# Output: scores = {symbol1: score1, symbol2: score2, ...}
# rankings = [symbol1, symbol2, ...] # Optional, auto-sorted by scores if not provided
scores = {}
for symbol, df in data.items():
# Calculate factor values for each symbol
# Example: Momentum factor
momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100
# Example: RSI indicator
def calculate_rsi(prices, period=14):
delta = prices.diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
rsi = 100 - (100 / (1 + rs))
return rsi.iloc[-1]
rsi = calculate_rsi(df['close'], 14)
# Composite score (adjust weights as needed)
score = momentum * 0.6 + (100 - rsi) * 0.4
scores[symbol] = score
# Optional: Manually specify ranking (if not provided, system will auto-sort by scores)
# rankings = sorted(scores.keys(), key=lambda x: scores[x], reverse=True)
```
### Indicator Code Environment Variables
The following variables are available when indicator code executes:
- `symbols`: Symbol list `['Crypto:BTC/USDT', 'Crypto:ETH/USDT', ...]`
- `data`: K-line data for all symbols `{symbol: df, ...}`
- `scores`: Dictionary for storing scores (needs to be populated in code)
- `rankings`: List for storing rankings (optional, auto-sorted by scores if not provided)
- `np`: numpy
- `pd`: pandas
- `trading_config`: Trading configuration
- `config`: Trading configuration (alias)
### Output Requirements
Indicator code needs to populate the `scores` dictionary:
```python
scores[symbol] = score_value # score_value can be any numeric value
```
Optional: Populate the `rankings` list (if not provided, system will auto-sort by scores):
```python
rankings = [symbol1, symbol2, ...] # Sorted by score from high to low
```
## Signal Generation Logic
The system automatically generates trading signals based on the following logic:
1. **Rank Symbols**: Rank all symbols based on indicator-calculated scores
2. **Select Positions**:
- Top `portfolio_size * long_ratio` symbols → Long
- Bottom `portfolio_size * (1 - long_ratio)` symbols → Short
3. **Generate Signals**:
- New symbols: If a symbol is not in current positions, generate open signal
- Remove symbols: If a symbol is not in target positions, generate close signal
- Direction change: If a symbol needs to change from long to short or vice versa, first generate close signal, then open signal
## Usage Examples
### 1. Create Cross-Sectional Strategy
When creating a strategy via API, include in the request body:
```json
{
"strategy_name": "Momentum Cross-Sectional Strategy",
"trading_config": {
"cs_strategy_type": "cross_sectional",
"symbol_list": [
"Crypto:BTC/USDT",
"Crypto:ETH/USDT",
"Crypto:BNB/USDT",
"Crypto:ADA/USDT",
"Crypto:SOL/USDT"
],
"portfolio_size": 5,
"long_ratio": 0.6,
"rebalance_frequency": "daily",
"timeframe": "1H",
"initial_capital": 10000,
"leverage": 1,
"market_type": "swap"
},
"indicator_config": {
"indicator_id": 123,
"indicator_code": "..."
}
}
```
### 2. Indicator Code Example
```python
# Momentum + RSI Composite Score
scores = {}
for symbol, df in data.items():
# 20-period momentum
momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100
# RSI
delta = df['close'].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
rs = gain / loss
rsi = 100 - (100 / (1 + rs))
rsi_value = rsi.iloc[-1]
# Composite score
score = momentum * 0.7 + (100 - rsi_value) * 0.3
scores[symbol] = score
```
## Notes
1. **Data Retrieval**: The system retrieves K-line data for each symbol. If data retrieval fails for a symbol, that symbol will be skipped.
2. **Rebalancing Frequency**: The system checks if rebalancing is needed based on `rebalance_frequency` settings. No trades will be executed if it's not time to rebalance.
3. **Batch Execution**: All trading signals are executed in parallel, with a maximum of 10 concurrent trades.
4. **Position Management**: The system automatically manages positions to ensure the portfolio meets configuration requirements.
5. **Compatibility**: Cross-sectional strategy functionality does not affect existing single-symbol strategies. Both can coexist.
## Database Migration
If you need to store cross-sectional strategy configuration in database fields (optional), you can run the migration script:
```sql
-- Run migrations/add_cross_sectional_strategy.sql
```
If you don't run the migration script, cross-sectional strategy configuration will be stored in the `trading_config` JSON field, and functionality will work normally.
## Troubleshooting
1. **Strategy Not Executing**:
- Check if `cs_strategy_type` is `'cross_sectional'`
- Check if `symbol_list` is not empty
- Check if rebalancing frequency time has been reached
2. **Indicator Execution Failed**:
- Check if indicator code correctly populates the `scores` dictionary
- Check if data for all symbols can be retrieved normally
3. **Signals Not Generated**:
- Check if `portfolio_size` is less than or equal to the length of `symbol_list`
- Check if scores are valid
@@ -0,0 +1,67 @@
# ============================================================
# 截面策略指标示例 - 动量+RSI综合评分
# Cross-Sectional Strategy Indicator Example
# Momentum + RSI Composite Score
# ============================================================
#
# 使用方法:
# 1. 在交易助手中创建截面策略
# 2. 选择此指标作为策略指标
# 3. 配置标的列表、持仓大小、做多比例等参数
#
# 评分逻辑:
# - 动量因子 (20周期): 价格变化率,越高越好
# - RSI指标 (14周期): 反转RSI值,越低越好(100 - RSI
# - 综合评分: 70% 动量 + 30% RSI反转值
#
# ============================================================
# 截面策略指标
# 输入: data = {symbol1: df1, symbol2: df2, ...}
# 输出: scores = {symbol1: score1, symbol2: score2, ...}
scores = {}
# Iterate through all symbols
for symbol, df in data.items():
# Ensure we have enough data
if len(df) < 20:
scores[symbol] = 0
continue
# === 1. 计算动量因子 (20周期) ===
# 动量 = (当前价格 / 20周期前价格 - 1) * 100
momentum = (df['close'].iloc[-1] / df['close'].iloc[-20] - 1) * 100
# === 2. 计算RSI指标 (14周期) ===
def calculate_rsi(prices, period=14):
"""计算RSI指标"""
delta = prices.diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
rsi = 100 - (100 / (1 + rs))
return rsi.iloc[-1]
rsi_value = calculate_rsi(df['close'], 14)
# === 3. 综合评分 ===
# 动量越高 = 评分越高
# RSI越低(超卖)= 评分越高(100 - RSI)
# 权重: 70% 动量 + 30% RSI反转值
momentum_score = momentum
rsi_score = 100 - rsi_value # 反转RSIRSI越低,评分越高)
composite_score = momentum_score * 0.7 + rsi_score * 0.3
scores[symbol] = composite_score
# === 可选: 手动指定排序 ===
# 如果不提供,系统会根据scores自动排序
# rankings = sorted(scores.keys(), key=lambda x: scores[x], reverse=True)
# === 系统自动处理逻辑 ===
# 1. 根据评分对所有标的进行排序(从高到低)
# 2. 选择排名靠前的N个标的做多(基于 portfolio_size * long_ratio
# 3. 选择排名靠后的N个标的做空(基于 portfolio_size * (1 - long_ratio)
# 4. 自动生成买入/卖出/平仓信号
+17
View File
@@ -1324,6 +1324,21 @@ const locale = {
'trading-assistant.form.symbols': 'Trading Pairs (Multi-select)',
'trading-assistant.form.symbolHint': 'Symbol format depends on the selected market',
'trading-assistant.form.symbolsHint': 'Select multiple pairs to create strategies for each',
'trading-assistant.form.strategyType': 'Strategy Type',
'trading-assistant.form.strategyTypeSingle': 'Single Symbol Strategy',
'trading-assistant.form.strategyTypeCrossSectional': 'Cross-Sectional Strategy',
'trading-assistant.form.strategyTypeHint': 'Single Symbol: Trade a single symbol; Cross-Sectional: Manage a portfolio of multiple symbols',
'trading-assistant.form.symbolList': 'Symbol List',
'trading-assistant.form.symbolListHint': 'Select multiple symbols, strategy will rank and rebalance based on indicator scores',
'trading-assistant.form.portfolioSize': 'Portfolio Size',
'trading-assistant.form.portfolioSizeHint': 'Number of symbols to hold simultaneously',
'trading-assistant.form.longRatio': 'Long Ratio',
'trading-assistant.form.longRatioHint': 'Proportion of long positions (0-1), e.g. 0.5 means 50% long, 50% short',
'trading-assistant.form.rebalanceFrequency': 'Rebalance Frequency',
'trading-assistant.form.rebalanceDaily': 'Daily',
'trading-assistant.form.rebalanceWeekly': 'Weekly',
'trading-assistant.form.rebalanceMonthly': 'Monthly',
'trading-assistant.form.rebalanceFrequencyHint': 'Frequency of portfolio rebalancing',
'trading-assistant.form.addSymbol': 'Add Symbol',
'trading-assistant.form.addSymbolTitle': 'Add Symbol to Watchlist',
'trading-assistant.form.searchSymbolPlaceholder': 'Search by code, e.g. BTC, AAPL',
@@ -1439,6 +1454,8 @@ const locale = {
'trading-assistant.validation.mt5ServerRequired': 'Please enter MT5 server',
'trading-assistant.validation.mt5LoginRequired': 'Please enter MT5 account number',
'trading-assistant.validation.mt5PasswordRequired': 'Please enter MT5 password',
'trading-assistant.validation.portfolioSizeRequired': 'Please enter portfolio size',
'trading-assistant.validation.longRatioRequired': 'Please enter long ratio',
'trading-assistant.exchange.mt5ConnectionSuccess': 'MT5 connected successfully',
'trading-assistant.exchange.mt5ConnectionFailed': 'MT5 connection failed. Please check if terminal is running.',
'trading-assistant.form.notifyChannels': 'Notification Channels',
+17
View File
@@ -1228,6 +1228,21 @@ const locale = {
'trading-assistant.form.symbolHint': '目前仅支持加密货币交易对',
'trading-assistant.form.symbolHintCrypto': '加密货币:使用BTC/USDT等交易对格式',
'trading-assistant.form.symbolsHint': '选择多个交易对,将自动创建多个策略',
'trading-assistant.form.strategyType': '策略类型',
'trading-assistant.form.strategyTypeSingle': '单标的策略',
'trading-assistant.form.strategyTypeCrossSectional': '截面策略',
'trading-assistant.form.strategyTypeHint': '单标的策略:针对单个标的进行交易;截面策略:同时管理多个标的的组合',
'trading-assistant.form.symbolList': '标的列表',
'trading-assistant.form.symbolListHint': '选择多个标的,策略将根据指标评分进行排序和调仓',
'trading-assistant.form.portfolioSize': '持仓组合大小',
'trading-assistant.form.portfolioSizeHint': '策略同时持有的标的数量',
'trading-assistant.form.longRatio': '做多比例',
'trading-assistant.form.longRatioHint': '持仓中做多标的的比例(0-1),例如0.5表示50%做多,50%做空',
'trading-assistant.form.rebalanceFrequency': '调仓频率',
'trading-assistant.form.rebalanceDaily': '每日',
'trading-assistant.form.rebalanceWeekly': '每周',
'trading-assistant.form.rebalanceMonthly': '每月',
'trading-assistant.form.rebalanceFrequencyHint': '策略重新调整持仓组合的频率',
'trading-assistant.form.addSymbol': '添加交易对',
'trading-assistant.form.addSymbolTitle': '添加交易对到自选列表',
'trading-assistant.form.searchSymbolPlaceholder': '输入代码搜索,如 BTC、AAPL',
@@ -1317,6 +1332,8 @@ const locale = {
'trading-assistant.validation.mt5ServerRequired': '请输入 MT5 服务器',
'trading-assistant.validation.mt5LoginRequired': '请输入 MT5 账户号',
'trading-assistant.validation.mt5PasswordRequired': '请输入 MT5 密码',
'trading-assistant.validation.portfolioSizeRequired': '请输入持仓组合大小',
'trading-assistant.validation.longRatioRequired': '请输入做多比例',
'trading-assistant.exchange.mt5ConnectionSuccess': 'MT5 连接成功',
'trading-assistant.exchange.mt5ConnectionFailed': 'MT5 连接失败,请检查终端是否运行',
'trading-assistant.form.notifyChannels': '通知渠道',
@@ -160,7 +160,12 @@ export default {
if (!isFinite(lev) || lev <= 0) lev = 1
if (mt === 'spot') lev = 1
const entryPrice = parseFloat(position.entry_price || position.entryPrice || '0') || 0
// entry_price 0 null使 current_price
let entryPrice = parseFloat(position.entry_price || position.entryPrice || 0)
if (!entryPrice || entryPrice <= 0) {
// entry_price 使 current_price
entryPrice = parseFloat(position.current_price || position.currentPrice || 0)
}
const size = parseFloat(position.size || '0') || 0
const pnl = parseFloat(position.unrealized_pnl || position.unrealizedPnl || '0') || 0
let pnlPercent = parseFloat(position.pnl_percent || position.pnlPercent || '0') || 0
@@ -177,8 +182,8 @@ export default {
id: position.id || index,
symbol: position.symbol || '',
side: position.side || 'long',
size: position.size || '0',
entry_price: position.entry_price || position.entryPrice || '0',
size: size > 0 ? size.toString() : '0',
entry_price: entryPrice > 0 ? entryPrice.toString() : (position.entry_price || position.entryPrice || '0'),
current_price: position.current_price || position.currentPrice || '0',
unrealized_pnl: position.unrealized_pnl || position.unrealizedPnl || '0',
pnl_percent: pnlPercent,
@@ -467,7 +467,103 @@
:placeholder="$t('trading-assistant.placeholders.inputStrategyName')" />
</a-form-item>
<!-- 策略类型选择 -->
<a-form-item :label="$t('trading-assistant.form.strategyType')">
<a-radio-group
v-decorator="['cs_strategy_type', { initialValue: 'single' }]"
@change="handleStrategyTypeChange">
<a-radio value="single">{{ $t('trading-assistant.form.strategyTypeSingle') }}</a-radio>
<a-radio value="cross_sectional">{{ $t('trading-assistant.form.strategyTypeCrossSectional') }}</a-radio>
</a-radio-group>
<div class="form-item-hint">
{{ $t('trading-assistant.form.strategyTypeHint') }}
</div>
</a-form-item>
<!-- 截面策略配置 -->
<template v-if="form.getFieldValue('cs_strategy_type') === 'cross_sectional'">
<a-form-item :label="$t('trading-assistant.form.symbolList')">
<a-select
v-model="crossSectionalSymbols"
mode="multiple"
:placeholder="$t('trading-assistant.placeholders.selectSymbols')"
show-search
:filter-option="filterWatchlistOptionWithAdd"
:loading="loadingWatchlist"
@change="handleCrossSectionalSymbolChange"
:getPopupContainer="(triggerNode) => triggerNode.parentNode"
:maxTagCount="5">
<a-select-option
v-for="item in watchlist"
:key="`${item.market}:${item.symbol}`"
:value="`${item.market}:${item.symbol}`">
<div class="symbol-option">
<a-tag :color="getMarketColor(item.market)" style="margin-right: 8px; margin-bottom: 0;">
{{ item.market }}
</a-tag>
<span class="symbol-name">{{ item.symbol }}</span>
<span v-if="item.name" class="symbol-name-extra">{{ item.name }}</span>
</div>
</a-select-option>
<a-select-option key="__add_symbol_option__" value="__add_symbol_option__" class="add-symbol-option">
<div style="width: 100%; text-align: center; padding: 4px 0; color: #1890ff; cursor: pointer;">
<a-icon type="plus" style="margin-right: 4px;" />
<span>{{ $t('trading-assistant.form.addSymbol') }}</span>
</div>
</a-select-option>
</a-select>
<div class="form-item-hint">
{{ $t('trading-assistant.form.symbolListHint') }}
</div>
</a-form-item>
<a-row :gutter="16">
<a-col :xs="24" :sm="12">
<a-form-item :label="$t('trading-assistant.form.portfolioSize')">
<a-input-number
v-decorator="['portfolio_size', { initialValue: 10, rules: [{ required: true, message: $t('trading-assistant.validation.portfolioSizeRequired') }] }]"
:min="1"
:max="100"
:step="1"
style="width: 100%" />
<div class="form-item-hint">
{{ $t('trading-assistant.form.portfolioSizeHint') }}
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item :label="$t('trading-assistant.form.longRatio')">
<a-input-number
v-decorator="['long_ratio', { initialValue: 0.5, rules: [{ required: true, message: $t('trading-assistant.validation.longRatioRequired') }] }]"
:min="0"
:max="1"
:step="0.1"
:precision="2"
style="width: 100%" />
<div class="form-item-hint">
{{ $t('trading-assistant.form.longRatioHint') }}
</div>
</a-form-item>
</a-col>
</a-row>
<a-form-item :label="$t('trading-assistant.form.rebalanceFrequency')">
<a-select
v-decorator="['rebalance_frequency', { initialValue: 'daily' }]"
style="width: 100%">
<a-select-option value="daily">{{ $t('trading-assistant.form.rebalanceDaily') }}</a-select-option>
<a-select-option value="weekly">{{ $t('trading-assistant.form.rebalanceWeekly') }}</a-select-option>
<a-select-option value="monthly">{{ $t('trading-assistant.form.rebalanceMonthly') }}</a-select-option>
</a-select>
<div class="form-item-hint">
{{ $t('trading-assistant.form.rebalanceFrequencyHint') }}
</div>
</a-form-item>
</template>
<!-- 单标的策略原有的标的选择 -->
<a-form-item
v-if="form.getFieldValue('cs_strategy_type') !== 'cross_sectional'"
:label="isEditMode ? $t('trading-assistant.form.symbol') : $t('trading-assistant.form.symbols')">
<!-- 编辑模式单选 -->
<a-select
@@ -1378,7 +1474,7 @@
</template>
<script>
import { getStrategyList, startStrategy, stopStrategy, deleteStrategy, updateStrategy, testExchangeConnection, getStrategyEquityCurve, batchCreateStrategies, batchStartStrategies, batchStopStrategies, batchDeleteStrategies } from '@/api/strategy'
import { getStrategyList, startStrategy, stopStrategy, deleteStrategy, updateStrategy, createStrategy, testExchangeConnection, getStrategyEquityCurve, batchCreateStrategies, batchStartStrategies, batchStopStrategies, batchDeleteStrategies } from '@/api/strategy'
import { getWatchlist, addWatchlist, searchSymbols, getHotSymbols } from '@/api/market'
import { listExchangeCredentials, getExchangeCredential, createExchangeCredential } from '@/api/credentials'
import { getNotificationSettings } from '@/api/user'
@@ -1785,6 +1881,8 @@ export default {
suppressApiClearOnce: false,
//
selectedSymbols: [],
//
crossSectionalSymbols: [],
//
collapsedGroups: {},
// : 'strategy' 'symbol'
@@ -2090,6 +2188,36 @@ export default {
}
this.handleMultiSymbolChange(vals)
},
handleStrategyTypeChange (e) {
const strategyType = e.target.value
//
if (strategyType === 'single') {
this.crossSectionalSymbols = []
}
},
handleCrossSectionalSymbolChange (vals) {
// ""
if (vals && vals.includes('__add_symbol_option__')) {
//
this.crossSectionalSymbols = vals.filter(v => v !== '__add_symbol_option__')
//
this.showAddSymbolModal = true
//
this.loadHotSymbols(this.addSymbolMarket)
return
}
this.crossSectionalSymbols = vals || []
//
if (vals && vals.length > 0) {
const firstVal = vals[0]
if (typeof firstVal === 'string' && firstVal.includes(':')) {
const idx = firstVal.indexOf(':')
const market = firstVal.slice(0, idx)
this.selectedMarketCategory = market || 'Crypto'
}
}
},
getMarketColor (market) {
const colors = {
USStock: 'green',
@@ -2585,6 +2713,38 @@ export default {
const scaleObj = (tc.scale && typeof tc.scale === 'object') ? tc.scale : null
const posObj = (tc.position && typeof tc.position === 'object') ? tc.position : null
//
const strategyType = tc.strategy_type || strategy.strategy_type || 'single'
if (strategyType === 'cross_sectional') {
this.form.setFieldsValue({
cs_strategy_type: 'cross_sectional',
portfolio_size: tc.portfolio_size || 10,
long_ratio: tc.long_ratio || 0.5,
rebalance_frequency: tc.rebalance_frequency || 'daily'
})
//
if (tc.symbol_list && Array.isArray(tc.symbol_list)) {
this.crossSectionalSymbols = tc.symbol_list
} else if (strategy.symbol_list) {
// trading_config
try {
const symbolList = typeof strategy.symbol_list === 'string' ? JSON.parse(strategy.symbol_list) : strategy.symbol_list
if (Array.isArray(symbolList)) {
this.crossSectionalSymbols = symbolList
}
} catch (e) {
this.crossSectionalSymbols = []
}
} else {
this.crossSectionalSymbols = []
}
} else {
this.form.setFieldsValue({
cs_strategy_type: 'single'
})
this.crossSectionalSymbols = []
}
// Backward compatible: nested configs from indicator-analysis backtest modal
const trendAddObj = scaleObj && scaleObj.trendAdd ? scaleObj.trendAdd : null
const dcaAddObj = scaleObj && scaleObj.dcaAdd ? scaleObj.dcaAdd : null
@@ -3437,9 +3597,19 @@ export default {
//
if (!this.isEditMode) {
if (!this.selectedSymbols || this.selectedSymbols.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.symbolsRequired'))
return
const strategyType = this.form.getFieldValue('cs_strategy_type') || 'single'
if (strategyType === 'cross_sectional') {
//
if (!this.crossSectionalSymbols || this.crossSectionalSymbols.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.symbolsRequired'))
return
}
} else {
//
if (!this.selectedSymbols || this.selectedSymbols.length === 0) {
this.$message.warning(this.$t('trading-assistant.validation.symbolsRequired'))
return
}
}
}
@@ -3620,7 +3790,13 @@ export default {
// AI
enable_ai_filter: enableAiFilter,
//
indicator_params: this.indicatorParamValues
indicator_params: this.indicatorParamValues,
//
strategy_type: values.cs_strategy_type || 'single',
symbol_list: values.cs_strategy_type === 'cross_sectional' ? this.crossSectionalSymbols : undefined,
portfolio_size: values.cs_strategy_type === 'cross_sectional' ? (values.portfolio_size || 10) : undefined,
long_ratio: values.cs_strategy_type === 'cross_sectional' ? (values.long_ratio || 0.5) : undefined,
rebalance_frequency: values.cs_strategy_type === 'cross_sectional' ? (values.rebalance_frequency || 'daily') : undefined
}
}
@@ -3636,19 +3812,35 @@ export default {
basePayload.trading_config.symbol = parsedSymbol
res = await updateStrategy(this.editingStrategy.id, basePayload)
} else {
//
//
basePayload.user_id = 1
basePayload.strategy_type = 'IndicatorStrategy'
basePayload.symbols = this.selectedSymbols //
res = await batchCreateStrategies(basePayload)
// 使 createStrategy
if (values.cs_strategy_type === 'cross_sectional') {
// trading_config
basePayload.strategy_type = 'IndicatorStrategy' // IndicatorStrategy trading_config
// symbol
basePayload.trading_config.symbol = null
// 使 createStrategy
res = await createStrategy(basePayload)
} else {
//
basePayload.symbols = this.selectedSymbols //
res = await batchCreateStrategies(basePayload)
}
}
if (res.code === 1) {
if (this.isEditMode) {
this.$message.success(this.$t('trading-assistant.messages.updateSuccess'))
} else {
const totalCreated = res.data?.total_created || this.selectedSymbols.length
//
const strategyType = values.cs_strategy_type || 'single'
const symbolCount = strategyType === 'cross_sectional'
? this.crossSectionalSymbols.length
: this.selectedSymbols.length
const totalCreated = res.data?.total_created || symbolCount
this.$message.success(this.$t('trading-assistant.messages.batchCreateSuccess', { count: totalCreated }))
}
// Save credential to vault (crypto exchanges only, IBKR/MT5 don't need this)