Files
DinQuant/backend_api_python/app/services/backtest.py
T

4048 lines
210 KiB
Python
Raw Normal View History

2025-12-29 03:06:49 +08:00
"""
Backtest Service
2025-12-29 03:06:49 +08:00
"""
import math
import traceback
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
import pandas as pd
import numpy as np
from app.data_sources import DataSourceFactory
from app.utils.logger import get_logger
from app.services.indicator_params import IndicatorParamsParser, IndicatorCaller
2025-12-29 03:06:49 +08:00
logger = get_logger(__name__)
class BacktestService:
"""Backtest Service"""
2025-12-29 03:06:49 +08:00
# Timeframe in seconds
2025-12-29 03:06:49 +08:00
TIMEFRAME_SECONDS = {
'1m': 60, '5m': 300, '15m': 900, '30m': 1800,
'1H': 3600, '4H': 14400, '1D': 86400, '1W': 604800
}
# Multi-timeframe backtest threshold configuration
2026-03-01 03:42:10 +08:00
# 1m backtest: max 15 days (~21,600 candles) - reduced for performance
# 5m backtest: max 1 year (~105,120 candles)
MTF_CONFIG = {
2026-03-01 03:42:10 +08:00
'max_1m_days': 15, # Max days for 1-minute backtest (reduced from 30 for performance)
'max_5m_days': 365, # Max days for 5-minute backtest
'default_exec_tf': '1m', # Default execution timeframe
'fallback_exec_tf': '5m', # Fallback execution timeframe
}
@staticmethod
def _infer_candle_path(open_: float, high: float, low: float, close: float) -> List[float]:
"""
Infer the price path within a candle.
Determines the order of price movement based on open/close relationship:
- Bullish candle (close >= open): Open -> Low -> High -> Close (dip then rally)
- Bearish candle (close < open): Open -> High -> Low -> Close (rally then dip)
Returns:
Price path list [price1, price2, price3, price4]
"""
if close >= open_:
# Bullish: dip first then rally
return [open_, low, high, close]
else:
# Bearish: rally first then dip
return [open_, high, low, close]
def get_execution_timeframe(self, start_date: datetime, end_date: datetime, market: str = 'crypto') -> tuple:
"""
Automatically select execution timeframe based on backtest date range.
Args:
start_date: Start date
end_date: End date
market: Market type
Returns:
(execution_timeframe, precision_info)
- execution_timeframe: '1m' or '5m'
- precision_info: Precision info dict for frontend display
"""
days_diff = (end_date - start_date).days
# Only crypto market supports high-precision backtest
if market.lower() not in ['crypto', 'cryptocurrency']:
return None, {
'enabled': False,
'reason': 'only_crypto',
'message': 'High-precision backtest only supports cryptocurrency market'
}
if days_diff <= self.MTF_CONFIG['max_1m_days']:
2026-03-01 03:42:10 +08:00
# Within 15 days: use 1-minute precision
estimated_candles = days_diff * 24 * 60
return '1m', {
'enabled': True,
'timeframe': '1m',
'days': days_diff,
'estimated_candles': estimated_candles,
'precision': 'high',
'message': f'Using 1-minute precision backtest (~{estimated_candles:,} candles)'
}
elif days_diff <= self.MTF_CONFIG['max_5m_days']:
2026-03-01 03:42:10 +08:00
# 15 days to 1 year: use 5-minute precision
estimated_candles = days_diff * 24 * 12
return '5m', {
'enabled': True,
'timeframe': '5m',
'days': days_diff,
'estimated_candles': estimated_candles,
'precision': 'medium',
2026-03-01 03:42:10 +08:00
'message': f'Range exceeds {self.MTF_CONFIG["max_1m_days"]} days, using 5-minute precision (~{estimated_candles:,} candles)'
}
else:
# Over 1 year: high-precision backtest not supported
return None, {
'enabled': False,
'reason': 'too_long',
'days': days_diff,
'max_days': self.MTF_CONFIG['max_5m_days'],
'message': f'Backtest range {days_diff} days exceeds max limit {self.MTF_CONFIG["max_5m_days"]} days'
}
def run_multi_timeframe(
self,
indicator_code: str,
market: str,
symbol: str,
timeframe: str,
start_date: datetime,
end_date: datetime,
initial_capital: float = 10000.0,
commission: float = 0.001,
slippage: float = 0.0,
leverage: int = 1,
trade_direction: str = 'long',
strategy_config: Optional[Dict[str, Any]] = None,
enable_mtf: bool = True
) -> Dict[str, Any]:
"""
Multi-timeframe backtest.
Uses strategy timeframe for signal generation and execution timeframe (1m/5m)
for precise trade simulation.
Args:
indicator_code: Indicator code
market: Market type
symbol: Trading symbol
timeframe: Strategy timeframe (for signal generation)
start_date: Start date
end_date: End date
initial_capital: Initial capital
commission: Commission rate
slippage: Slippage
leverage: Leverage
trade_direction: Trade direction
strategy_config: Strategy configuration
enable_mtf: Whether to enable multi-timeframe backtest
Returns:
Backtest result with precision info
"""
# Get execution timeframe
exec_tf, precision_info = self.get_execution_timeframe(start_date, end_date, market)
if not enable_mtf or not precision_info.get('enabled'):
# Fallback to standard candle backtest
result = self.run(
indicator_code=indicator_code,
market=market,
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date,
initial_capital=initial_capital,
commission=commission,
slippage=slippage,
leverage=leverage,
trade_direction=trade_direction,
strategy_config=strategy_config
)
result['precision_info'] = precision_info or {
'enabled': False,
'timeframe': timeframe,
'precision': 'standard',
'message': 'Using standard candle backtest'
}
return result
logger.info(f"Multi-timeframe backtest: strategy_tf={timeframe}, exec_tf={exec_tf}, range={start_date} ~ {end_date}")
# 1. Fetch strategy timeframe candles (for signal generation)
df_signal = self._fetch_kline_data(market, symbol, timeframe, start_date, end_date)
if df_signal.empty:
raise ValueError("No candle data available in the backtest date range")
# 2. Execute indicator code to get signals
backtest_params = {
'leverage': leverage,
'initial_capital': initial_capital,
'commission': commission,
'trade_direction': trade_direction
}
signals = self._execute_indicator(indicator_code, df_signal, backtest_params)
2026-03-01 03:42:10 +08:00
logger.info(f"Signals generated: {list(signals.keys()) if isinstance(signals, dict) else type(signals)}")
# 3. Fetch execution timeframe candles (for precise trade simulation)
2026-03-01 03:42:10 +08:00
logger.info(f"Fetching execution timeframe data: {exec_tf} for {market}:{symbol}")
df_exec = self._fetch_kline_data(market, symbol, exec_tf, start_date, end_date)
2026-03-01 03:42:10 +08:00
logger.info(f"Execution timeframe data fetched: {len(df_exec)} candles")
if df_exec.empty:
logger.warning(f"Cannot fetch {exec_tf} candles, falling back to standard backtest")
result = self.run(
indicator_code=indicator_code,
market=market,
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date,
initial_capital=initial_capital,
commission=commission,
slippage=slippage,
leverage=leverage,
trade_direction=trade_direction,
strategy_config=strategy_config
)
result['precision_info'] = {
'enabled': False,
'reason': 'data_unavailable',
'message': f'Cannot fetch {exec_tf} data, using standard backtest'
}
return result
logger.info(f"Data fetched: signal_candles={len(df_signal)}, exec_candles={len(df_exec)}")
# 4. Use execution timeframe for precise trade simulation
2026-03-01 03:42:10 +08:00
try:
logger.info("Starting MTF trading simulation...")
equity_curve, trades, total_commission = self._simulate_trading_mtf(
df_signal=df_signal,
df_exec=df_exec,
signals=signals,
initial_capital=initial_capital,
commission=commission,
slippage=slippage,
leverage=leverage,
trade_direction=trade_direction,
strategy_config=strategy_config,
2026-03-01 03:42:10 +08:00
signal_timeframe=timeframe,
exec_timeframe=exec_tf
)
logger.info(f"MTF simulation completed: {len(trades)} trades executed")
except Exception as e:
logger.error(f"MTF simulation failed: {str(e)}")
logger.error(traceback.format_exc())
raise
# 5. Calculate metrics
2026-03-01 03:42:10 +08:00
try:
logger.info(f"Calculating metrics: equity_curve_len={len(equity_curve)}, trades_len={len(trades)}, initial_capital={initial_capital}")
metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission)
logger.info(f"Metrics calculated successfully: {list(metrics.keys())}")
except Exception as e:
logger.error(f"Failed to calculate metrics: {str(e)}")
logger.error(traceback.format_exc())
raise
# 6. Format result
2026-03-01 03:42:10 +08:00
try:
logger.info("Formatting backtest result...")
result = self._format_result(metrics, equity_curve, trades)
result['precision_info'] = precision_info
result['execution_timeframe'] = exec_tf
result['signal_candles'] = len(df_signal)
result['execution_candles'] = len(df_exec)
logger.info("Backtest result formatted successfully")
except Exception as e:
logger.error(f"Failed to format result: {str(e)}")
logger.error(traceback.format_exc())
raise
return result
def _simulate_trading_mtf(
self,
df_signal: pd.DataFrame,
df_exec: pd.DataFrame,
signals: dict,
initial_capital: float,
commission: float,
slippage: float,
leverage: int,
trade_direction: str,
strategy_config: Optional[Dict[str, Any]],
signal_timeframe: str,
exec_timeframe: str
) -> tuple:
"""
Multi-timeframe trading simulation.
Simulates trades candle by candle on execution timeframe,
using inferred candle price path to determine trigger order.
"""
2026-03-01 03:42:10 +08:00
try:
logger.info(f"Entering _simulate_trading_mtf: df_signal={len(df_signal)}, df_exec={len(df_exec)}, signals_type={type(signals)}")
except Exception as e:
logger.error(f"Error in _simulate_trading_mtf entry logging: {e}")
equity_curve = []
trades = []
total_commission_paid = 0.0
is_liquidated = False
min_capital_to_trade = 1.0
capital = initial_capital
position = 0
entry_price = 0.0
position_type = None # 'long' or 'short'
# Parse strategy config
cfg = strategy_config or {}
risk_cfg = cfg.get('risk') or {}
stop_loss_pct = float(risk_cfg.get('stopLossPct') or 0.0)
take_profit_pct = float(risk_cfg.get('takeProfitPct') or 0.0)
trailing_cfg = risk_cfg.get('trailing') or {}
trailing_enabled = bool(trailing_cfg.get('enabled'))
trailing_pct = float(trailing_cfg.get('pct') or 0.0)
trailing_activation_pct = float(trailing_cfg.get('activationPct') or 0.0)
lev = max(int(leverage or 1), 1)
stop_loss_pct_eff = stop_loss_pct / lev if stop_loss_pct > 0 else 0
take_profit_pct_eff = take_profit_pct / lev if take_profit_pct > 0 else 0
trailing_pct_eff = trailing_pct / lev if trailing_pct > 0 else 0
trailing_activation_pct_eff = trailing_activation_pct / lev if trailing_activation_pct > 0 else 0
# If trailing stop enabled but no activation threshold set, use take profit threshold
if trailing_enabled and trailing_pct_eff > 0:
if trailing_activation_pct_eff <= 0 and take_profit_pct_eff > 0:
trailing_activation_pct_eff = take_profit_pct_eff
# Entry percentage
pos_cfg = cfg.get('position') or {}
raw_entry_pct = pos_cfg.get('entryPct')
# If entryPct is None, 0, or not provided, default to 1.0 (100%)
if raw_entry_pct is None or raw_entry_pct == 0:
entry_pct_cfg = 1.0
else:
entry_pct_cfg = float(raw_entry_pct)
if entry_pct_cfg > 1:
entry_pct_cfg = entry_pct_cfg / 100.0
entry_pct_cfg = max(0.01, min(entry_pct_cfg, 1.0)) # Minimum 1% to avoid 0 position
logger.info(f"Trading params: capital={capital}, leverage={lev}, entry_pct={entry_pct_cfg}, strategy_config={cfg}")
highest_since_entry = None
lowest_since_entry = None
# Normalize signal format
if not isinstance(signals, dict):
raise ValueError("signals must be a dict")
# Debug: check signal index compatibility
signal_keys = list(signals.keys())
logger.info(f"Signal keys: {signal_keys}")
if signal_keys:
first_key = signal_keys[0]
if hasattr(signals[first_key], 'index'):
sig_index = signals[first_key].index
df_index = df_signal.index
logger.info(f"Signal index len={len(sig_index)}, df_signal index len={len(df_index)}")
if len(sig_index) > 0 and len(df_index) > 0:
logger.info(f"Signal index first={sig_index[0]}, df_signal index first={df_index[0]}")
# Check if indices match
if not sig_index.equals(df_index):
logger.warning("Signal index does NOT match df_signal index! This may cause signal lookup failures.")
# Check if trade_direction is 'both' mode
is_both_mode = str(trade_direction or 'both').lower() == 'both'
if all(k in signals for k in ['open_long', 'close_long', 'open_short', 'close_short']):
norm_signals = signals
norm_signals['_both_mode'] = False # Explicit 4-signal mode, not both mode
elif all(k in signals for k in ['buy', 'sell']):
2026-03-01 03:42:10 +08:00
# Ensure signals have the same index as df_signal
buy_series = signals['buy']
sell_series = signals['sell']
# Reindex to match df_signal.index (fill missing with False)
if not buy_series.index.equals(df_signal.index):
logger.warning(f"Buy signal index mismatch! Signal index: {buy_series.index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}")
buy_series = buy_series.reindex(df_signal.index, fill_value=False)
if not sell_series.index.equals(df_signal.index):
logger.warning(f"Sell signal index mismatch! Signal index: {sell_series.index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}")
sell_series = sell_series.reindex(df_signal.index, fill_value=False)
buy = buy_series.fillna(False).astype(bool)
sell = sell_series.fillna(False).astype(bool)
# Debug: log signal statistics
buy_count = buy.sum()
sell_count = sell.sum()
logger.info(f"Signal statistics: buy={buy_count}, sell={sell_count}, total_candles={len(df_signal)}")
td = str(trade_direction or 'both').lower()
2026-03-01 03:42:10 +08:00
logger.info(f"Trade direction: {td} (original: {trade_direction})")
if td == 'long':
norm_signals = {
'open_long': buy, 'close_long': sell,
2026-03-01 03:42:10 +08:00
'open_short': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
'close_short': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
}
elif td == 'short':
norm_signals = {
2026-03-01 03:42:10 +08:00
'open_long': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
'close_long': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
'open_short': sell, 'close_short': buy,
}
else:
# Both mode: buy signal triggers long entry (close short if any, then open long)
# sell signal triggers short entry (close long if any, then open short)
# We use special signal types 'enter_long' and 'enter_short' to indicate
# that the signal should auto-close opposing position before opening
norm_signals = {
2026-03-01 03:42:10 +08:00
'open_long': buy, 'close_long': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
'open_short': sell, 'close_short': pd.Series([False] * len(df_signal), index=df_signal.index, dtype=bool),
'_both_mode': True # Flag to indicate both mode for special handling
}
else:
raise ValueError("Invalid signal format")
2026-03-01 03:42:10 +08:00
logger.info("Signal normalization completed, starting signal queue building...")
# Map signals to execution timeframe
# Strategy timeframe seconds (e.g. 1H=3600, 1D=86400)
signal_tf_seconds = self.TIMEFRAME_SECONDS.get(signal_timeframe, 3600)
exec_tf_seconds = self.TIMEFRAME_SECONDS.get(exec_timeframe, 60)
logger.info(f"Signal timeframe: {signal_timeframe} ({signal_tf_seconds}s), Exec timeframe: {exec_timeframe} ({exec_tf_seconds}s)")
# Preprocessing: create signal queue sorted by effective time
# Each signal executes at the open of the next execution candle after its candle closes
2026-03-01 03:42:10 +08:00
logger.info("Initializing signal queue...")
signal_queue = [] # [(effective_time, signal_type, signal_bar_time), ...]
# Debug: check signal values
debug_signal_counts = {'open_long': 0, 'close_long': 0, 'open_short': 0, 'close_short': 0}
2026-03-01 03:42:10 +08:00
# Verify all norm_signals have matching index
for sig_type in ['open_long', 'close_long', 'open_short', 'close_short']:
if not norm_signals[sig_type].index.equals(df_signal.index):
logger.error(f"Critical: {sig_type} signal index does not match df_signal.index!")
logger.error(f" Signal index: {norm_signals[sig_type].index[:5].tolist()}")
logger.error(f" df_signal index: {df_signal.index[:5].tolist()}")
# Reindex to fix
norm_signals[sig_type] = norm_signals[sig_type].reindex(df_signal.index, fill_value=False)
logger.warning(f" Fixed by reindexing {sig_type}")
for sig_time in df_signal.index:
# Signal candle end time = start time + period
sig_end = sig_time + timedelta(seconds=signal_tf_seconds)
# Check if this signal candle has signals
2026-03-01 03:42:10 +08:00
# All signals should now have matching index, so we can safely use .loc[]
try:
2026-03-01 03:42:10 +08:00
ol = bool(norm_signals['open_long'].loc[sig_time])
cl = bool(norm_signals['close_long'].loc[sig_time])
os = bool(norm_signals['open_short'].loc[sig_time])
cs = bool(norm_signals['close_short'].loc[sig_time])
except (KeyError, IndexError) as e:
logger.warning(f"Error accessing signal at {sig_time}: {e}, signal index: {norm_signals['open_long'].index[:5].tolist()}, df_signal index: {df_signal.index[:5].tolist()}")
continue
except Exception as e:
2026-03-01 03:42:10 +08:00
logger.warning(f"Unexpected error accessing signal at {sig_time}: {e}")
continue
if ol:
signal_queue.append((sig_end, 'open_long', sig_time))
debug_signal_counts['open_long'] += 1
if cl:
signal_queue.append((sig_end, 'close_long', sig_time))
debug_signal_counts['close_long'] += 1
if os:
signal_queue.append((sig_end, 'open_short', sig_time))
debug_signal_counts['open_short'] += 1
if cs:
signal_queue.append((sig_end, 'close_short', sig_time))
debug_signal_counts['close_short'] += 1
logger.info(f"Debug signal counts from queue building: {debug_signal_counts}")
2026-03-01 03:42:10 +08:00
# If no signals found, log detailed diagnostic info
if len(signal_queue) == 0:
logger.warning("No signals found in signal queue! Diagnostic info:")
logger.warning(f" df_signal length: {len(df_signal)}")
logger.warning(f" df_signal index range: {df_signal.index[0]} to {df_signal.index[-1]}")
for sig_type in ['open_long', 'close_long', 'open_short', 'close_short']:
sig_series = norm_signals[sig_type]
true_count = sig_series.sum()
logger.warning(f" {sig_type}: {true_count} True values out of {len(sig_series)}")
if true_count > 0:
true_indices = sig_series[sig_series].index.tolist()[:5]
logger.warning(f" First few True indices: {true_indices}")
# Check if signals might be in wrong format
if 'buy' in signals or 'sell' in signals:
logger.warning(" Original signals had 'buy'/'sell' keys - check if conversion was correct")
# Sort by effective time
signal_queue.sort(key=lambda x: x[0])
signal_queue_idx = 0 # Current signal queue pointer
logger.info(f"Signal queue built: total {len(signal_queue)} signals")
if signal_queue:
logger.info(f"First signal: {signal_queue[0][1]} @ {signal_queue[0][0]} (from {signal_queue[0][2]})")
logger.info(f"Last signal: {signal_queue[-1][1]} @ {signal_queue[-1][0]} (from {signal_queue[-1][2]})")
2026-03-01 03:42:10 +08:00
else:
logger.error("Signal queue is empty! Backtest will fail. Check indicator code to ensure it generates buy/sell signals.")
# Count signals by type
signal_counts = {}
for _, sig_type, _ in signal_queue:
signal_counts[sig_type] = signal_counts.get(sig_type, 0) + 1
logger.info(f"Signal counts: {signal_counts}")
2026-03-01 03:42:10 +08:00
# Log first few signal details for debugging
if signal_queue:
logger.info(f"First 3 signals details:")
for idx, (sig_time, sig_type, sig_bar_time) in enumerate(signal_queue[:3]):
logger.info(f" Signal {idx+1}: {sig_type} @ effective_time={sig_time}, from_bar={sig_bar_time}")
# Log execution data range
if len(df_exec) > 0:
exec_start = df_exec.index[0]
exec_end = df_exec.index[-1]
logger.info(f"Exec data range: {exec_start} ~ {exec_end}")
# Check first few candles for data validity
first_row = df_exec.iloc[0]
logger.info(f"First exec candle: open={first_row['open']}, high={first_row['high']}, low={first_row['low']}, close={first_row['close']}")
# Current pending signal to execute
pending_signal = None # ('open_long', 'close_long', 'open_short', 'close_short')
pending_signal_time = None # Signal effective time
executed_trades_count = 0 # Debug counter
2026-03-01 03:42:10 +08:00
# Progress logging for large datasets
total_exec_candles = len(df_exec)
progress_log_interval = max(1000, total_exec_candles // 10) # Log every 10% or every 1000 candles
logger.info(f"Starting execution loop: {total_exec_candles} candles to process, {len(signal_queue)} signals in queue")
for i, (timestamp, row) in enumerate(df_exec.iterrows()):
2026-03-01 03:42:10 +08:00
# Progress logging
if i > 0 and i % progress_log_interval == 0:
progress_pct = (i / total_exec_candles) * 100
logger.info(f"Execution progress: {i}/{total_exec_candles} ({progress_pct:.1f}%), trades={executed_trades_count}, position={position}")
# 爆仓后直接停止回测,输出结果
if is_liquidated:
break
if position == 0 and capital < min_capital_to_trade:
is_liquidated = True
capital = 0
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
open_ = row['open']
high = row['high']
low = row['low']
close = row['close']
# Use inferred candle price path to determine trigger order
price_path = self._infer_candle_path(open_, high, low, close)
# Check if new signal becomes effective
# Signal executes at the first execution candle open after its candle closes
while signal_queue_idx < len(signal_queue):
sig_effective_time, sig_type, sig_bar_time = signal_queue[signal_queue_idx]
# Debug: log first few signal checks
if i < 10 and signal_queue_idx < len(signal_queue):
logger.debug(f"[i={i}] Checking signal #{signal_queue_idx}: {sig_type} @ {sig_effective_time}, exec_time={timestamp}, position={position}")
# If current exec candle time >= signal effective time, signal can execute
if timestamp >= sig_effective_time:
# Check if signal can execute (based on current position)
# In both mode, open_long can execute even with short position (will auto-close first)
# Similarly, open_short can execute even with long position
can_execute = False
both_mode_active = norm_signals.get('_both_mode', False)
if sig_type == 'open_long':
if position == 0:
can_execute = True
elif both_mode_active and position < 0:
# Both mode: have short position, will close short then open long
can_execute = True
elif sig_type == 'close_long' and position > 0:
can_execute = True
elif sig_type == 'open_short':
if position == 0:
can_execute = True
elif both_mode_active and position > 0:
# Both mode: have long position, will close long then open short
can_execute = True
elif sig_type == 'close_short' and position < 0:
can_execute = True
if can_execute:
pending_signal = sig_type
pending_signal_time = sig_effective_time
signal_queue_idx += 1
2026-03-01 03:42:10 +08:00
if executed_trades_count < 5 or signal_queue_idx <= 5:
logger.info(f"Signal ready: {sig_type} @ {timestamp} (effective_time={sig_effective_time}, sig_bar_time={sig_bar_time}), "
f"will execute at open price (both_mode={both_mode_active}, position={position})")
break
else:
# Signal doesn't meet execution conditions, skip
2026-03-01 03:42:10 +08:00
if signal_queue_idx < 10:
logger.info(f"Skipping signal #{signal_queue_idx}: {sig_type} @ {sig_effective_time} "
f"(position={position}, can_execute=False, both_mode={both_mode_active})")
signal_queue_idx += 1
continue
else:
# Not yet at signal effective time
break
# Check trigger conditions along price path
for path_price in price_path:
if is_liquidated:
break
# 1. Check stop-loss/take-profit/trailing stop (highest priority)
if position != 0 and position_type in ['long', 'short']:
triggered = False
if position_type == 'long' and position > 0:
if highest_since_entry is None:
highest_since_entry = entry_price
highest_since_entry = max(highest_since_entry, path_price)
# Stop loss
if stop_loss_pct_eff > 0:
sl_price = entry_price * (1 - stop_loss_pct_eff)
if path_price <= sl_price:
exec_price = sl_price * (1 - slippage)
commission_fee = position * exec_price * commission
profit = (exec_price - entry_price) * position - commission_fee
capital += profit
if capital < 0:
capital = 0
is_liquidated = True
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long_stop',
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
highest_since_entry = None
lowest_since_entry = None
triggered = True
# Trailing stop
if not triggered and trailing_enabled and trailing_pct_eff > 0:
trail_active = True
if trailing_activation_pct_eff > 0:
trail_active = highest_since_entry >= entry_price * (1 + trailing_activation_pct_eff)
if trail_active:
tr_price = highest_since_entry * (1 - trailing_pct_eff)
if path_price <= tr_price:
exec_price = tr_price * (1 - slippage)
commission_fee = position * exec_price * commission
profit = (exec_price - entry_price) * position - commission_fee
capital += profit
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long_trailing',
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
highest_since_entry = None
lowest_since_entry = None
triggered = True
# Fixed take profit (disabled when trailing stop is enabled)
if not triggered and not trailing_enabled and take_profit_pct_eff > 0:
tp_price = entry_price * (1 + take_profit_pct_eff)
if path_price >= tp_price:
exec_price = tp_price * (1 - slippage)
commission_fee = position * exec_price * commission
profit = (exec_price - entry_price) * position - commission_fee
capital += profit
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long_profit',
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
highest_since_entry = None
lowest_since_entry = None
triggered = True
elif position_type == 'short' and position < 0:
shares = abs(position)
if lowest_since_entry is None:
lowest_since_entry = entry_price
lowest_since_entry = min(lowest_since_entry, path_price)
# Stop loss
if stop_loss_pct_eff > 0:
sl_price = entry_price * (1 + stop_loss_pct_eff)
if path_price >= sl_price:
exec_price = sl_price * (1 + slippage)
commission_fee = shares * exec_price * commission
profit = (entry_price - exec_price) * shares - commission_fee
if capital + profit <= 0:
capital = 0
is_liquidated = True
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
else:
capital += profit
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short_stop',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
highest_since_entry = None
lowest_since_entry = None
triggered = True
# Trailing stop
if not triggered and trailing_enabled and trailing_pct_eff > 0:
trail_active = True
if trailing_activation_pct_eff > 0:
trail_active = lowest_since_entry <= entry_price * (1 - trailing_activation_pct_eff)
if trail_active:
tr_price = lowest_since_entry * (1 + trailing_pct_eff)
if path_price >= tr_price:
exec_price = tr_price * (1 + slippage)
commission_fee = shares * exec_price * commission
profit = (entry_price - exec_price) * shares - commission_fee
if capital + profit <= 0:
capital = 0
is_liquidated = True
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
else:
capital += profit
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short_trailing',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
highest_since_entry = None
lowest_since_entry = None
triggered = True
# Fixed take profit
if not triggered and not trailing_enabled and take_profit_pct_eff > 0:
tp_price = entry_price * (1 - take_profit_pct_eff)
if path_price <= tp_price:
exec_price = tp_price * (1 + slippage)
commission_fee = shares * exec_price * commission
profit = (entry_price - exec_price) * shares - commission_fee
capital += profit
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short_profit',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
highest_since_entry = None
lowest_since_entry = None
triggered = True
if triggered:
pending_signal = None
continue
# 2. Execute pending signal (at open price)
if pending_signal and path_price == open_:
both_mode_active = norm_signals.get('_both_mode', False)
2026-03-01 03:42:10 +08:00
if executed_trades_count < 10:
logger.info(f"Executing pending signal: {pending_signal} @ {timestamp}, path_price={path_price}, open={open_}, position={position}")
# open_long: In both mode, first close short if any, then open long
if pending_signal == 'open_long' and (position == 0 or (both_mode_active and position < 0)):
exec_price = open_ * (1 + slippage)
# If in both mode and have short position, close it first
if both_mode_active and position < 0:
shares_to_close = abs(position)
close_price = open_ * (1 + slippage)
close_commission = shares_to_close * close_price * commission
close_profit = (entry_price - close_price) * shares_to_close - close_commission
capital += close_profit
if capital < 0:
capital = 0
total_commission_paid += close_commission
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short',
'price': round(close_price, 4),
'amount': round(shares_to_close, 4),
'profit': round(close_profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
executed_trades_count += 1
if executed_trades_count <= 10:
logger.info(f"Trade #{executed_trades_count}: close_short (before open_long) @ {timestamp}, price={close_price:.4f}, profit={close_profit:.2f}")
# 检查是否爆仓
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
pending_signal = None
continue
# Now open long
use_capital = capital * entry_pct_cfg
if exec_price > 0:
shares = (use_capital * lev) / exec_price
else:
logger.warning(f"Invalid exec_price={exec_price} at {timestamp}, skipping open_long")
pending_signal = None
continue
commission_fee = shares * exec_price * commission
capital -= commission_fee
total_commission_paid += commission_fee
position = shares
entry_price = exec_price
position_type = 'long'
highest_since_entry = exec_price
lowest_since_entry = exec_price
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'open_long',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
})
executed_trades_count += 1
if executed_trades_count <= 10:
logger.info(f"Trade #{executed_trades_count}: open_long @ {timestamp}, price={exec_price:.4f}, shares={shares:.4f}")
pending_signal = None
elif pending_signal == 'close_long' and position > 0:
exec_price = open_ * (1 - slippage)
commission_fee = position * exec_price * commission
profit = (exec_price - entry_price) * position - commission_fee
capital += profit
if capital < 0:
capital = 0
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long',
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
highest_since_entry = None
lowest_since_entry = None
pending_signal = None
# 检查是否爆仓
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
# open_short: In both mode, first close long if any, then open short
elif pending_signal == 'open_short' and (position == 0 or (both_mode_active and position > 0)):
exec_price = open_ * (1 - slippage)
# If in both mode and have long position, close it first
if both_mode_active and position > 0:
close_price = open_ * (1 - slippage)
close_commission = position * close_price * commission
close_profit = (close_price - entry_price) * position - close_commission
capital += close_profit
if capital < 0:
capital = 0
total_commission_paid += close_commission
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long',
'price': round(close_price, 4),
'amount': round(position, 4),
'profit': round(close_profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
executed_trades_count += 1
if executed_trades_count <= 10:
logger.info(f"Trade #{executed_trades_count}: close_long (before open_short) @ {timestamp}, price={close_price:.4f}, profit={close_profit:.2f}")
# 检查是否爆仓
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
pending_signal = None
continue
# Now open short
use_capital = capital * entry_pct_cfg
if exec_price > 0:
shares = (use_capital * lev) / exec_price
else:
logger.warning(f"Invalid exec_price={exec_price} at {timestamp}, skipping open_short")
pending_signal = None
continue
commission_fee = shares * exec_price * commission
capital -= commission_fee
total_commission_paid += commission_fee
position = -shares
entry_price = exec_price
position_type = 'short'
highest_since_entry = exec_price
lowest_since_entry = exec_price
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'open_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
})
executed_trades_count += 1
if executed_trades_count <= 10:
logger.info(f"Trade #{executed_trades_count}: open_short @ {timestamp}, price={exec_price:.4f}, shares={shares:.4f}")
pending_signal = None
elif pending_signal == 'close_short' and position < 0:
shares = abs(position)
exec_price = open_ * (1 + slippage)
commission_fee = shares * exec_price * commission
profit = (entry_price - exec_price) * shares - commission_fee
capital += profit
if capital < 0:
capital = 0
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
highest_since_entry = None
lowest_since_entry = None
pending_signal = None
# 检查是否爆仓
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
# Calculate current equity
if position > 0:
unrealized = (close - entry_price) * position
current_equity = capital + unrealized
elif position < 0:
shares = abs(position)
unrealized = (entry_price - close) * shares
current_equity = capital + unrealized
else:
current_equity = capital
equity_curve.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'value': round(max(0, current_equity), 2)
})
# Summary log
2026-03-01 03:42:10 +08:00
logger.info(f"MTF simulation complete: executed_trades={executed_trades_count}, total_trades_recorded={len(trades)}, final_capital={capital:.2f}, final_position={position}")
if len(trades) == 0:
2026-03-01 03:42:10 +08:00
if len(signal_queue) == 0:
logger.error(f"No trades executed because signal queue is empty! This usually means:")
logger.error(" 1. Indicator code did not generate any buy/sell signals")
logger.error(" 2. Signal index mismatch between indicator output and df_signal")
logger.error(" 3. All signal values are False")
raise ValueError("No signals generated by indicator code. Please check your indicator code to ensure it sets df['buy'] and/or df['sell'] columns with boolean values.")
else:
logger.error(f"No trades executed despite {len(signal_queue)} signals in queue. signal_queue_idx={signal_queue_idx}")
logger.error(f" Signal queue processed: {signal_queue_idx}/{len(signal_queue)}")
logger.error(f" Final position: {position}, Final capital: {capital:.2f}")
logger.error(" This may indicate:")
logger.error(" 1. Signal timing issues (signal effective time doesn't match execution timeframe)")
logger.error(" 2. Position state conflicts (signals skipped due to position state)")
logger.error(" 3. Capital insufficient for trading")
logger.error(f" First few signals: {signal_queue[:min(5, len(signal_queue))]}")
logger.error(f" Exec data range: {df_exec.index[0]} to {df_exec.index[-1]}")
raise ValueError(f"No trades executed despite {len(signal_queue)} signals. Check signal timing and position state logic.")
return equity_curve, trades, total_commission_paid
2025-12-29 03:06:49 +08:00
def run_code_strategy(
self,
code: str,
symbol: str,
timeframe: str,
limit: int = 1000
) -> Dict[str, Any]:
"""
Run strategy code and return the 'output' variable defined in code.
Used for signal bot preview functionality.
2025-12-29 03:06:49 +08:00
"""
# 1. Calculate time range
2025-12-29 03:06:49 +08:00
end_date = datetime.now()
tf_seconds = self.TIMEFRAME_SECONDS.get(timeframe, 3600)
start_date = end_date - timedelta(seconds=tf_seconds * limit)
# 2. Fetch data (assuming market='crypto', can be optimized later)
2025-12-29 03:06:49 +08:00
df = self._fetch_kline_data('crypto', symbol, timeframe, start_date, end_date)
if df.empty:
return {"error": "No data found"}
# 3. Prepare execution environment
2025-12-29 03:06:49 +08:00
local_vars = {
'df': df.copy(),
'np': np,
'pd': pd,
'output': {} # Default empty output
2025-12-29 03:06:49 +08:00
}
# 4. Execute code
2025-12-29 03:06:49 +08:00
try:
import builtins
def safe_import(name, *args, **kwargs):
allowed = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time']
if name in allowed or name.split('.')[0] in allowed:
return builtins.__import__(name, *args, **kwargs)
raise ImportError(f"Import not allowed: {name}")
safe_builtins = {k: getattr(builtins, k) for k in dir(builtins)
if not k.startswith('_') and k not in ['eval', 'exec', 'compile', 'open', 'input', 'exit']}
safe_builtins['__import__'] = safe_import
exec_env = local_vars.copy()
exec_env['__builtins__'] = safe_builtins
exec(code, exec_env)
return exec_env.get('output', {})
except Exception as e:
logger.error(f"Strategy execution failed: {e}")
logger.error(traceback.format_exc())
return {"error": str(e)}
def run(
self,
indicator_code: str,
market: str,
symbol: str,
timeframe: str,
start_date: datetime,
end_date: datetime,
initial_capital: float = 10000.0,
commission: float = 0.001,
slippage: float = 0.0, # Ideal backtest environment, no slippage
2025-12-29 03:06:49 +08:00
leverage: int = 1,
trade_direction: str = 'long',
strategy_config: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Run backtest.
2025-12-29 03:06:49 +08:00
Args:
indicator_code: Indicator code
market: Market type
symbol: Trading symbol
timeframe: Timeframe
start_date: Start date
end_date: End date
initial_capital: Initial capital
commission: Commission rate
slippage: Slippage
2025-12-29 03:06:49 +08:00
Returns:
Backtest result
2025-12-29 03:06:49 +08:00
"""
# 1. Fetch candle data
2025-12-29 03:06:49 +08:00
df = self._fetch_kline_data(market, symbol, timeframe, start_date, end_date)
if df.empty:
raise ValueError("No candle data available in the backtest date range")
2025-12-29 03:06:49 +08:00
# 2. Execute indicator code to get signals (pass backtest params)
2025-12-29 03:06:49 +08:00
backtest_params = {
'leverage': leverage,
'initial_capital': initial_capital,
'commission': commission,
'trade_direction': trade_direction
}
signals = self._execute_indicator(indicator_code, df, backtest_params)
# 3. Simulate trading
2025-12-29 03:06:49 +08:00
equity_curve, trades, total_commission = self._simulate_trading(
df, signals, initial_capital, commission, slippage, leverage, trade_direction, strategy_config
)
# 4. Calculate metrics
2025-12-29 03:06:49 +08:00
metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission)
# 5. Format result
2025-12-29 03:06:49 +08:00
return self._format_result(metrics, equity_curve, trades)
def _fetch_kline_data(
self,
market: str,
symbol: str,
timeframe: str,
start_date: datetime,
end_date: datetime
) -> pd.DataFrame:
"""Fetch candle data and convert to DataFrame"""
# Calculate required candle count
2025-12-29 03:06:49 +08:00
total_seconds = (end_date - start_date).total_seconds()
tf_seconds = self.TIMEFRAME_SECONDS.get(timeframe, 86400)
limit = math.ceil(total_seconds / tf_seconds) + 200
# Calculate before_time (end date + 1 day)
2025-12-29 03:06:49 +08:00
before_time = int((end_date + timedelta(days=1)).timestamp())
# Fetch data
2025-12-29 03:06:49 +08:00
kline_data = DataSourceFactory.get_kline(
market=market,
symbol=symbol,
timeframe=timeframe,
limit=limit,
before_time=before_time
)
if not kline_data:
2026-03-01 03:42:10 +08:00
logger.warning(f"No candle data retrieved for {market}:{symbol}, timeframe={timeframe}, limit={limit}, before_time={before_time}")
2025-12-29 03:06:49 +08:00
return pd.DataFrame()
2026-03-01 03:42:10 +08:00
logger.info(f"Retrieved {len(kline_data)} candles for {market}:{symbol}, timeframe={timeframe}")
2025-12-29 03:06:49 +08:00
# Convert to DataFrame
2026-03-01 03:42:10 +08:00
try:
df = pd.DataFrame(kline_data)
if df.empty:
logger.warning(f"DataFrame is empty after conversion")
return pd.DataFrame()
# Handle time column - could be seconds or milliseconds
if 'time' not in df.columns:
logger.error(f"Missing 'time' column in kline data. Columns: {df.columns.tolist()}")
return pd.DataFrame()
# Try seconds first, if fails try milliseconds
try:
df['time'] = pd.to_datetime(df['time'], unit='s')
except (ValueError, OverflowError):
# If seconds fails, try milliseconds
try:
df['time'] = pd.to_datetime(df['time'], unit='ms')
except (ValueError, OverflowError):
# If both fail, try direct conversion
df['time'] = pd.to_datetime(df['time'])
df = df.set_index('time')
if df.empty:
logger.warning(f"DataFrame is empty after setting time index")
return pd.DataFrame()
# Log data range before filtering
data_start = df.index.min()
data_end = df.index.max()
logger.info(f"Kline data range: {data_start} to {data_end}, requested range: {start_date} to {end_date}")
# Check if requested range is within available data
if data_start > start_date:
logger.warning(f"Requested start date {start_date} is before available data start {data_start}. "
f"Using available start date instead.")
if data_end < end_date:
logger.warning(f"Requested end date {end_date} is after available data end {data_end}. "
f"Using available end date instead. This may affect backtest results.")
# Filter date range (use available data range if requested range is outside)
# If data ends before requested end_date, use the most recent data up to the requested limit
if data_end < end_date:
# Data ends before requested end date - use the most recent data
# Calculate how many candles we need based on requested time range
requested_seconds = (end_date - start_date).total_seconds()
requested_candles = math.ceil(requested_seconds / tf_seconds)
# Take the most recent N candles from available data
if len(df) > requested_candles:
df_filtered = df.tail(requested_candles).copy()
effective_start = df_filtered.index.min()
effective_end = df_filtered.index.max()
else:
# Use all available data
df_filtered = df.copy()
effective_start = data_start
effective_end = data_end
logger.warning(f"Available data ({len(df)} candles) is less than requested ({requested_candles} candles). "
f"Using all available data from {effective_start} to {effective_end}")
else:
# Normal case: filter by requested date range
effective_start = max(start_date, data_start)
effective_end = min(end_date, data_end)
df_filtered = df[(df.index >= effective_start) & (df.index <= effective_end)].copy()
2026-03-01 03:42:10 +08:00
if df_filtered.empty:
logger.error(f"After filtering date range ({effective_start} to {effective_end}), no data remains. "
f"Available data range: {data_start} to {data_end}, requested: {start_date} to {end_date}")
return pd.DataFrame()
logger.info(f"After filtering: {len(df_filtered)} candles remain for backtest (effective range: {effective_start} to {effective_end})")
return df_filtered
except Exception as e:
logger.error(f"Error processing kline data: {str(e)}")
logger.error(traceback.format_exc())
return pd.DataFrame()
2025-12-29 03:06:49 +08:00
def _execute_indicator(self, code: str, df: pd.DataFrame, backtest_params: dict = None):
"""Execute indicator code to get signals.
2025-12-29 03:06:49 +08:00
Args:
code: Indicator code
df: Candle data
backtest_params: Backtest parameters dict (leverage, initial_capital, commission, trade_direction)
2025-12-29 03:06:49 +08:00
"""
# Supported indicator signal formats:
# - Preferred (simple): df['buy'], df['sell'] as boolean
# - Backtest/internal (4-way): df['open_long'], df['close_long'], df['open_short'], df['close_short'] as boolean
signals = pd.Series(0, index=df.index)
try:
# Prepare execution environment
2025-12-29 03:06:49 +08:00
local_vars = {
'df': df.copy(),
'open': df['open'],
'high': df['high'],
'low': df['low'],
'close': df['close'],
'volume': df['volume'],
'signals': signals,
'np': np,
'pd': pd,
}
# Add backtest params to execution environment (if provided)
2025-12-29 03:06:49 +08:00
if backtest_params:
local_vars['backtest_params'] = backtest_params
local_vars['leverage'] = backtest_params.get('leverage', 1)
local_vars['initial_capital'] = backtest_params.get('initial_capital', 10000)
local_vars['commission'] = backtest_params.get('commission', 0.0002)
local_vars['trade_direction'] = backtest_params.get('trade_direction', 'both')
# === 指标参数支持 ===
# 从 backtest_params 获取用户设置的指标参数
user_indicator_params = (backtest_params or {}).get('indicator_params', {})
# 解析指标代码中声明的参数
declared_params = IndicatorParamsParser.parse_params(code)
# 合并参数(用户值优先,否则使用默认值)
merged_params = IndicatorParamsParser.merge_params(declared_params, user_indicator_params)
local_vars['params'] = merged_params
# === 指标调用器支持 ===
user_id = (backtest_params or {}).get('user_id', 1)
indicator_id = (backtest_params or {}).get('indicator_id')
indicator_caller = IndicatorCaller(user_id, indicator_id)
local_vars['call_indicator'] = indicator_caller.call_indicator
# Add technical indicator functions
2025-12-29 03:06:49 +08:00
local_vars.update(self._get_indicator_functions())
# Add safe builtins (keep full builtins to support lambda etc.)
# but remove dangerous functions like eval, exec, open etc.
2025-12-29 03:06:49 +08:00
import builtins
# Create restricted __import__ that only allows safe modules
2025-12-29 03:06:49 +08:00
def safe_import(name, *args, **kwargs):
"""Only allow importing numpy, pandas, math, json etc."""
2025-12-29 03:06:49 +08:00
allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time']
if name in allowed_modules or name.split('.')[0] in allowed_modules:
return builtins.__import__(name, *args, **kwargs)
raise ImportError(f"Import not allowed: {name}")
2025-12-29 03:06:49 +08:00
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',
'copyright', 'credits', 'license'
]}
# Add restricted __import__
2025-12-29 03:06:49 +08:00
safe_builtins['__import__'] = safe_import
# Create unified execution environment (globals and locals use same dict)
# This allows functions to access np, pd etc.
2025-12-29 03:06:49 +08:00
exec_env = local_vars.copy()
exec_env['__builtins__'] = safe_builtins
# Pre-execute import statements to ensure np and pd are available
2025-12-29 03:06:49 +08:00
pre_import_code = """
import numpy as np
import pandas as pd
"""
exec(pre_import_code, exec_env)
# Security check: validate code doesn't contain dangerous operations
2025-12-29 03:06:49 +08:00
from app.utils.safe_exec import validate_code_safety
is_safe, error_msg = validate_code_safety(code)
if not is_safe:
logger.error(f"Backtest code security check failed: {error_msg}")
raise ValueError(f"Code contains unsafe operations: {error_msg}")
2025-12-29 03:06:49 +08:00
# Execute user code safely (with timeout)
2025-12-29 03:06:49 +08:00
from app.utils.safe_exec import safe_exec_code
exec_result = safe_exec_code(
code=code,
exec_globals=exec_env,
exec_locals=exec_env,
timeout=60 # Backtest allows longer time (60 seconds)
2025-12-29 03:06:49 +08:00
)
if not exec_result['success']:
raise RuntimeError(f"Code execution failed: {exec_result['error']}")
2025-12-29 03:06:49 +08:00
# Get the executed df
executed_df = exec_env.get('df', df)
# Validation: if chart signals are provided, df['buy']/df['sell'] must exist for backtest normalization.
# This keeps indicator scripts simple and consistent (chart=buy/sell, execution=normalized in backend).
output_obj = exec_env.get('output')
has_output_signals = isinstance(output_obj, dict) and isinstance(output_obj.get('signals'), list) and len(output_obj.get('signals')) > 0
if has_output_signals and not all(col in executed_df.columns for col in ['buy', 'sell']):
raise ValueError(
"Invalid indicator script: output['signals'] is provided, but df['buy'] and df['sell'] are missing. "
"Please set df['buy'] and df['sell'] as boolean columns (len == len(df))."
)
# Extract signals from executed df
if all(col in executed_df.columns for col in ['open_long', 'close_long', 'open_short', 'close_short']):
signals = {
'open_long': executed_df['open_long'].fillna(False).astype(bool),
'close_long': executed_df['close_long'].fillna(False).astype(bool),
'open_short': executed_df['open_short'].fillna(False).astype(bool),
'close_short': executed_df['close_short'].fillna(False).astype(bool)
}
# Convention: backtest uses 4-way signals only.
# Position sizing, TP/SL, trailing, etc must be handled by strategy_config / strategy logic.
elif all(col in executed_df.columns for col in ['buy', 'sell']):
# Simple buy/sell signals (recommended for indicator authors)
2026-03-01 03:42:10 +08:00
buy_series = executed_df['buy'].fillna(False).astype(bool)
sell_series = executed_df['sell'].fillna(False).astype(bool)
# Ensure signals have the same index as df
if not buy_series.index.equals(df.index):
logger.warning(f"Buy signal index mismatch in _execute_indicator! Reindexing...")
buy_series = buy_series.reindex(df.index, fill_value=False)
if not sell_series.index.equals(df.index):
logger.warning(f"Sell signal index mismatch in _execute_indicator! Reindexing...")
sell_series = sell_series.reindex(df.index, fill_value=False)
# Debug: log signal statistics
buy_count = buy_series.sum()
sell_count = sell_series.sum()
logger.info(f"Indicator execution: buy signals={buy_count}, sell signals={sell_count}, total_candles={len(df)}")
2025-12-29 03:06:49 +08:00
signals = {
2026-03-01 03:42:10 +08:00
'buy': buy_series,
'sell': sell_series
2025-12-29 03:06:49 +08:00
}
else:
raise ValueError(
"Indicator must define either 4-way columns "
"(df['open_long'], df['close_long'], df['open_short'], df['close_short']) "
"or simple columns (df['buy'], df['sell'])."
)
except Exception as e:
logger.error(f"Indicator code execution error: {e}")
2025-12-29 03:06:49 +08:00
logger.error(traceback.format_exc())
return signals
def _get_indicator_functions(self) -> Dict:
"""Get technical indicator functions"""
2025-12-29 03:06:49 +08:00
def SMA(series, period):
return series.rolling(window=period).mean()
def EMA(series, period):
return series.ewm(span=period, adjust=False).mean()
def RSI(series, period=14):
delta = series.diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
return 100 - (100 / (1 + rs))
def MACD(series, fast=12, slow=26, signal=9):
exp1 = series.ewm(span=fast, adjust=False).mean()
exp2 = series.ewm(span=slow, adjust=False).mean()
macd = exp1 - exp2
macd_signal = macd.ewm(span=signal, adjust=False).mean()
macd_hist = macd - macd_signal
return macd, macd_signal, macd_hist
def BOLL(series, period=20, std_dev=2):
middle = series.rolling(window=period).mean()
std = series.rolling(window=period).std()
upper = middle + std_dev * std
lower = middle - std_dev * std
return upper, middle, lower
def ATR(high, low, close, period=14):
tr1 = high - low
tr2 = abs(high - close.shift())
tr3 = abs(low - close.shift())
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
return tr.rolling(window=period).mean()
def CROSSOVER(series1, series2):
return (series1 > series2) & (series1.shift(1) <= series2.shift(1))
def CROSSUNDER(series1, series2):
return (series1 < series2) & (series1.shift(1) >= series2.shift(1))
return {
'SMA': SMA,
'EMA': EMA,
'RSI': RSI,
'MACD': MACD,
'BOLL': BOLL,
'ATR': ATR,
'CROSSOVER': CROSSOVER,
'CROSSUNDER': CROSSUNDER,
}
def _simulate_trading(
self,
df: pd.DataFrame,
signals,
initial_capital: float,
commission: float,
slippage: float,
leverage: int = 1,
trade_direction: str = 'long',
strategy_config: Optional[Dict[str, Any]] = None
) -> tuple:
"""
Simulate trading.
2025-12-29 03:06:49 +08:00
Args:
signals: Signals, can be pd.Series (old format) or dict (new 4-way format)
trade_direction: Trade direction
- 'long': Long only (buy->sell)
- 'short': Short only (sell->buy, reversed PnL)
- 'both': Both directions (buy->sell long + sell->buy short)
2025-12-29 03:06:49 +08:00
"""
# Normalize supported signal formats into 4-way signals.
if not isinstance(signals, dict):
raise ValueError("signals must be a dict (either 4-way or buy/sell).")
if all(k in signals for k in ['open_long', 'close_long', 'open_short', 'close_short']):
norm = signals
elif all(k in signals for k in ['buy', 'sell']):
buy = signals['buy'].fillna(False).astype(bool)
sell = signals['sell'].fillna(False).astype(bool)
td = (trade_direction or 'both')
td = str(td).lower()
if td not in ['long', 'short', 'both']:
td = 'both'
# Mapping rules:
# - long: buy=open_long, sell=close_long
# - short: sell=open_short, buy=close_short
# - both: buy=open_long+close_short, sell=open_short+close_long
if td == 'long':
norm = {
'open_long': buy,
'close_long': sell,
'open_short': pd.Series([False] * len(df), index=df.index),
'close_short': pd.Series([False] * len(df), index=df.index),
}
elif td == 'short':
norm = {
'open_long': pd.Series([False] * len(df), index=df.index),
'close_long': pd.Series([False] * len(df), index=df.index),
'open_short': sell,
'close_short': buy,
'_both_mode': False,
2025-12-29 03:06:49 +08:00
}
else:
# Both mode: buy signal opens long (auto-close short first)
# sell signal opens short (auto-close long first)
2025-12-29 03:06:49 +08:00
norm = {
'open_long': buy,
'close_long': pd.Series([False] * len(df), index=df.index), # Disabled, handled by open_short
2025-12-29 03:06:49 +08:00
'open_short': sell,
'close_short': pd.Series([False] * len(df), index=df.index), # Disabled, handled by open_long
'_both_mode': True, # Flag to indicate auto-close opposing position
2025-12-29 03:06:49 +08:00
}
else:
raise ValueError("signals dict must contain either 4-way keys or buy/sell keys.")
return self._simulate_trading_new_format(df, norm, initial_capital, commission, slippage, leverage, trade_direction, strategy_config)
def _simulate_trading_new_format(
self,
df: pd.DataFrame,
signals: dict,
initial_capital: float,
commission: float,
slippage: float,
leverage: int = 1,
trade_direction: str = 'both',
strategy_config: Optional[Dict[str, Any]] = None
) -> tuple:
"""
Simulate trading with 4-way signal format (supports position management and scaling).
2025-12-29 03:06:49 +08:00
Args:
trade_direction: Trade direction ('long', 'short', 'both')
2025-12-29 03:06:49 +08:00
"""
equity_curve = []
trades = []
total_commission_paid = 0
is_liquidated = False
liquidation_price = 0
min_capital_to_trade = 1.0 # Below this balance, consider wiped out, no new orders
2025-12-29 03:06:49 +08:00
capital = initial_capital
position = 0 # Positive=long, Negative=short
entry_price = 0 # Average entry price
2025-12-29 03:06:49 +08:00
position_type = None # 'long' or 'short'
# Position management related
2025-12-29 03:06:49 +08:00
has_position_management = 'add_long' in signals and 'add_short' in signals
position_batches = [] # Store each position batch: [{'price': xxx, 'amount': xxx}, ...]
2025-12-29 03:06:49 +08:00
# --- Strategy config: signals + parameters = strategy (sent from BacktestModal as strategyConfig) ---
cfg = strategy_config or {}
exec_cfg = cfg.get('execution') or {}
# Signal confirmation / execution timing:
# - bar_close: execute on the same bar close (more aggressive)
# - next_bar_open: execute on next bar open after signal is confirmed on bar close (recommended, closer to live)
signal_timing = str(exec_cfg.get('signalTiming') or 'next_bar_open').strip().lower()
risk_cfg = cfg.get('risk') or {}
stop_loss_pct = float(risk_cfg.get('stopLossPct') or 0.0)
take_profit_pct = float(risk_cfg.get('takeProfitPct') or 0.0)
trailing_cfg = risk_cfg.get('trailing') or {}
trailing_enabled = bool(trailing_cfg.get('enabled'))
trailing_pct = float(trailing_cfg.get('pct') or 0.0)
trailing_activation_pct = float(trailing_cfg.get('activationPct') or 0.0)
# Risk percentages are defined on margin PnL; convert to price move thresholds by leverage.
lev = max(int(leverage or 1), 1)
stop_loss_pct_eff = stop_loss_pct / lev
take_profit_pct_eff = take_profit_pct / lev
trailing_pct_eff = trailing_pct / lev
trailing_activation_pct_eff = trailing_activation_pct / lev
# Conflict rule (TP vs trailing):
# - If trailing is enabled, it takes precedence.
# - If activationPct is not provided, reuse takeProfitPct as the trailing activation threshold.
# - When trailing is enabled, fixed take-profit exits are disabled to avoid ambiguity.
if trailing_enabled and trailing_pct_eff > 0:
if trailing_activation_pct_eff <= 0 and take_profit_pct_eff > 0:
trailing_activation_pct_eff = take_profit_pct_eff
# IMPORTANT: risk percentages are defined on margin PnL (user expectation):
# e.g. 10x leverage + 5% SL means ~0.5% adverse price move.
lev = max(int(leverage or 1), 1)
stop_loss_pct_eff = stop_loss_pct / lev
take_profit_pct_eff = take_profit_pct / lev
trailing_pct_eff = trailing_pct / lev
trailing_activation_pct_eff = trailing_activation_pct / lev
pos_cfg = cfg.get('position') or {}
entry_pct_cfg = float(pos_cfg.get('entryPct') or 1.0) # expected 0~1
# Accept both 0~1 and 0~100 inputs (some clients may send percent units).
if entry_pct_cfg > 1:
entry_pct_cfg = entry_pct_cfg / 100.0
entry_pct_cfg = max(0.0, min(entry_pct_cfg, 1.0))
scale_cfg = cfg.get('scale') or {}
trend_add_cfg = scale_cfg.get('trendAdd') or {}
dca_add_cfg = scale_cfg.get('dcaAdd') or {}
trend_reduce_cfg = scale_cfg.get('trendReduce') or {}
adverse_reduce_cfg = scale_cfg.get('adverseReduce') or {}
trend_add_enabled = bool(trend_add_cfg.get('enabled'))
trend_add_step_pct = float(trend_add_cfg.get('stepPct') or 0.0)
trend_add_size_pct = float(trend_add_cfg.get('sizePct') or 0.0)
trend_add_max_times = int(trend_add_cfg.get('maxTimes') or 0)
dca_add_enabled = bool(dca_add_cfg.get('enabled'))
dca_add_step_pct = float(dca_add_cfg.get('stepPct') or 0.0)
dca_add_size_pct = float(dca_add_cfg.get('sizePct') or 0.0)
dca_add_max_times = int(dca_add_cfg.get('maxTimes') or 0)
# Prevent logical conflict: trend scale-in and mean-reversion scale-in should not run together.
# Otherwise both may trigger in the same candle (high/low both hit), causing double scaling unexpectedly.
if trend_add_enabled and dca_add_enabled:
dca_add_enabled = False
trend_reduce_enabled = bool(trend_reduce_cfg.get('enabled'))
trend_reduce_step_pct = float(trend_reduce_cfg.get('stepPct') or 0.0)
trend_reduce_size_pct = float(trend_reduce_cfg.get('sizePct') or 0.0)
trend_reduce_max_times = int(trend_reduce_cfg.get('maxTimes') or 0)
adverse_reduce_enabled = bool(adverse_reduce_cfg.get('enabled'))
adverse_reduce_step_pct = float(adverse_reduce_cfg.get('stepPct') or 0.0)
adverse_reduce_size_pct = float(adverse_reduce_cfg.get('sizePct') or 0.0)
adverse_reduce_max_times = int(adverse_reduce_cfg.get('maxTimes') or 0)
# Trigger pct as post-leverage margin threshold: divide by leverage for price trigger
# e.g. 10x + 5% trigger means ~0.5% price movement
2025-12-29 03:06:49 +08:00
trend_add_step_pct_eff = trend_add_step_pct / lev
dca_add_step_pct_eff = dca_add_step_pct / lev
trend_reduce_step_pct_eff = trend_reduce_step_pct / lev
adverse_reduce_step_pct_eff = adverse_reduce_step_pct / lev
# State: used for trailing exits and scale-in/scale-out anchor levels
highest_since_entry = None
lowest_since_entry = None
trend_add_times = 0
dca_add_times = 0
trend_reduce_times = 0
adverse_reduce_times = 0
last_trend_add_anchor = None
last_dca_add_anchor = None
last_trend_reduce_anchor = None
last_adverse_reduce_anchor = None
# Convert signals to arrays
2025-12-29 03:06:49 +08:00
open_long_arr = signals['open_long'].values
close_long_arr = signals['close_long'].values
open_short_arr = signals['open_short'].values
close_short_arr = signals['close_short'].values
# Apply execution timing to avoid look-ahead bias:
# If signals are computed using bar close, realistic execution is next bar open.
if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']:
open_long_arr = np.insert(open_long_arr[:-1], 0, False)
close_long_arr = np.insert(close_long_arr[:-1], 0, False)
open_short_arr = np.insert(open_short_arr[:-1], 0, False)
close_short_arr = np.insert(close_short_arr[:-1], 0, False)
# Filter signals by trade direction
2025-12-29 03:06:49 +08:00
if trade_direction == 'long':
# Long only: disable all short signals
2025-12-29 03:06:49 +08:00
open_short_arr = np.zeros(len(df), dtype=bool)
close_short_arr = np.zeros(len(df), dtype=bool)
elif trade_direction == 'short':
# Short only: disable all long signals
2025-12-29 03:06:49 +08:00
open_long_arr = np.zeros(len(df), dtype=bool)
close_long_arr = np.zeros(len(df), dtype=bool)
else:
pass
# Add position signals
2025-12-29 03:06:49 +08:00
if has_position_management:
add_long_arr = signals['add_long'].values
add_short_arr = signals['add_short'].values
position_size_arr = signals.get('position_size', pd.Series([0.0] * len(df))).values
# Filter add signals by trade direction
2025-12-29 03:06:49 +08:00
if trade_direction == 'long':
add_short_arr = np.zeros(len(df), dtype=bool)
elif trade_direction == 'short':
add_long_arr = np.zeros(len(df), dtype=bool)
# Entry trigger price (if indicator provides)
2025-12-29 03:06:49 +08:00
open_long_price_arr = signals.get('open_long_price', pd.Series([0.0] * len(df))).values
open_short_price_arr = signals.get('open_short_price', pd.Series([0.0] * len(df))).values
# Exit target price (if indicator provides)
2025-12-29 03:06:49 +08:00
close_long_price_arr = signals.get('close_long_price', pd.Series([0.0] * len(df))).values
close_short_price_arr = signals.get('close_short_price', pd.Series([0.0] * len(df))).values
# Add position price (if indicator provides)
2025-12-29 03:06:49 +08:00
add_long_price_arr = signals.get('add_long_price', pd.Series([0.0] * len(df))).values
add_short_price_arr = signals.get('add_short_price', pd.Series([0.0] * len(df))).values
for i, (timestamp, row) in enumerate(df.iterrows()):
# 爆仓后直接停止回测,输出结果
2025-12-29 03:06:49 +08:00
if is_liquidated:
break
2025-12-29 03:06:49 +08:00
# If no position and balance low, stop trading
2025-12-29 03:06:49 +08:00
if position == 0 and capital < min_capital_to_trade:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(float(row.get('close', 0) or 0), 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
break # 直接停止
2025-12-29 03:06:49 +08:00
# Use OHLC to evaluate triggers.
high = row['high']
low = row['low']
close = row['close']
open_ = row.get('open', close)
# Default execution price depends on timing mode
# - bar_close: close
# - next_bar_open: open (this bar is the next bar for a prior signal)
exec_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else close
# --- Risk controls: SL / TP / trailing exit (highest priority) ---
if position != 0 and position_type in ['long', 'short']:
# Update extreme prices for trailing stop
2025-12-29 03:06:49 +08:00
if position_type == 'long':
if highest_since_entry is None:
highest_since_entry = entry_price
if lowest_since_entry is None:
lowest_since_entry = entry_price
highest_since_entry = max(highest_since_entry, high)
lowest_since_entry = min(lowest_since_entry, low)
else: # short
if lowest_since_entry is None:
lowest_since_entry = entry_price
if highest_since_entry is None:
highest_since_entry = entry_price
lowest_since_entry = min(lowest_since_entry, low)
highest_since_entry = max(highest_since_entry, high)
# Collect forced exit points in same candle
# Backtest is candle-level, cannot determine exact trigger order; using priority:
# StopLoss > TrailingStop > TakeProfit
2025-12-29 03:06:49 +08:00
candidates = [] # [(trade_type, trigger_price)]
if position_type == 'long' and position > 0:
if stop_loss_pct_eff > 0:
sl_price = entry_price * (1 - stop_loss_pct_eff)
if low <= sl_price:
candidates.append(('close_long_stop', sl_price))
# Fixed take-profit exit is disabled when trailing is enabled (see conflict rule above).
if (not trailing_enabled) and take_profit_pct_eff > 0:
tp_price = entry_price * (1 + take_profit_pct_eff)
if high >= tp_price:
candidates.append(('close_long_profit', tp_price))
if trailing_enabled and trailing_pct_eff > 0 and highest_since_entry is not None:
trail_active = True
if trailing_activation_pct_eff > 0:
trail_active = highest_since_entry >= entry_price * (1 + trailing_activation_pct_eff)
if trail_active:
tr_price = highest_since_entry * (1 - trailing_pct_eff)
if low <= tr_price:
candidates.append(('close_long_trailing', tr_price))
if candidates:
# Select by priority: SL > Trailing > TP
2025-12-29 03:06:49 +08:00
pri = {'close_long_stop': 0, 'close_long_trailing': 1, 'close_long_profit': 2}
trade_type, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), x[1]))[0]
exec_price_close = trigger_price * (1 - slippage)
commission_fee_close = position * exec_price_close * commission
# Entry commission deducted, only deduct exit commission
2025-12-29 03:06:49 +08:00
profit = (exec_price_close - entry_price) * position - commission_fee_close
capital += profit
total_commission_paid += commission_fee_close
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': trade_type,
'price': round(exec_price_close, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)})
continue
if position_type == 'short' and position < 0:
shares = abs(position)
if stop_loss_pct_eff > 0:
sl_price = entry_price * (1 + stop_loss_pct_eff)
if high >= sl_price:
candidates.append(('close_short_stop', sl_price))
# Fixed take-profit exit is disabled when trailing is enabled (see conflict rule above).
if (not trailing_enabled) and take_profit_pct_eff > 0:
tp_price = entry_price * (1 - take_profit_pct_eff)
if low <= tp_price:
candidates.append(('close_short_profit', tp_price))
if trailing_enabled and trailing_pct_eff > 0 and lowest_since_entry is not None:
trail_active = True
if trailing_activation_pct_eff > 0:
trail_active = lowest_since_entry <= entry_price * (1 - trailing_activation_pct_eff)
if trail_active:
tr_price = lowest_since_entry * (1 + trailing_pct_eff)
if high >= tr_price:
candidates.append(('close_short_trailing', tr_price))
if candidates:
# Select by priority: SL > Trailing > TP
2025-12-29 03:06:49 +08:00
pri = {'close_short_stop': 0, 'close_short_trailing': 1, 'close_short_profit': 2}
trade_type, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), -x[1]))[0]
exec_price_close = trigger_price * (1 + slippage)
commission_fee_close = shares * exec_price_close * commission
# Entry commission deducted, only deduct exit commission
2025-12-29 03:06:49 +08:00
profit = (entry_price - exec_price_close) * shares - commission_fee_close
if capital + profit <= 0:
capital = 0
is_liquidated = True
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price_close, 4),
'amount': round(shares, 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
position = 0
position_type = None
liquidation_price = 0
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
capital += profit
total_commission_paid += commission_fee_close
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': trade_type,
'price': round(exec_price_close, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)})
continue
# Handle exit signals (priority, SL/TP)
2025-12-29 03:06:49 +08:00
if position > 0 and close_long_arr[i]:
# Close long: use indicator price or close
2025-12-29 03:06:49 +08:00
if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']:
target_price = open_
else:
target_price = close_long_price_arr[i] if close_long_price_arr[i] > 0 else close
exec_price = target_price * (1 - slippage)
commission_fee = position * exec_price * commission
profit = (exec_price - entry_price) * position - commission_fee
capital += profit
total_commission_paid += commission_fee
# NOTE:
# This is a "signal close" (not a forced stop-loss/take-profit/trailing exit).
# Do NOT label it as *_stop/*_profit based on PnL sign, otherwise it looks like a stop-loss happened
# even when risk controls are disabled (stopLossPct/takeProfitPct == 0).
trade_type = 'close_long'
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': trade_type,
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
# Stop if balance too low after exit
2025-12-29 03:06:49 +08:00
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
elif position < 0 and close_short_arr[i]:
# Close short: use indicator price or close
2025-12-29 03:06:49 +08:00
if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']:
target_price = open_
else:
target_price = close_short_price_arr[i] if close_short_price_arr[i] > 0 else close
exec_price = target_price * (1 + slippage)
shares = abs(position)
commission_fee = shares * exec_price * commission
profit = (entry_price - exec_price) * shares - commission_fee
if capital + profit <= 0:
logger.warning(f"Insufficient funds when closing short - liquidation")
2025-12-29 03:06:49 +08:00
capital = 0
is_liquidated = True
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(-capital, 2),
'balance': 0
})
position = 0
position_type = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
capital += profit
total_commission_paid += commission_fee
# Signal close (not forced TP/SL/trailing).
trade_type = 'close_short'
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': trade_type,
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
# If this candle has a main strategy signal (open/close long/short),
# we must NOT apply any scale-in/scale-out actions on the same candle.
main_signal_on_bar = bool(open_long_arr[i] or open_short_arr[i] or close_long_arr[i] or close_short_arr[i])
# --- Parameterized scaling rules (no strategy code needed) ---
# Rules:
# - Trend scale-in: long triggers when price rises stepPct from anchor; short triggers when price falls stepPct from anchor
# - Mean-reversion DCA: long triggers when price falls stepPct from anchor; short triggers when price rises stepPct from anchor
# - Trend reduce: long reduces on rise; short reduces on fall
# - Adverse reduce: long reduces on fall; short reduces on rise
if (not main_signal_on_bar) and position != 0 and position_type in ['long', 'short'] and capital >= min_capital_to_trade:
# Long
2025-12-29 03:06:49 +08:00
if position_type == 'long' and position > 0:
# Trend scale-in (trigger on higher price)
if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times):
anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price
trigger = anchor * (1 + trend_add_step_pct_eff)
if high >= trigger:
order_pct = trend_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 + slippage)
use_capital = capital * order_pct
# Commission from notional value
2025-12-29 03:06:49 +08:00
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = position * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position += shares_add
entry_price = total_cost_after / position
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 - 1.0 / leverage)
trend_add_times += 1
last_trend_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_long',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Mean-reversion DCA (trigger on lower price)
if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times):
anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price
trigger = anchor * (1 - dca_add_step_pct_eff)
if low <= trigger:
order_pct = dca_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 + slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = position * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position += shares_add
entry_price = total_cost_after / position
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 - 1.0 / leverage)
dca_add_times += 1
last_dca_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_long',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Trend reduce (trigger on higher price)
if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times):
anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price
trigger = anchor * (1 + trend_reduce_step_pct_eff)
if high >= trigger:
reduce_pct = max(trend_reduce_size_pct, 0.0)
reduce_shares = position * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 - slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position -= reduce_shares
if position <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
else:
liquidation_price = entry_price * (1 - 1.0 / leverage)
trend_reduce_times += 1
last_trend_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_long',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Adverse reduce (trigger on lower price)
if position_type == 'long' and position > 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times):
anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price
trigger = anchor * (1 - adverse_reduce_step_pct_eff)
if low <= trigger:
reduce_pct = max(adverse_reduce_size_pct, 0.0)
reduce_shares = position * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 - slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position -= reduce_shares
if position <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
else:
liquidation_price = entry_price * (1 - 1.0 / leverage)
adverse_reduce_times += 1
last_adverse_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_long',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Short
2025-12-29 03:06:49 +08:00
if position_type == 'short' and position < 0:
shares_total = abs(position)
# Trend scale-in (trigger on lower price)
if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times):
anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price
trigger = anchor * (1 - trend_add_step_pct_eff)
if low <= trigger:
order_pct = trend_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 - slippage) # Sell to add short, slippage unfavorable
2025-12-29 03:06:49 +08:00
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = shares_total * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position -= shares_add
shares_total = abs(position)
entry_price = total_cost_after / shares_total
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 + 1.0 / leverage)
trend_add_times += 1
last_trend_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_short',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Mean-reversion DCA (trigger on higher price)
if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times):
anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price
trigger = anchor * (1 + dca_add_step_pct_eff)
if high >= trigger:
order_pct = dca_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 - slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = shares_total * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position -= shares_add
shares_total = abs(position)
entry_price = total_cost_after / shares_total
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 + 1.0 / leverage)
dca_add_times += 1
last_dca_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_short',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Trend reduce (trigger on lower price)
if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times):
anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price
trigger = anchor * (1 - trend_reduce_step_pct_eff)
if low <= trigger:
reduce_pct = max(trend_reduce_size_pct, 0.0)
reduce_shares = shares_total * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 + slippage) # Cover more expensive
2025-12-29 03:06:49 +08:00
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position += reduce_shares
shares_total = abs(position)
if shares_total <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
else:
liquidation_price = entry_price * (1 + 1.0 / leverage)
trend_reduce_times += 1
last_trend_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_short',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Adverse reduce (trigger on higher price)
if position_type == 'short' and position < 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times):
anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price
trigger = anchor * (1 + adverse_reduce_step_pct_eff)
if high >= trigger:
reduce_pct = max(adverse_reduce_size_pct, 0.0)
reduce_shares = shares_total * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 + slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position += reduce_shares
shares_total = abs(position)
if shares_total <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
else:
liquidation_price = entry_price * (1 + 1.0 / leverage)
adverse_reduce_times += 1
last_adverse_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_short',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Handle add position signals
2025-12-29 03:06:49 +08:00
if has_position_management and (not main_signal_on_bar):
if position > 0 and add_long_arr[i] and capital >= min_capital_to_trade:
# Add long: use indicator price or close
2025-12-29 03:06:49 +08:00
target_price = add_long_price_arr[i] if add_long_price_arr[i] > 0 else close
exec_price = target_price * (1 + slippage)
# Use specified pct to add
2025-12-29 03:06:49 +08:00
position_pct = position_size_arr[i] if position_size_arr[i] > 0 else 0.1
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
# Update average cost
2025-12-29 03:06:49 +08:00
total_cost_before = position * entry_price
total_cost_after = total_cost_before + shares * exec_price
position += shares
entry_price = total_cost_after / position
capital -= commission_fee
total_commission_paid += commission_fee
# Recalculate liquidation price
2025-12-29 03:06:49 +08:00
liquidation_price = entry_price * (1 - 1.0 / leverage)
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_long',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
elif position < 0 and add_short_arr[i] and capital >= min_capital_to_trade:
# Add short: use indicator price or close
2025-12-29 03:06:49 +08:00
target_price = add_short_price_arr[i] if add_short_price_arr[i] > 0 else close
exec_price = target_price * (1 - slippage)
# Use specified pct to add
2025-12-29 03:06:49 +08:00
position_pct = position_size_arr[i] if position_size_arr[i] > 0 else 0.1
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
# Update average cost
2025-12-29 03:06:49 +08:00
current_shares = abs(position)
total_cost_before = current_shares * entry_price
total_cost_after = total_cost_before + shares * exec_price
position -= shares # Short is negative
2025-12-29 03:06:49 +08:00
current_shares = abs(position)
entry_price = total_cost_after / current_shares
capital -= commission_fee
total_commission_paid += commission_fee
# Recalculate liquidation price
2025-12-29 03:06:49 +08:00
liquidation_price = entry_price * (1 + 1.0 / leverage)
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Handle entry signals
# In both mode, open_long/open_short can auto-close opposing position first
both_mode_active = signals.get('_both_mode', False)
# open_long: can execute when position==0, OR when both_mode and position<0 (auto-close short first)
if open_long_arr[i] and (position == 0 or (both_mode_active and position < 0)) and capital >= min_capital_to_trade:
# In both mode with short position, close it first
if both_mode_active and position < 0:
shares_to_close = abs(position)
close_price = open_ * (1 + slippage)
close_commission = shares_to_close * close_price * commission
close_profit = (entry_price - close_price) * shares_to_close - close_commission
capital += close_profit
if capital < 0:
capital = 0
total_commission_paid += close_commission
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short',
'price': round(close_price, 4),
'amount': round(shares_to_close, 4),
'profit': round(close_profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
# 检查是否爆仓
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
# Now open long (position is guaranteed to be 0 here)
# Use indicator entry price or close
2025-12-29 03:06:49 +08:00
if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']:
base_price = open_
else:
base_price = open_long_price_arr[i] if open_long_price_arr[i] > 0 else close
exec_price = base_price * (1 + slippage)
# Use specified pct (entryPct > position_size > full)
2025-12-29 03:06:49 +08:00
position_pct = None
if entry_pct_cfg and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
elif has_position_management and position_size_arr[i] > 0:
position_pct = position_size_arr[i]
if position_pct is not None and position_pct > 0 and position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
position = shares
entry_price = exec_price
position_type = 'long'
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 - 1.0 / leverage)
highest_since_entry = entry_price
lowest_since_entry = entry_price
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_price
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'open_long',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Strict intrabar stop-loss / liquidation check right after entry (closer to live trading).
# If this bar touches stop-loss price, close immediately at stop price (with slippage).
# If this bar also touches liquidation price, assume stop-loss triggers first only if it is above liquidation.
if position_type == 'long' and position > 0:
sl_price = entry_price * (1 - stop_loss_pct_eff) if stop_loss_pct_eff > 0 else None
hit_sl = (sl_price is not None) and (low <= sl_price)
hit_liq = liquidation_price > 0 and (low <= liquidation_price)
if hit_sl or hit_liq:
if hit_liq and (not hit_sl or (sl_price is not None and sl_price <= liquidation_price)):
# Liquidation happens before stop-loss (or stop-loss not configured).
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(position, 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
else:
# Stop-loss triggers first.
exec_price_close = sl_price * (1 - slippage)
commission_fee_close = position * exec_price_close * commission
profit = (exec_price_close - entry_price) * position - commission_fee_close
capital += profit
total_commission_paid += commission_fee_close
if capital <= 0:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long_stop',
'price': round(exec_price_close, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)})
continue
# open_short: can execute when position==0, OR when both_mode and position>0 (auto-close long first)
elif open_short_arr[i] and (position == 0 or (both_mode_active and position > 0)) and capital >= min_capital_to_trade:
# In both mode with long position, close it first
if both_mode_active and position > 0:
close_price = open_ * (1 - slippage)
close_commission = position * close_price * commission
close_profit = (close_price - entry_price) * position - close_commission
capital += close_profit
if capital < 0:
capital = 0
total_commission_paid += close_commission
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long',
'price': round(close_price, 4),
'amount': round(position, 4),
'profit': round(close_profit, 2),
'balance': round(max(0, capital), 2)
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
# 检查是否爆仓
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
# Now open short (position is guaranteed to be 0 here)
# Use indicator entry price or close
2025-12-29 03:06:49 +08:00
if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']:
base_price = open_
else:
base_price = open_short_price_arr[i] if open_short_price_arr[i] > 0 else close
exec_price = base_price * (1 - slippage)
# Use specified pct (entryPct > position_size > full)
2025-12-29 03:06:49 +08:00
position_pct = None
if entry_pct_cfg and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
elif has_position_management and position_size_arr[i] > 0:
position_pct = position_size_arr[i]
if position_pct is not None and position_pct > 0 and position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
position = -shares
entry_price = exec_price
position_type = 'short'
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 + 1.0 / leverage)
highest_since_entry = entry_price
lowest_since_entry = entry_price
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_price
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'open_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Strict intrabar stop-loss / liquidation check right after entry (closer to live trading).
if position_type == 'short' and position < 0:
sl_price = entry_price * (1 + stop_loss_pct_eff) if stop_loss_pct_eff > 0 else None
hit_sl = (sl_price is not None) and (high >= sl_price)
hit_liq = liquidation_price > 0 and (high >= liquidation_price)
if hit_sl or hit_liq:
if hit_liq and (not hit_sl or (sl_price is not None and sl_price >= liquidation_price)):
# Liquidation happens before stop-loss (or stop-loss not configured).
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(abs(position), 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
else:
# Stop-loss triggers first.
exec_price_close = sl_price * (1 + slippage)
shares_close = abs(position)
commission_fee_close = shares_close * exec_price_close * commission
profit = (entry_price - exec_price_close) * shares_close - commission_fee_close
capital += profit
total_commission_paid += commission_fee_close
if capital <= 0:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short_stop',
'price': round(exec_price_close, 4),
'amount': round(shares_close, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)})
continue
# Check if liquidation hit (safety net)
# Note: check after all active exit signals
# If liquidation hit, check SL signal first
2025-12-29 03:06:49 +08:00
if position != 0 and not is_liquidated:
if position_type == 'long' and low <= liquidation_price:
# Long触及爆仓线:检查是否有止损信号
2025-12-29 03:06:49 +08:00
has_stop_loss = close_long_arr[i] and close_long_price_arr[i] > 0
stop_loss_price = close_long_price_arr[i] if has_stop_loss else 0
# Determine SL or liquidation first
2025-12-29 03:06:49 +08:00
if has_stop_loss and stop_loss_price > liquidation_price:
# SL triggers before liquidation
2025-12-29 03:06:49 +08:00
exec_price_close = stop_loss_price * (1 - slippage)
commission_fee_close = position * exec_price_close * commission
profit = (exec_price_close - entry_price) * position - commission_fee_close
capital += profit
total_commission_paid += commission_fee_close
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long_stop',
'price': round(exec_price_close, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
else:
# SL not strict enough, liquidation triggered
logger.warning(f"Long liquidation! entry={entry_price:.2f}, low={low:.2f}, "
2025-12-29 03:06:49 +08:00
f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}")
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(abs(position), 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
position = 0
position_type = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': capital})
continue
elif position_type == 'short' and high >= liquidation_price:
# Short触及爆仓线:检查是否有止损信号
2025-12-29 03:06:49 +08:00
has_stop_loss = close_short_arr[i] and close_short_price_arr[i] > 0
stop_loss_price = close_short_price_arr[i] if has_stop_loss else 0
logger.warning(f"[candle {i}] Short hit liquidation! entry={entry_price:.2f}, high={high:.2f}, liq_price={liquidation_price:.2f}, "
2025-12-29 03:06:49 +08:00
f"止损信号={close_short_arr[i]}, 止损价={stop_loss_price:.4f}, 时间={timestamp}")
# Determine SL or liquidation first
2025-12-29 03:06:49 +08:00
if has_stop_loss and stop_loss_price < liquidation_price:
# SL triggers before liquidation
2025-12-29 03:06:49 +08:00
exec_price_close = stop_loss_price * (1 + slippage)
shares_close = abs(position)
commission_fee_close = shares_close * exec_price_close * commission
profit = (entry_price - exec_price_close) * shares_close - commission_fee_close
capital += profit
total_commission_paid += commission_fee_close
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short_stop',
'price': round(exec_price_close, 4),
'amount': round(shares_close, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
else:
# SL not strict enough, liquidation triggered
logger.warning(f"Short liquidation! entry={entry_price:.2f}, high={high:.2f}, "
2025-12-29 03:06:49 +08:00
f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}")
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(abs(position), 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
position = 0
position_type = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': capital})
continue
# Record equity (unrealized PnL from close)
2025-12-29 03:06:49 +08:00
if position_type == 'long':
unrealized_pnl = (close - entry_price) * position
total_value = capital + unrealized_pnl
elif position_type == 'short':
shares = abs(position)
unrealized_pnl = (entry_price - close) * shares
total_value = capital + unrealized_pnl
else:
total_value = capital
if total_value < 0:
total_value = 0
equity_curve.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'value': round(total_value, 2)
})
# Force exit at backtest end
2025-12-29 03:06:49 +08:00
if position != 0:
timestamp = df.index[-1]
final_close = df.iloc[-1]['close']
if position > 0: # Close long
2025-12-29 03:06:49 +08:00
exec_price = final_close * (1 - slippage)
commission_fee = position * exec_price * commission
profit = (exec_price - entry_price) * position - commission_fee
capital += profit
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long',
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
else: # Close short
2025-12-29 03:06:49 +08:00
exec_price = final_close * (1 + slippage)
shares = abs(position)
commission_fee = shares * exec_price * commission
profit = (entry_price - exec_price) * shares - commission_fee
if capital + profit <= 0:
logger.warning(f"Liquidation at backtest end!")
2025-12-29 03:06:49 +08:00
capital = 0
is_liquidated = True
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(-capital, 2),
'balance': 0
})
else:
capital += profit
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
if equity_curve:
equity_curve[-1]['value'] = round(capital, 2)
return equity_curve, trades, total_commission_paid
def _simulate_trading_old_format(
self,
df: pd.DataFrame,
signals: pd.Series,
initial_capital: float,
commission: float,
slippage: float,
leverage: int = 1,
trade_direction: str = 'long',
strategy_config: Optional[Dict[str, Any]] = None
) -> tuple:
"""
使用旧格式信号进行交易模拟(保持兼容性)
"""
equity_curve = []
trades = []
total_commission_paid = 0 # Accumulated commission
is_liquidated = False # Liquidation flag
liquidation_price = 0 # Liquidation price
min_capital_to_trade = 1.0 # Below this balance, consider wiped out
2025-12-29 03:06:49 +08:00
capital = initial_capital
position = 0 # Positive=long, Negative=short
2025-12-29 03:06:49 +08:00
entry_price = 0
position_type = None # 'long' or 'short'
# Risk controls (also supported for legacy signals): SL / TP / trailing exit
cfg = strategy_config or {}
exec_cfg = cfg.get('execution') or {}
# Signal confirmation / execution timing (legacy mode):
# - bar_close: execute on the same bar close
# - next_bar_open: execute on next bar open after signal is confirmed on bar close (recommended)
signal_timing = str(exec_cfg.get('signalTiming') or 'next_bar_open').strip().lower()
risk_cfg = cfg.get('risk') or {}
stop_loss_pct = float(risk_cfg.get('stopLossPct') or 0.0)
take_profit_pct = float(risk_cfg.get('takeProfitPct') or 0.0)
trailing_cfg = risk_cfg.get('trailing') or {}
trailing_enabled = bool(trailing_cfg.get('enabled'))
trailing_pct = float(trailing_cfg.get('pct') or 0.0)
trailing_activation_pct = float(trailing_cfg.get('activationPct') or 0.0)
# Risk percentages are defined on margin PnL; convert to price move thresholds by leverage.
lev = max(int(leverage or 1), 1)
stop_loss_pct_eff = stop_loss_pct / lev
take_profit_pct_eff = take_profit_pct / lev
trailing_pct_eff = trailing_pct / lev
trailing_activation_pct_eff = trailing_activation_pct / lev
highest_since_entry = None
lowest_since_entry = None
# --- Position / scaling config (make old-format strategies support the same backtest modal features) ---
pos_cfg = cfg.get('position') or {}
entry_pct_cfg = float(pos_cfg.get('entryPct') if pos_cfg.get('entryPct') is not None else 1.0) # expected 0~1
# Accept both 0~1 and 0~100 inputs (some clients may send percent units).
if entry_pct_cfg > 1:
entry_pct_cfg = entry_pct_cfg / 100.0
entry_pct_cfg = max(0.0, min(entry_pct_cfg, 1.0))
scale_cfg = cfg.get('scale') or {}
trend_add_cfg = scale_cfg.get('trendAdd') or {}
dca_add_cfg = scale_cfg.get('dcaAdd') or {}
trend_reduce_cfg = scale_cfg.get('trendReduce') or {}
adverse_reduce_cfg = scale_cfg.get('adverseReduce') or {}
trend_add_enabled = bool(trend_add_cfg.get('enabled'))
trend_add_step_pct = float(trend_add_cfg.get('stepPct') or 0.0)
trend_add_size_pct = float(trend_add_cfg.get('sizePct') or 0.0)
trend_add_max_times = int(trend_add_cfg.get('maxTimes') or 0)
dca_add_enabled = bool(dca_add_cfg.get('enabled'))
dca_add_step_pct = float(dca_add_cfg.get('stepPct') or 0.0)
dca_add_size_pct = float(dca_add_cfg.get('sizePct') or 0.0)
dca_add_max_times = int(dca_add_cfg.get('maxTimes') or 0)
trend_reduce_enabled = bool(trend_reduce_cfg.get('enabled'))
trend_reduce_step_pct = float(trend_reduce_cfg.get('stepPct') or 0.0)
trend_reduce_size_pct = float(trend_reduce_cfg.get('sizePct') or 0.0)
trend_reduce_max_times = int(trend_reduce_cfg.get('maxTimes') or 0)
adverse_reduce_enabled = bool(adverse_reduce_cfg.get('enabled'))
adverse_reduce_step_pct = float(adverse_reduce_cfg.get('stepPct') or 0.0)
adverse_reduce_size_pct = float(adverse_reduce_cfg.get('sizePct') or 0.0)
adverse_reduce_max_times = int(adverse_reduce_cfg.get('maxTimes') or 0)
# Trigger pct to price threshold with leverage
2025-12-29 03:06:49 +08:00
trend_add_step_pct_eff = trend_add_step_pct / lev
dca_add_step_pct_eff = dca_add_step_pct / lev
trend_reduce_step_pct_eff = trend_reduce_step_pct / lev
adverse_reduce_step_pct_eff = adverse_reduce_step_pct / lev
# State for scaling
trend_add_times = 0
dca_add_times = 0
trend_reduce_times = 0
adverse_reduce_times = 0
last_trend_add_anchor = None
last_dca_add_anchor = None
last_trend_reduce_anchor = None
last_adverse_reduce_anchor = None
# Apply execution timing to avoid look-ahead bias in legacy signals (buy/sell series):
# If signal is computed on bar close, realistic execution is next bar open.
signals_exec = signals
if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next']:
try:
signals_exec = signals.shift(1).fillna(0)
except Exception:
signals_exec = signals
for i, (timestamp, row) in enumerate(df.iterrows()):
# 爆仓后直接停止回测,输出结果
2025-12-29 03:06:49 +08:00
if is_liquidated:
break
2025-12-29 03:06:49 +08:00
# If no position and balance low, stop trading
2025-12-29 03:06:49 +08:00
if position == 0 and capital < min_capital_to_trade:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(float(row.get('close', 0) or 0), 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
signal = signals_exec.iloc[i] if i < len(signals_exec) else 0
high = row['high']
low = row['low']
price = row['close']
open_ = row.get('open', price)
# Forced exit (TP/SL/trailing) over signals
2025-12-29 03:06:49 +08:00
if position != 0 and position_type in ['long', 'short']:
if position_type == 'long' and position > 0:
if highest_since_entry is None:
highest_since_entry = entry_price
highest_since_entry = max(highest_since_entry, high)
candidates = []
if stop_loss_pct_eff > 0:
sl_price = entry_price * (1 - stop_loss_pct_eff)
if low <= sl_price:
candidates.append(('stop', sl_price))
if take_profit_pct_eff > 0:
tp_price = entry_price * (1 + take_profit_pct_eff)
if high >= tp_price:
candidates.append(('profit', tp_price))
if trailing_enabled and trailing_pct_eff > 0:
trail_active = True
if trailing_activation_pct_eff > 0:
trail_active = highest_since_entry >= entry_price * (1 + trailing_activation_pct_eff)
if trail_active:
tr_price = highest_since_entry * (1 - trailing_pct_eff)
if low <= tr_price:
candidates.append(('trailing', tr_price))
if candidates:
# SL > TrailingStop > TP
2025-12-29 03:06:49 +08:00
pri = {'stop': 0, 'trailing': 1, 'profit': 2}
reason, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), x[1]))[0]
exec_price = trigger_price * (1 - slippage)
commission_fee = position * exec_price * commission
# Entry commission deducted, only deduct exit commission
2025-12-29 03:06:49 +08:00
profit = (exec_price - entry_price) * position - commission_fee
capital += profit
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': {'stop': 'close_long_stop', 'profit': 'close_long_profit', 'trailing': 'close_long_trailing'}.get(reason, 'close_long'),
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)})
continue
if position_type == 'short' and position < 0:
shares = abs(position)
if lowest_since_entry is None:
lowest_since_entry = entry_price
lowest_since_entry = min(lowest_since_entry, low)
candidates = []
if stop_loss_pct_eff > 0:
sl_price = entry_price * (1 + stop_loss_pct_eff)
if high >= sl_price:
candidates.append(('stop', sl_price))
if take_profit_pct_eff > 0:
tp_price = entry_price * (1 - take_profit_pct_eff)
if low <= tp_price:
candidates.append(('profit', tp_price))
if trailing_enabled and trailing_pct_eff > 0:
trail_active = True
if trailing_activation_pct_eff > 0:
trail_active = lowest_since_entry <= entry_price * (1 - trailing_activation_pct_eff)
if trail_active:
tr_price = lowest_since_entry * (1 + trailing_pct_eff)
if high >= tr_price:
candidates.append(('trailing', tr_price))
if candidates:
# SL > TrailingStop > TP
2025-12-29 03:06:49 +08:00
pri = {'stop': 0, 'trailing': 1, 'profit': 2}
reason, trigger_price = sorted(candidates, key=lambda x: (pri.get(x[0], 99), -x[1]))[0]
exec_price = trigger_price * (1 + slippage)
commission_fee = shares * exec_price * commission
# Entry commission deducted, only deduct exit commission
2025-12-29 03:06:49 +08:00
profit = (entry_price - exec_price) * shares - commission_fee
if capital + profit <= 0:
capital = 0
is_liquidated = True
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
position = 0
position_type = None
liquidation_price = 0
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': 0})
continue
capital += profit
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': {'stop': 'close_short_stop', 'profit': 'close_short_profit', 'trailing': 'close_short_trailing'}.get(reason, 'close_short'),
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
position = 0
position_type = None
liquidation_price = 0
highest_since_entry = None
lowest_since_entry = None
equity_curve.append({'time': timestamp.strftime('%Y-%m-%d %H:%M'), 'value': round(capital, 2)})
continue
# --- Parameterized scaling rules (also for old-format strategies) ---
# Note: old format only has buy/sell, but scaling params should work.
# Trigger pct as post-leverage threshold.
2025-12-29 03:06:49 +08:00
# IMPORTANT: if this candle has a main buy/sell signal, do NOT apply any scale-in/scale-out.
if signal == 0 and position != 0 and position_type in ['long', 'short'] and capital >= min_capital_to_trade:
# Long
2025-12-29 03:06:49 +08:00
if position_type == 'long' and position > 0:
# Trend add(顺势加仓:上涨触发)
if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times):
anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price
trigger = anchor * (1 + trend_add_step_pct_eff)
if high >= trigger:
order_pct = trend_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 + slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = position * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position += shares_add
entry_price = total_cost_after / position
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 - 1.0 / leverage)
trend_add_times += 1
last_trend_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_long',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# DCA add(逆势加仓:下跌触发)
if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times):
anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price
trigger = anchor * (1 - dca_add_step_pct_eff)
if low <= trigger:
order_pct = dca_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 + slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = position * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position += shares_add
entry_price = total_cost_after / position
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 - 1.0 / leverage)
dca_add_times += 1
last_dca_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_long',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Trend reduce(顺势减仓:上涨触发)
if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times):
anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price
trigger = anchor * (1 + trend_reduce_step_pct_eff)
if high >= trigger:
reduce_pct = max(trend_reduce_size_pct, 0.0)
reduce_shares = position * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 - slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position -= reduce_shares
if position <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
else:
liquidation_price = entry_price * (1 - 1.0 / leverage)
trend_reduce_times += 1
last_trend_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_long',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Adverse reduce(逆势减仓:下跌触发)
if position_type == 'long' and position > 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times):
anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price
trigger = anchor * (1 - adverse_reduce_step_pct_eff)
if low <= trigger:
reduce_pct = max(adverse_reduce_size_pct, 0.0)
reduce_shares = position * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 - slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (exec_price_reduce - entry_price) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position -= reduce_shares
if position <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
else:
liquidation_price = entry_price * (1 - 1.0 / leverage)
adverse_reduce_times += 1
last_adverse_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_long',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Short
2025-12-29 03:06:49 +08:00
if position_type == 'short' and position < 0:
shares_total = abs(position)
# Trend add(顺势加空:下跌触发)
if trend_add_enabled and trend_add_step_pct_eff > 0 and trend_add_size_pct > 0 and (trend_add_max_times == 0 or trend_add_times < trend_add_max_times):
anchor = last_trend_add_anchor if last_trend_add_anchor is not None else entry_price
trigger = anchor * (1 - trend_add_step_pct_eff)
if low <= trigger:
order_pct = trend_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 - slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = shares_total * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position -= shares_add
shares_total = abs(position)
entry_price = total_cost_after / shares_total
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 + 1.0 / leverage)
trend_add_times += 1
last_trend_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_short',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# DCA add(逆势加空:上涨触发)
if dca_add_enabled and dca_add_step_pct_eff > 0 and dca_add_size_pct > 0 and (dca_add_max_times == 0 or dca_add_times < dca_add_max_times):
anchor = last_dca_add_anchor if last_dca_add_anchor is not None else entry_price
trigger = anchor * (1 + dca_add_step_pct_eff)
if high >= trigger:
order_pct = dca_add_size_pct
if order_pct > 0:
exec_price_add = trigger * (1 - slippage)
use_capital = capital * order_pct
shares_add = (use_capital * leverage) / exec_price_add
commission_fee = shares_add * exec_price_add * commission
total_cost_before = shares_total * entry_price
total_cost_after = total_cost_before + shares_add * exec_price_add
position -= shares_add
shares_total = abs(position)
entry_price = total_cost_after / shares_total
capital -= commission_fee
total_commission_paid += commission_fee
liquidation_price = entry_price * (1 + 1.0 / leverage)
dca_add_times += 1
last_dca_add_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'add_short',
'price': round(exec_price_add, 4),
'amount': round(shares_add, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Trend reduce(顺势减空:下跌触发,回补一部分)
if trend_reduce_enabled and trend_reduce_step_pct_eff > 0 and trend_reduce_size_pct > 0 and (trend_reduce_max_times == 0 or trend_reduce_times < trend_reduce_max_times):
anchor = last_trend_reduce_anchor if last_trend_reduce_anchor is not None else entry_price
trigger = anchor * (1 - trend_reduce_step_pct_eff)
if low <= trigger:
reduce_pct = max(trend_reduce_size_pct, 0.0)
reduce_shares = shares_total * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 + slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position += reduce_shares
shares_total = abs(position)
if shares_total <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
else:
liquidation_price = entry_price * (1 + 1.0 / leverage)
trend_reduce_times += 1
last_trend_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_short',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Adverse reduce(逆势减空:上涨触发)
if position_type == 'short' and position < 0 and adverse_reduce_enabled and adverse_reduce_step_pct_eff > 0 and adverse_reduce_size_pct > 0 and (adverse_reduce_max_times == 0 or adverse_reduce_times < adverse_reduce_max_times):
anchor = last_adverse_reduce_anchor if last_adverse_reduce_anchor is not None else entry_price
trigger = anchor * (1 + adverse_reduce_step_pct_eff)
if high >= trigger:
reduce_pct = max(adverse_reduce_size_pct, 0.0)
reduce_shares = shares_total * reduce_pct
if reduce_shares > 0:
exec_price_reduce = trigger * (1 + slippage)
commission_fee = reduce_shares * exec_price_reduce * commission
profit = (entry_price - exec_price_reduce) * reduce_shares - commission_fee
capital += profit
total_commission_paid += commission_fee
position += reduce_shares
shares_total = abs(position)
if shares_total <= 1e-12:
position = 0
position_type = None
liquidation_price = 0
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
else:
liquidation_price = entry_price * (1 + 1.0 / leverage)
adverse_reduce_times += 1
last_adverse_reduce_anchor = trigger
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'reduce_short',
'price': round(exec_price_reduce, 4),
'amount': round(reduce_shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Handle different trade directions
2025-12-29 03:06:49 +08:00
if trade_direction == 'long':
# Long only mode
if signal == 1 and position == 0 and capital >= min_capital_to_trade: # Buy to open long
logger.debug(f"[Long mode] Buy to open long: time={timestamp}, price={price}, leverage={leverage}x")
2025-12-29 03:06:49 +08:00
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 + slippage)
# With leverage: position = capital * leverage / price
# Use specified pct (entryPct preferred; else full)
2025-12-29 03:06:49 +08:00
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
# Margin (commission from capital)
2025-12-29 03:06:49 +08:00
margin = capital
commission_fee = shares * exec_price * commission
position = shares
entry_price = exec_price
position_type = 'long'
capital -= commission_fee # Only deduct commission
2025-12-29 03:06:49 +08:00
total_commission_paid += commission_fee
# Long liquidation when price drops to entry * (1 - 1/leverage)
2025-12-29 03:06:49 +08:00
liquidation_price = entry_price * (1 - 1.0 / leverage)
logger.debug(f"Long liquidation price: {liquidation_price:.2f}")
2025-12-29 03:06:49 +08:00
# init scaling anchors
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_price
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'open_long',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
elif signal == -1 and position > 0: # Sell to close long
logger.debug(f"[Long mode] Sell to close long: time={timestamp}, price={price}")
2025-12-29 03:06:49 +08:00
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 - slippage)
# PnL = (exit - entry) * shares - commission
2025-12-29 03:06:49 +08:00
commission_fee = position * exec_price * commission
profit = (exec_price - entry_price) * position - commission_fee
capital += profit
total_commission_paid += commission_fee
liquidation_price = 0 # Clear liquidation price
2025-12-29 03:06:49 +08:00
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long',
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
position = 0
position_type = None
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
if capital < min_capital_to_trade:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
elif trade_direction == 'short':
# Short only mode
if signal == -1 and position == 0 and capital >= min_capital_to_trade: # Sell to open short
logger.debug(f"[Short mode] Sell to open short: time={timestamp}, price={price}, leverage={leverage}x")
2025-12-29 03:06:49 +08:00
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 - slippage)
# With leverage: position = capital * leverage / price
2025-12-29 03:06:49 +08:00
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
position = -shares # Negative = short (owe shares)
2025-12-29 03:06:49 +08:00
entry_price = exec_price
position_type = 'short'
capital -= commission_fee # Only deduct commission
2025-12-29 03:06:49 +08:00
total_commission_paid += commission_fee
# Short liquidation when price rises to entry * (1 + 1/leverage)
2025-12-29 03:06:49 +08:00
liquidation_price = entry_price * (1 + 1.0 / leverage)
logger.debug(f"Short liquidation price: {liquidation_price:.2f}")
2025-12-29 03:06:49 +08:00
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_price
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'open_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
elif signal == 1 and position < 0: # Buy to close short
logger.debug(f"[Short mode] Buy to close short: time={timestamp}, price={price}")
2025-12-29 03:06:49 +08:00
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 + slippage)
shares = abs(position) # Shares to buy back
# PnL = (entry - exit) * shares - commission
2025-12-29 03:06:49 +08:00
commission_fee = shares * exec_price * commission
profit = (entry_price - exec_price) * shares - commission_fee
# Check for liquidation
2025-12-29 03:06:49 +08:00
if capital + profit <= 0:
logger.warning(f"Insufficient funds when closing short - liquidation: capital={capital:.2f}, loss={-profit:.2f}")
2025-12-29 03:06:49 +08:00
capital = 0
is_liquidated = True
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(-capital, 2),
'balance': 0
})
else:
capital += profit
total_commission_paid += commission_fee
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
position = 0
position_type = None
liquidation_price = 0 # Clear liquidation price
2025-12-29 03:06:49 +08:00
last_trend_add_anchor = last_dca_add_anchor = last_trend_reduce_anchor = last_adverse_reduce_anchor = None
trend_add_times = dca_add_times = trend_reduce_times = adverse_reduce_times = 0
if capital < min_capital_to_trade and not is_liquidated:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
elif trade_direction == 'both':
# Both directions mode
if signal == 1 and position == 0 and capital >= min_capital_to_trade: # Buy to open long
logger.debug(f"[Both mode] Buy to open long: time={timestamp}, price={price}, leverage={leverage}x")
2025-12-29 03:06:49 +08:00
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 + slippage)
# With leverage: position = capital * leverage / price
2025-12-29 03:06:49 +08:00
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
position = shares
entry_price = exec_price
position_type = 'long'
capital -= commission_fee # Only deduct commission
2025-12-29 03:06:49 +08:00
total_commission_paid += commission_fee
# Calculate liquidation price
2025-12-29 03:06:49 +08:00
liquidation_price = entry_price * (1 - 1.0 / leverage)
logger.debug(f"Long liquidation price: {liquidation_price:.2f}")
2025-12-29 03:06:49 +08:00
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_price
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'open_long',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
elif signal == -1 and position == 0 and capital >= min_capital_to_trade: # Sell to open short
logger.debug(f"[Both mode] Sell to open short: time={timestamp}, price={price}, leverage={leverage}x")
2025-12-29 03:06:49 +08:00
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 - slippage)
# With leverage: position = capital * leverage / price
2025-12-29 03:06:49 +08:00
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee = shares * exec_price * commission
position = -shares
entry_price = exec_price
position_type = 'short'
capital -= commission_fee
total_commission_paid += commission_fee
# Calculate liquidation price
2025-12-29 03:06:49 +08:00
liquidation_price = entry_price * (1 + 1.0 / leverage)
logger.debug(f"Short liquidation price: {liquidation_price:.2f}")
2025-12-29 03:06:49 +08:00
last_trend_add_anchor = entry_price
last_dca_add_anchor = entry_price
last_trend_reduce_anchor = entry_price
last_adverse_reduce_anchor = entry_price
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'open_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
elif signal == -1 and position > 0: # Close long open short
logger.debug(f"[Both mode] Close long open short: time={timestamp}, price={price}")
# First close long
2025-12-29 03:06:49 +08:00
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 - slippage)
commission_fee_close = position * exec_price * commission
profit = (exec_price - entry_price) * position - commission_fee_close
capital += profit
total_commission_paid += commission_fee_close
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long',
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Stop if balance too low after exit
2025-12-29 03:06:49 +08:00
if capital < min_capital_to_trade or is_liquidated:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
continue
# Re-open short (respects entryPct; default entryPct=100%)
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee_open = shares * exec_price * commission
position = -shares
entry_price = exec_price
position_type = 'short'
capital -= commission_fee_open
total_commission_paid += commission_fee_open
# Calculate liquidation price
2025-12-29 03:06:49 +08:00
liquidation_price = entry_price * (1 + 1.0 / leverage)
logger.debug(f"Short liquidation price: {liquidation_price:.2f}")
2025-12-29 03:06:49 +08:00
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'open_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
elif signal == 1 and position < 0: # Close short open long
logger.debug(f"[Both mode] Close short open long: time={timestamp}, price={price}")
# First close short
2025-12-29 03:06:49 +08:00
base_price = open_ if signal_timing in ['next_bar_open', 'next_open', 'nextopen', 'next'] else price
exec_price = base_price * (1 + slippage)
shares = abs(position)
commission_fee_close = shares * exec_price * commission
profit = (entry_price - exec_price) * shares - commission_fee_close
# Check for liquidation
2025-12-29 03:06:49 +08:00
if capital + profit <= 0:
logger.warning(f"Insufficient funds when closing short - liquidation: capital={capital:.2f}, loss={-profit:.2f}")
2025-12-29 03:06:49 +08:00
capital = 0
is_liquidated = True
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(-capital, 2),
'balance': 0
})
position = 0
position_type = None
continue # No new positions after liquidation
2025-12-29 03:06:49 +08:00
capital += profit
total_commission_paid += commission_fee_close
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
if capital < min_capital_to_trade or is_liquidated:
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': 0,
'profit': round(-initial_capital, 2),
'balance': 0
})
continue
# Re-open long (respects entryPct; default entryPct=100%)
position_pct = None
if entry_pct_cfg is not None and entry_pct_cfg > 0:
position_pct = entry_pct_cfg
if position_pct is not None and 0 < position_pct < 1:
use_capital = capital * position_pct
shares = (use_capital * leverage) / exec_price
else:
shares = (capital * leverage) / exec_price
commission_fee_open = shares * exec_price * commission
position = shares
entry_price = exec_price
position_type = 'long'
capital -= commission_fee_open
total_commission_paid += commission_fee_open
# Calculate liquidation price
2025-12-29 03:06:49 +08:00
liquidation_price = entry_price * (1 - 1.0 / leverage)
logger.debug(f"Long liquidation price: {liquidation_price:.2f}")
2025-12-29 03:06:49 +08:00
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'open_long',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': 0,
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Check if liquidation hit (safety net, only when no active exit)
# Note: check after all signals, SL/TP takes priority
2025-12-29 03:06:49 +08:00
if position != 0 and not is_liquidated:
if position_type == 'long':
# Long爆仓:价格跌破爆仓线
2025-12-29 03:06:49 +08:00
if price <= liquidation_price:
logger.warning(f"Long liquidation! entry={entry_price:.2f}, current={price:.2f}, liq_price={liquidation_price:.2f}")
2025-12-29 03:06:49 +08:00
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(abs(position), 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
position = 0
position_type = None
equity_curve.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'value': 0
})
continue
elif position_type == 'short':
# Short爆仓:价格涨破爆仓线
2025-12-29 03:06:49 +08:00
if price >= liquidation_price:
logger.warning(f"Short liquidation! entry={entry_price:.2f}, current={price:.2f}, liq_price={liquidation_price:.2f}")
2025-12-29 03:06:49 +08:00
is_liquidated = True
capital = 0
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(liquidation_price, 4),
'amount': round(abs(position), 4),
'profit': round(-initial_capital, 2),
'balance': 0
})
position = 0
position_type = None
equity_curve.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'value': 0
})
continue
# Record equity
2025-12-29 03:06:49 +08:00
if position_type == 'long':
# Long equity = cash + unrealized PnL
# Unrealized PnL = (current - entry) * shares
2025-12-29 03:06:49 +08:00
unrealized_pnl = (price - entry_price) * position
total_value = capital + unrealized_pnl
elif position_type == 'short':
# Short equity = cash + unrealized PnL
# Unrealized PnL = (entry - current) * shares
2025-12-29 03:06:49 +08:00
shares = abs(position)
unrealized_pnl = (entry_price - price) * shares
total_value = capital + unrealized_pnl
else:
total_value = capital
# Ensure equity is not negative (liquidation already handled)
2025-12-29 03:06:49 +08:00
if total_value < 0:
total_value = 0
equity_curve.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'value': round(total_value, 2)
})
# Force exit at backtest end
2025-12-29 03:06:49 +08:00
if position != 0:
timestamp = df.index[-1]
price = df.iloc[-1]['close']
if position > 0: # Close long
2025-12-29 03:06:49 +08:00
exec_price = price * (1 - slippage)
commission_fee = position * exec_price * commission
profit = (exec_price - entry_price) * position - commission_fee
capital += profit
total_commission_paid += commission_fee
# Record close long trade
2025-12-29 03:06:49 +08:00
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_long',
'price': round(exec_price, 4),
'amount': round(position, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
else: # Close short
2025-12-29 03:06:49 +08:00
exec_price = price * (1 + slippage)
shares = abs(position)
commission_fee = shares * exec_price * commission
profit = (entry_price - exec_price) * shares - commission_fee
# Check for liquidation
2025-12-29 03:06:49 +08:00
if capital + profit <= 0:
logger.warning(f"Liquidation at backtest end! Close short loss too large: capital={capital:.2f}, loss={-profit:.2f}")
2025-12-29 03:06:49 +08:00
is_liquidated = True
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'liquidation',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(-capital, 2),
'balance': 0
})
capital = 0
else:
capital += profit
total_commission_paid += commission_fee
# Record close short trade
2025-12-29 03:06:49 +08:00
trades.append({
'time': timestamp.strftime('%Y-%m-%d %H:%M'),
'type': 'close_short',
'price': round(exec_price, 4),
'amount': round(shares, 4),
'profit': round(profit, 2),
'balance': round(max(0, capital), 2)
2025-12-29 03:06:49 +08:00
})
# Update last equity curve value with capital after forced exit
2025-12-29 03:06:49 +08:00
if equity_curve:
equity_curve[-1]['value'] = round(capital, 2)
return equity_curve, trades, total_commission_paid
def _calculate_metrics(
self,
equity_curve: List,
trades: List,
initial_capital: float,
timeframe: str,
start_date: datetime,
end_date: datetime,
total_commission: float = 0
) -> Dict:
"""计算回测指标"""
if not equity_curve:
return {}
final_value = equity_curve[-1]['value']
total_return = (final_value - initial_capital) / initial_capital * 100
# Calculate annualized return: simple, not compound
# For high-return strategies, compound annualization produces unrealistic numbers
# Use actual data time range from equity_curve instead of requested start_date/end_date
# This fixes the issue where data may only be available until a certain date (e.g., TSLA only to January)
try:
# Parse actual start and end times from equity_curve
actual_start_str = equity_curve[0]['time']
actual_end_str = equity_curve[-1]['time']
actual_start = datetime.strptime(actual_start_str, '%Y-%m-%d %H:%M')
actual_end = datetime.strptime(actual_end_str, '%Y-%m-%d %H:%M')
actual_days = (actual_end - actual_start).total_seconds() / 86400
except (KeyError, ValueError, IndexError) as e:
# Fallback to requested date range if parsing fails
logger.warning(f"Failed to parse actual time range from equity_curve: {e}, using requested range")
actual_days = (end_date - start_date).total_seconds() / 86400
2025-12-29 03:06:49 +08:00
years = actual_days / 365.0
# Simple annualization: annualized return = total return / years
2025-12-29 03:06:49 +08:00
if years > 0:
annual_return = total_return / years
else:
annual_return = 0
# Calculate max drawdown
2025-12-29 03:06:49 +08:00
values = [e['value'] for e in equity_curve]
max_drawdown = self._calculate_max_drawdown(values)
# Calculate Sharpe ratio
2025-12-29 03:06:49 +08:00
sharpe = self._calculate_sharpe(values, timeframe)
# Calculate total PnL: final equity - initial capital (most accurate)
2025-12-29 03:06:49 +08:00
total_profit = final_value - initial_capital
# Calculate win rate (all exit trades)
# Exit trades: trades with profit != 0
2025-12-29 03:06:49 +08:00
closing_trades = [t for t in trades if t.get('profit', 0) != 0]
win_trades = [t for t in closing_trades if t['profit'] > 0]
loss_trades = [t for t in closing_trades if t['profit'] < 0]
total_trades = len(closing_trades)
win_rate = len(win_trades) / total_trades * 100 if total_trades > 0 else 0
# Calculate profit factor (= total profit / total loss)
2025-12-29 03:06:49 +08:00
total_wins = sum(t['profit'] for t in win_trades)
total_losses = abs(sum(t['profit'] for t in loss_trades))
profit_factor = total_wins / total_losses if total_losses > 0 else (total_wins if total_wins > 0 else 0)
return {
'totalReturn': round(total_return, 2),
'annualReturn': round(annual_return, 2),
'maxDrawdown': round(max_drawdown, 2),
'sharpeRatio': round(sharpe, 2),
'winRate': round(win_rate, 2),
'profitFactor': round(profit_factor, 2),
'totalTrades': total_trades,
'totalProfit': round(total_profit, 2),
'totalCommission': round(total_commission, 2)
}
def _calculate_max_drawdown(self, values: List[float]) -> float:
"""计算最大回撤"""
if not values:
return 0
peak = values[0]
max_dd = 0
for value in values:
if value > peak:
peak = value
dd = (peak - value) / peak * 100
if dd > max_dd:
max_dd = dd
return -max_dd
def _calculate_sharpe(self, values: List[float], timeframe: str = '1D', risk_free_rate: float = 0.02) -> float:
"""
计算夏普比率
Args:
values: 权益曲线数值列表
timeframe: 时间周期
risk_free_rate: 无风险收益率(年化)
"""
if len(values) < 2:
return 0
# Filter out zero values (post-liquidation data), avoid division by 0
2025-12-29 03:06:49 +08:00
valid_values = [v for v in values if v > 0]
if len(valid_values) < 2:
return 0
# Determine annualization factor by timeframe
2025-12-29 03:06:49 +08:00
annualization_factor = {
'1m': 252 * 24 * 60, # 1m candle: ~362,880
2025-12-29 03:06:49 +08:00
'5m': 252 * 24 * 12, # 5分钟K:约72,576
'15m': 252 * 24 * 4, # 15分钟K:约24,192
'30m': 252 * 24 * 2, # 30分钟K:约12,096
'1H': 252 * 24, # 1H candle: 6,048
2025-12-29 03:06:49 +08:00
'4H': 252 * 6, # 4小时K1,512
'1D': 252, # 1D candle: 252
'1W': 52 # 1W candle: 52
2025-12-29 03:06:49 +08:00
}.get(timeframe, 252)
try:
# Calculate period returns
2025-12-29 03:06:49 +08:00
returns = np.diff(valid_values) / valid_values[:-1]
# Filter invalid values
2025-12-29 03:06:49 +08:00
returns = returns[np.isfinite(returns)]
if len(returns) == 0:
return 0
# Annualized mean return
2025-12-29 03:06:49 +08:00
avg_return = np.mean(returns) * annualization_factor
# Annualized std (volatility)
2025-12-29 03:06:49 +08:00
std_return = np.std(returns) * np.sqrt(annualization_factor)
if std_return == 0 or not np.isfinite(std_return):
return 0
# Sharpe ratio = (annualized return - risk-free rate) / annualized volatility
2025-12-29 03:06:49 +08:00
sharpe = (avg_return - risk_free_rate) / std_return
return sharpe if np.isfinite(sharpe) else 0
except Exception as e:
logger.warning(f"Sharpe ratio calculation failed: {e}")
2025-12-29 03:06:49 +08:00
return 0
def _format_result(
self,
metrics: Dict,
equity_curve: List,
trades: List
) -> Dict[str, Any]:
"""格式化回测结果"""
# Simplify equity curve
2025-12-29 03:06:49 +08:00
max_points = 500
if len(equity_curve) > max_points:
step = len(equity_curve) // max_points
equity_curve = equity_curve[::step]
# Clean NaN/Inf values for JSON serialization
2025-12-29 03:06:49 +08:00
def clean_value(value):
"""清理数值,将NaN/Inf转换为0"""
if isinstance(value, float):
if np.isnan(value) or np.isinf(value):
return 0
return value
# Clean metrics
2025-12-29 03:06:49 +08:00
cleaned_metrics = {}
for key, value in metrics.items():
cleaned_metrics[key] = clean_value(value)
# Clean equity_curve
2025-12-29 03:06:49 +08:00
cleaned_curve = []
for item in equity_curve:
cleaned_curve.append({
'time': item['time'],
'value': clean_value(item['value'])
})
# Clean trades
2025-12-29 03:06:49 +08:00
cleaned_trades = []
# Don't truncate trades: return all (frontend can paginate)
2025-12-29 03:06:49 +08:00
for trade in trades:
cleaned_trade = {}
for key, value in trade.items():
cleaned_trade[key] = clean_value(value)
cleaned_trades.append(cleaned_trade)
return {
**cleaned_metrics,
'equityCurve': cleaned_curve,
'trades': cleaned_trades
}