Skip to content

Commit c3cc58a

Browse files
authored
Merge pull request freqtrade#11248 from xmatthias/fix/backtest_max_detail_2
Improved Backtest timeframe-detail execution logic
2 parents 713979d + 6b1af9b commit c3cc58a

File tree

5 files changed

+178
-110
lines changed

5 files changed

+178
-110
lines changed

docs/backtesting.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,12 @@ To utilize this, you can append `--timeframe-detail 5m` to your regular backtest
508508
freqtrade backtesting --strategy AwesomeStrategy --timeframe 1h --timeframe-detail 5m
509509
```
510510

511-
This will load 1h data as well as 5m data for the timeframe. The strategy will be analyzed with the 1h timeframe, and Entry orders will only be placed at the main timeframe, however Order fills and exit signals will be evaluated at the 5m candle, simulating intra-candle movements.
511+
This will load 1h data (the main timeframe) as well as 5m data (detail timeframe) for the selected timerange.
512+
The strategy will be analyzed with the 1h timeframe.
513+
Candles where activity may take place (there's an active signal, the pair is in a trade) are evaluated at the 5m timeframe.
514+
This will allow for a more accurate simulation of intra-candle movements - and can lead to different results, especially on higher timeframes.
515+
516+
Entries will generally still happen at the main candle's open, however freed trade slots may be freed earlier (if the exit signal is triggered on the 5m candle), which can then be used for a new trade of a different pair.
512517

513518
All callback functions (`custom_exit()`, `custom_stoploss()`, ... ) will be running for each 5m candle once the trade is opened (so 12 times in the above example of 1h timeframe, and 5m detailed timeframe).
514519

@@ -520,6 +525,27 @@ Also, data must be available / downloaded already.
520525
!!! Tip
521526
You can use this function as the last part of strategy development, to ensure your strategy is not exploiting one of the [backtesting assumptions](#assumptions-made-by-backtesting). Strategies that perform similarly well with this mode have a good chance to perform well in dry/live modes too (although only forward-testing (dry-mode) can really confirm a strategy).
522527

528+
??? Sample "Extreme Difference Example"
529+
Using `--timeframe-detail` on an extreme example (all below pairs have the 10:00 candle with an entry signal) may lead to the following backtesting Trade sequence with 1 max_open_trades:
530+
531+
| Pair | Entry Time | Exit Time | Duration |
532+
|------|------------|-----------| -------- |
533+
| BTC/USDT | 2024-01-01 10:00:00 | 2021-01-01 10:05:00 | 5m |
534+
| ETH/USDT | 2024-01-01 10:05:00 | 2021-01-01 10:15:00 | 10m |
535+
| XRP/USDT | 2024-01-01 10:15:00 | 2021-01-01 10:30:00 | 15m |
536+
| SOL/USDT | 2024-01-01 10:15:00 | 2021-01-01 11:05:00 | 50m |
537+
| BTC/USDT | 2024-01-01 11:05:00 | 2021-01-01 12:00:00 | 55m |
538+
539+
Without timeframe-detail, this would look like:
540+
541+
| Pair | Entry Time | Exit Time | Duration |
542+
|------|------------|-----------| -------- |
543+
| BTC/USDT | 2024-01-01 10:00:00 | 2021-01-01 11:00:00 | 1h |
544+
| BTC/USDT | 2024-01-01 11:00:00 | 2021-01-01 12:00:00 | 1h |
545+
546+
The difference is significant, as without detail data, only the first `max_open_trades` signals per candle are evaluated, and the trade slots are only freed at the end of the candle, allowing for a new trade to be opened at the next candle.
547+
548+
523549
## Backtesting multiple strategies
524550

525551
To compare multiple strategies, a list of Strategies can be provided to backtesting.

freqtrade/optimize/backtesting.py

Lines changed: 131 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -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()

freqtrade/persistence/trade_model.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,6 @@ class LocalTrade:
393393
# Copy of trades_open - but indexed by pair
394394
bt_trades_open_pp: dict[str, list["LocalTrade"]] = defaultdict(list)
395395
bt_open_open_trade_count: int = 0
396-
bt_open_open_trade_count_candle: int = 0
397396
bt_total_profit: float = 0
398397
realized_profit: float = 0
399398

@@ -769,7 +768,6 @@ def reset_trades() -> None:
769768
LocalTrade.bt_trades_open = []
770769
LocalTrade.bt_trades_open_pp = defaultdict(list)
771770
LocalTrade.bt_open_open_trade_count = 0
772-
LocalTrade.bt_open_open_trade_count_candle = 0
773771
LocalTrade.bt_total_profit = 0
774772

775773
def adjust_min_max_rates(self, current_price: float, current_price_low: float) -> None:
@@ -1471,11 +1469,6 @@ def close_bt_trade(trade):
14711469
LocalTrade.bt_trades_open.remove(trade)
14721470
LocalTrade.bt_trades_open_pp[trade.pair].remove(trade)
14731471
LocalTrade.bt_open_open_trade_count -= 1
1474-
if (trade.close_date_utc - trade.open_date_utc) > timedelta(minutes=trade.timeframe):
1475-
# Only subtract trades that are open for more than 1 candle
1476-
# To avoid exceeding max_open_trades.
1477-
# Must be reset at the start of every candle during backesting.
1478-
LocalTrade.bt_open_open_trade_count_candle -= 1
14791472
LocalTrade.bt_trades.append(trade)
14801473
LocalTrade.bt_total_profit += trade.close_profit_abs
14811474

@@ -1485,7 +1478,6 @@ def add_bt_trade(trade):
14851478
LocalTrade.bt_trades_open.append(trade)
14861479
LocalTrade.bt_trades_open_pp[trade.pair].append(trade)
14871480
LocalTrade.bt_open_open_trade_count += 1
1488-
LocalTrade.bt_open_open_trade_count_candle += 1
14891481
else:
14901482
LocalTrade.bt_trades.append(trade)
14911483

@@ -1494,9 +1486,6 @@ def remove_bt_trade(trade):
14941486
LocalTrade.bt_trades_open.remove(trade)
14951487
LocalTrade.bt_trades_open_pp[trade.pair].remove(trade)
14961488
LocalTrade.bt_open_open_trade_count -= 1
1497-
# TODO: The below may have odd behavior in case of canceled entries
1498-
# It might need to be removed so the trade "counts" as open for this candle.
1499-
LocalTrade.bt_open_open_trade_count_candle -= 1
15001489

15011490
@staticmethod
15021491
def get_open_trades() -> list[Any]:

0 commit comments

Comments
 (0)