@@ -1378,28 +1378,6 @@ def backtest_loop(
13781378 current_time : datetime ,
13791379 trade_dir : LongShort | None ,
13801380 can_enter : bool ,
1381- ) -> None :
1382- """
1383- Conditionally call backtest_loop_inner a 2nd time if shorting is enabled,
1384- a position closed and a new signal in the other direction is available.
1385- """
1386- if not self ._can_short or trade_dir is None :
1387- # No need to reverse position if shorting is disabled or there's no new signal
1388- self .backtest_loop_inner (row , pair , current_time , trade_dir , can_enter )
1389- else :
1390- for _ in (0 , 1 ):
1391- a = self .backtest_loop_inner (row , pair , current_time , trade_dir , can_enter )
1392- if not a or a == trade_dir :
1393- # the trade didn't close or position change is in the same direction
1394- break
1395-
1396- def backtest_loop_inner (
1397- self ,
1398- row : tuple ,
1399- pair : str ,
1400- current_time : datetime ,
1401- trade_dir : LongShort | None ,
1402- can_enter : bool ,
14031381 ) -> LongShort | None :
14041382 """
14051383 NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
@@ -1429,7 +1407,7 @@ def backtest_loop_inner(
14291407 and (self ._position_stacking or len (LocalTrade .bt_trades_open_pp [pair ]) == 0 )
14301408 and not PairLocks .is_pair_locked (pair , row [DATE_IDX ], trade_dir )
14311409 ):
1432- if self .trade_slot_available (LocalTrade .bt_open_open_trade_count_candle ):
1410+ if self .trade_slot_available (LocalTrade .bt_open_open_trade_count ):
14331411 trade = self ._enter_trade (pair , row , trade_dir )
14341412 if trade :
14351413 self .wallets .update ()
@@ -1455,7 +1433,7 @@ def backtest_loop_inner(
14551433 return exiting_dir
14561434 return None
14571435
1458- def get_detail_data (self , pair : str , row : tuple ) -> DataFrame | None :
1436+ def get_detail_data (self , pair : str , row : tuple ) -> list [ tuple ] | None :
14591437 """
14601438 Spread into detail data
14611439 """
@@ -1474,44 +1452,143 @@ def get_detail_data(self, pair: str, row: tuple) -> DataFrame | None:
14741452 detail_data .loc [:, "exit_short" ] = row [ESHORT_IDX ]
14751453 detail_data .loc [:, "enter_tag" ] = row [ENTER_TAG_IDX ]
14761454 detail_data .loc [:, "exit_tag" ] = row [EXIT_TAG_IDX ]
1477- return detail_data
1455+ return detail_data [ HEADERS ]. values . tolist ()
14781456
1479- def time_generator (self , start_date : datetime , end_date : datetime ):
1457+ def _time_generator (self , start_date : datetime , end_date : datetime ):
14801458 current_time = start_date + self .timeframe_td
14811459 while current_time <= end_date :
14821460 yield current_time
14831461 current_time += self .timeframe_td
14841462
1463+ def _time_generator_det (self , start_date : datetime , end_date : datetime ):
1464+ """
1465+ Loop for each detail candle.
1466+ Yields only the start date if no detail timeframe is set.
1467+ """
1468+ if not self .timeframe_detail_td :
1469+ yield start_date , True , False , 0
1470+ return
1471+
1472+ current_time = start_date
1473+ i = 0
1474+ while current_time <= end_date :
1475+ yield current_time , i == 0 , True , i
1476+ i += 1
1477+ current_time += self .timeframe_detail_td
1478+
1479+ def _time_pair_generator_det (self , current_time : datetime , pairs : list [str ]):
1480+ for current_time_det , is_first , has_detail , idx in self ._time_generator_det (
1481+ current_time , current_time + self .timeframe_td
1482+ ):
1483+ # Pairs that have open trades should be processed first
1484+ new_pairlist = list (dict .fromkeys ([t .pair for t in LocalTrade .bt_trades_open ] + pairs ))
1485+ for pair in new_pairlist :
1486+ yield current_time_det , is_first , has_detail , idx , pair
1487+
14851488 def time_pair_generator (
1486- self , start_date : datetime , end_date : datetime , increment : timedelta , pairs : list [str ]
1489+ self ,
1490+ start_date : datetime ,
1491+ end_date : datetime ,
1492+ pairs : list [str ],
1493+ data : dict [str , list [tuple ]],
14871494 ):
14881495 """
14891496 Backtest time and pair generator
1490- :returns: generator of (current_time, pair, is_first )
1491- where is_first is True for the first pair of each new candle
1497+ :returns: generator of (current_time, pair, row, is_last_row, trade_dir )
1498+ where is_last_row is a boolean indicating if this is the data end date.
14921499 """
1493- current_time = start_date + increment
1500+ current_time = start_date + self . timeframe_td
14941501 self .progress .init_step (
14951502 BacktestState .BACKTEST , int ((end_date - start_date ) / self .timeframe_td )
14961503 )
1497- for current_time in self . time_generator ( start_date , end_date ):
1498- # Loop for each time point.
1504+ # Indexes per pair, so some pairs are allowed to have a missing start.
1505+ indexes : dict = defaultdict ( int )
14991506
1507+ for current_time in self ._time_generator (start_date , end_date ):
1508+ # Loop for each main candle.
15001509 self .check_abort ()
15011510 # Reset open trade count for this candle
15021511 # Critical to avoid exceeding max_open_trades in backtesting
15031512 # when timeframe-detail is used and trades close within the opening candle.
1504- LocalTrade .bt_open_open_trade_count_candle = LocalTrade .bt_open_open_trade_count
15051513 strategy_safe_wrapper (self .strategy .bot_loop_start , supress_error = True )(
15061514 current_time = current_time
15071515 )
1516+ pair_detail_cache : dict [str , list [tuple ]] = {}
1517+ pair_tradedir_cache : dict [str , LongShort | None ] = {}
1518+ pairs_with_open_trades = [t .pair for t in LocalTrade .bt_trades_open ]
15081519
1509- # Pairs that have open trades should be processed first
1510- new_pairlist = list (dict .fromkeys ([t .pair for t in LocalTrade .bt_trades_open ] + pairs ))
1520+ for current_time_det , is_first , has_detail , idx , pair in self ._time_pair_generator_det (
1521+ current_time , pairs
1522+ ):
1523+ # Loop for each detail candle (if necessary) and pair
1524+ # Yields only the main date if no detail timeframe is set.
1525+
1526+ # Pairs that have open trades should be processed first
1527+ trade_dir : LongShort | None = None
1528+ if is_first :
1529+ # Main candle
1530+ row_index = indexes [pair ]
1531+ row = self .validate_row (data , pair , row_index , current_time )
1532+ if not row :
1533+ continue
1534+
1535+ row_index += 1
1536+ indexes [pair ] = row_index
1537+ is_last_row = current_time == end_date
1538+ self .dataprovider ._set_dataframe_max_index (self .required_startup + row_index )
1539+ trade_dir = self .check_for_trade_entry (row )
1540+ pair_tradedir_cache [pair ] = trade_dir
15111541
1512- for pair in new_pairlist :
1513- yield current_time , pair
1542+ else :
1543+ # Detail candle - from cache.
1544+ detail_data = pair_detail_cache .get (pair )
1545+ if detail_data is None or len (detail_data ) <= idx :
1546+ # logger.info(f"skipping {pair}, {current_time_det}, {trade_dir}")
1547+ continue
1548+ row = detail_data [idx ]
1549+ trade_dir = pair_tradedir_cache .get (pair )
1550+
1551+ if self .strategy .ignore_expired_candle (
1552+ current_time - self .timeframe_td , # last closed candle is 1 timeframe away.
1553+ current_time_det ,
1554+ self .timeframe_secs ,
1555+ trade_dir is not None ,
1556+ ):
1557+ # Ignore late entries eventually
1558+ trade_dir = None
1559+
1560+ self .dataprovider ._set_dataframe_max_date (current_time_det )
1561+
1562+ pair_has_open_trades = len (LocalTrade .bt_trades_open_pp [pair ]) > 0
1563+ if pair in pairs_with_open_trades and not pair_has_open_trades :
1564+ # Pair has had open trades which closed in the current main candle.
1565+ # Skip this pair for this timeframe
1566+ continue
1567+ if pair_has_open_trades and pair not in pairs_with_open_trades :
1568+ # auto-lock for pairs that have open trades
1569+ # Necessary for detail - to capture trades that open and close within
1570+ # the same main candle
1571+ pairs_with_open_trades .append (pair )
1572+
1573+ if (
1574+ is_first
1575+ and (trade_dir is not None or pair_has_open_trades )
1576+ and has_detail
1577+ and pair not in pair_detail_cache
1578+ and pair in self .detail_data
1579+ and row
1580+ ):
1581+ # Spread candle into detail timeframe and cache that -
1582+ # only once per main candle
1583+ # and only if we can expect activity.
1584+ pair_detail = self .get_detail_data (pair , row )
1585+ if pair_detail is not None :
1586+ pair_detail_cache [pair ] = pair_detail
1587+ row = pair_detail_cache [pair ][idx ]
1588+
1589+ is_last_row = current_time_det == end_date
15141590
1591+ yield current_time_det , pair , row , is_last_row , trade_dir
15151592 self .progress .increment ()
15161593
15171594 def backtest (self , processed : dict , start_date : datetime , end_date : datetime ) -> dict [str , Any ]:
@@ -1535,60 +1612,26 @@ def backtest(self, processed: dict, start_date: datetime, end_date: datetime) ->
15351612 # (looping lists is a lot faster than pandas DataFrames)
15361613 data : dict = self ._get_ohlcv_as_lists (processed )
15371614
1538- # Indexes per pair, so some pairs are allowed to have a missing start.
1539- indexes : dict = defaultdict (int )
1540-
15411615 # Loop timerange and get candle for each pair at that point in time
1542- for current_time , pair in self .time_pair_generator (
1543- start_date , end_date , self .timeframe_td , list (data .keys ())
1544- ):
1545- row_index = indexes [pair ]
1546- row = self .validate_row (data , pair , row_index , current_time )
1547- if not row :
1548- continue
1549-
1550- row_index += 1
1551- indexes [pair ] = row_index
1552- is_last_row = current_time == end_date
1553- self .dataprovider ._set_dataframe_max_index (self .required_startup + row_index )
1554- self .dataprovider ._set_dataframe_max_date (current_time )
1555- trade_dir : LongShort | None = self .check_for_trade_entry (row )
1616+ for (
1617+ current_time ,
1618+ pair ,
1619+ row ,
1620+ is_last_row ,
1621+ trade_dir ,
1622+ ) in self .time_pair_generator (start_date , end_date , list (data .keys ()), data ):
1623+ if not self ._can_short or trade_dir is None :
1624+ # No need to reverse position if shorting is disabled or there's no new signal
1625+ self .backtest_loop (row , pair , current_time , trade_dir , not is_last_row )
1626+ else :
1627+ # Conditionally call backtest_loop a 2nd time if shorting is enabled,
1628+ # a position closed and a new signal in the other direction is available.
15561629
1557- pair_has_open_trades = len (LocalTrade .bt_trades_open_pp [pair ]) > 0
1558- if (
1559- (trade_dir is not None or pair_has_open_trades )
1560- and self .timeframe_detail
1561- and pair in self .detail_data
1562- ):
1563- # Spread out into detail timeframe.
1564- # Should only happen when we are either in a trade for this pair
1565- # or when we got the signal for a new trade.
1566- detail_data = self .get_detail_data (pair , row )
1567-
1568- if detail_data is None or len (detail_data ) == 0 :
1569- # Fall back to "regular" data if no detail data was found for this candle
1570- self .dataprovider ._set_dataframe_max_date (current_time )
1571- self .backtest_loop (row , pair , current_time , trade_dir , not is_last_row )
1572- continue
1573- is_first = True
1574- current_time_det = current_time
1575- for det_row in detail_data [HEADERS ].values .tolist ():
1576- self .dataprovider ._set_dataframe_max_date (current_time_det )
1577- self .backtest_loop (
1578- det_row ,
1579- pair ,
1580- current_time_det ,
1581- trade_dir ,
1582- is_first and not is_last_row ,
1583- )
1584- current_time_det += self .timeframe_detail_td
1585- is_first = False
1586- if pair_has_open_trades and not len (LocalTrade .bt_trades_open_pp [pair ]) > 0 :
1587- # Auto-lock pair for the rest of the candle if the trade has been closed.
1630+ for _ in (0 , 1 ):
1631+ a = self .backtest_loop (row , pair , current_time , trade_dir , not is_last_row )
1632+ if not a or a == trade_dir :
1633+ # the trade didn't close or position change is in the same direction
15881634 break
1589- else :
1590- self .dataprovider ._set_dataframe_max_date (current_time )
1591- self .backtest_loop (row , pair , current_time , trade_dir , not is_last_row )
15921635
15931636 self .handle_left_open (LocalTrade .bt_trades_open_pp , data = data )
15941637 self .wallets .update ()
0 commit comments