Skip to content

Commit c3f6aa1

Browse files
authored
Merge pull request freqtrade#11711 from freqtrade/feat/bt_historic_precision
Improve price precision logic and add significant digits calculation
2 parents 8dc278f + 2142b2a commit c3f6aa1

File tree

5 files changed

+180
-11
lines changed

5 files changed

+180
-11
lines changed

freqtrade/data/btanalysis/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
trade_list_to_dataframe,
2626
update_backtest_metadata,
2727
)
28+
from .historic_precision import get_tick_size_over_time
2829
from .trade_parallelism import (
2930
analyze_trade_parallelism,
3031
evaluate_result_multi,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from pandas import DataFrame, Series
2+
3+
4+
def get_tick_size_over_time(candles: DataFrame) -> Series:
5+
"""
6+
Calculate the number of significant digits for candles over time.
7+
It's using the Monthly maximum of the number of significant digits for each month.
8+
:param candles: DataFrame with OHLCV data
9+
:return: Series with the average number of significant digits for each month
10+
"""
11+
# count the number of significant digits for the open and close prices
12+
for col in ["open", "high", "low", "close"]:
13+
candles[f"{col}_count"] = (
14+
candles[col].round(14).astype(str).str.extract(r"\.(\d*[1-9])")[0].str.len()
15+
)
16+
candles["max_count"] = candles[["open_count", "close_count", "high_count", "low_count"]].max(
17+
axis=1
18+
)
19+
20+
candles1 = candles.set_index("date", drop=True)
21+
# Group by month and calculate the average number of significant digits
22+
monthly_count_avg1 = candles1["max_count"].resample("MS").max()
23+
# monthly_open_count_avg
24+
# convert monthly_open_count_avg from 5.0 to 0.00001, 4.0 to 0.0001, ...
25+
monthly_open_count_avg = 1 / 10**monthly_count_avg1
26+
27+
return monthly_open_count_avg

freqtrade/optimize/backtesting.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@
99
from copy import deepcopy
1010
from datetime import datetime, timedelta
1111

12-
from numpy import nan
13-
from pandas import DataFrame
12+
from numpy import isnan, nan
13+
from pandas import DataFrame, Series
1414

1515
from freqtrade import constants
1616
from freqtrade.configuration import TimeRange, validate_config_consistency
1717
from freqtrade.constants import DATETIME_PRINT_FORMAT, Config, IntOrInf, LongShort
1818
from freqtrade.data import history
19-
from freqtrade.data.btanalysis import find_existing_backtest_stats, trade_list_to_dataframe
19+
from freqtrade.data.btanalysis import (
20+
find_existing_backtest_stats,
21+
get_tick_size_over_time,
22+
trade_list_to_dataframe,
23+
)
2024
from freqtrade.data.converter import trim_dataframe, trim_dataframes
2125
from freqtrade.data.dataprovider import DataProvider
2226
from freqtrade.data.metrics import combined_dataframes_with_rel_mean
@@ -35,7 +39,7 @@
3539
price_to_precision,
3640
timeframe_to_seconds,
3741
)
38-
from freqtrade.exchange.exchange import Exchange
42+
from freqtrade.exchange.exchange import TICK_SIZE, Exchange
3943
from freqtrade.ft_types import (
4044
BacktestContentType,
4145
BacktestContentTypeIcomplete,
@@ -121,6 +125,7 @@ def __init__(self, config: Config, exchange: Exchange | None = None) -> None:
121125
self.order_id_counter: int = 0
122126

123127
config["dry_run"] = True
128+
self.price_pair_prec: dict[str, Series] = {}
124129
self.run_ids: dict[str, str] = {}
125130
self.strategylist: list[IStrategy] = []
126131
self.all_bt_content: dict[str, BacktestContentType] = {}
@@ -189,7 +194,6 @@ def __init__(self, config: Config, exchange: Exchange | None = None) -> None:
189194
self.fee = max(fee for fee in fees if fee is not None)
190195
logger.info(f"Using fee {self.fee:.4%} - worst case fee from exchange (lowest tier).")
191196
self.precision_mode = self.exchange.precisionMode
192-
self.precision_mode_price = self.exchange.precision_mode_price
193197

194198
if self.config.get("freqai_backtest_live_models", False):
195199
from freqtrade.freqai.utils import get_timerange_backtest_live_models
@@ -316,6 +320,11 @@ def load_bt_data(self) -> tuple[dict[str, DataFrame], TimeRange]:
316320

317321
self.progress.set_new_value(1)
318322
self._load_bt_data_detail()
323+
self.price_pair_prec = {}
324+
for pair in self.pairlists.whitelist:
325+
if pair in data:
326+
# Load price precision logic
327+
self.price_pair_prec[pair] = get_tick_size_over_time(data[pair])
319328
return data, self.timerange
320329

321330
def _load_bt_data_detail(self) -> None:
@@ -385,6 +394,22 @@ def _load_bt_data_detail(self) -> None:
385394
else:
386395
self.futures_data = {}
387396

397+
def get_pair_precision(self, pair: str, current_time: datetime) -> tuple[float | None, int]:
398+
"""
399+
Get pair precision at that moment in time
400+
:param pair: Pair to get precision for
401+
:param current_time: Time to get precision for
402+
:return: tuple of price precision, precision_mode_price for the pair at that given time.
403+
"""
404+
precision_series = self.price_pair_prec.get(pair)
405+
if precision_series is not None:
406+
precision = precision_series.asof(current_time)
407+
408+
if not isnan(precision):
409+
# Force tick size if we define the precision
410+
return precision, TICK_SIZE
411+
return self.exchange.get_precision_price(pair), self.exchange.precision_mode_price
412+
388413
def disable_database_use(self):
389414
disable_database_use(self.timeframe)
390415

@@ -793,7 +818,7 @@ def _get_exit_for_signal(
793818
)
794819
if rate is not None and rate != close_rate:
795820
close_rate = price_to_precision(
796-
rate, trade.price_precision, self.precision_mode_price
821+
rate, trade.price_precision, trade.precision_mode_price
797822
)
798823
# We can't place orders lower than current low.
799824
# freqtrade does not support this in live, and the order would fill immediately
@@ -926,6 +951,7 @@ def get_valid_price_and_stake(
926951
trade: LocalTrade | None,
927952
order_type: str,
928953
price_precision: float | None,
954+
precision_mode_price: int,
929955
) -> tuple[float, float, float, float]:
930956
if order_type == "limit":
931957
new_rate = strategy_safe_wrapper(
@@ -941,9 +967,7 @@ def get_valid_price_and_stake(
941967
# We can't place orders higher than current high (otherwise it'd be a stop limit entry)
942968
# which freqtrade does not support in live.
943969
if new_rate is not None and new_rate != propose_rate:
944-
propose_rate = price_to_precision(
945-
new_rate, price_precision, self.precision_mode_price
946-
)
970+
propose_rate = price_to_precision(new_rate, price_precision, precision_mode_price)
947971
if direction == "short":
948972
propose_rate = max(propose_rate, row[LOW_IDX])
949973
else:
@@ -1036,7 +1060,7 @@ def _enter_trade(
10361060
pos_adjust = trade is not None and requested_rate is None
10371061

10381062
stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0)
1039-
precision_price = self.exchange.get_precision_price(pair)
1063+
precision_price, precision_mode_price = self.get_pair_precision(pair, current_time)
10401064

10411065
propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake(
10421066
pair,
@@ -1049,6 +1073,7 @@ def _enter_trade(
10491073
trade,
10501074
order_type,
10511075
precision_price,
1076+
precision_mode_price,
10521077
)
10531078

10541079
# replace proposed rate if another rate was requested
@@ -1124,7 +1149,7 @@ def _enter_trade(
11241149
amount_precision=precision_amount,
11251150
price_precision=precision_price,
11261151
precision_mode=self.precision_mode,
1127-
precision_mode_price=self.precision_mode_price,
1152+
precision_mode_price=precision_mode_price,
11281153
contract_size=contract_size,
11291154
orders=[],
11301155
)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# pragma pylint: disable=missing-docstring, C0103
2+
3+
from datetime import timezone
4+
5+
import pandas as pd
6+
from numpy import nan
7+
from pandas import DataFrame, Timestamp
8+
9+
from freqtrade.data.btanalysis.historic_precision import get_tick_size_over_time
10+
11+
12+
def test_get_tick_size_over_time():
13+
"""
14+
Test the get_tick_size_over_time function with predefined data
15+
"""
16+
# Create test dataframe with different levels of precision
17+
data = {
18+
"date": [
19+
Timestamp("2020-01-01 00:00:00", tz=timezone.utc),
20+
Timestamp("2020-01-02 00:00:00", tz=timezone.utc),
21+
Timestamp("2020-01-03 00:00:00", tz=timezone.utc),
22+
Timestamp("2020-01-15 00:00:00", tz=timezone.utc),
23+
Timestamp("2020-01-16 00:00:00", tz=timezone.utc),
24+
Timestamp("2020-01-31 00:00:00", tz=timezone.utc),
25+
Timestamp("2020-02-01 00:00:00", tz=timezone.utc),
26+
Timestamp("2020-02-15 00:00:00", tz=timezone.utc),
27+
Timestamp("2020-03-15 00:00:00", tz=timezone.utc),
28+
],
29+
"open": [1.23456, 1.234, 1.23, 1.2, 1.23456, 1.234, 2.3456, 2.34, 2.34],
30+
"high": [1.23457, 1.235, 1.24, 1.3, 1.23456, 1.235, 2.3457, 2.34, 2.34],
31+
"low": [1.23455, 1.233, 1.22, 1.1, 1.23456, 1.233, 2.3455, 2.34, 2.34],
32+
"close": [1.23456, 1.234, 1.23, 1.2, 1.23456, 1.234, 2.3456, 2.34, 2.34],
33+
"volume": [100, 200, 300, 400, 500, 600, 700, 800, 900],
34+
}
35+
36+
candles = DataFrame(data)
37+
38+
# Calculate significant digits
39+
result = get_tick_size_over_time(candles)
40+
41+
# Check that the result is a pandas Series
42+
assert isinstance(result, pd.Series)
43+
44+
# Check that we have three months of data (Jan, Feb and March 2020 )
45+
assert len(result) == 3
46+
47+
# Before
48+
assert result.asof("2019-01-01 00:00:00+00:00") is nan
49+
# January should have 5 significant digits (based on 1.23456789 being the most precise value)
50+
# which should be converted to 0.00001
51+
52+
assert result.asof("2020-01-01 00:00:00+00:00") == 0.00001
53+
assert result.asof("2020-01-01 00:00:00+00:00") == 0.00001
54+
assert result.asof("2020-02-25 00:00:00+00:00") == 0.0001
55+
assert result.asof("2020-03-25 00:00:00+00:00") == 0.01
56+
assert result.asof("2020-04-01 00:00:00+00:00") == 0.01
57+
# Value far past the last date should be the last value
58+
assert result.asof("2025-04-01 00:00:00+00:00") == 0.01
59+
60+
assert result.iloc[0] == 0.00001
61+
62+
63+
def test_get_tick_size_over_time_real_data(testdatadir):
64+
"""
65+
Test the get_tick_size_over_time function with real data from the testdatadir
66+
"""
67+
from freqtrade.data.history import load_pair_history
68+
69+
# Load some test data from the testdata directory
70+
pair = "UNITTEST/BTC"
71+
timeframe = "1m"
72+
73+
candles = load_pair_history(
74+
datadir=testdatadir,
75+
pair=pair,
76+
timeframe=timeframe,
77+
)
78+
79+
# Make sure we have test data
80+
assert not candles.empty, "No test data found, cannot run test"
81+
82+
# Calculate significant digits
83+
result = get_tick_size_over_time(candles)
84+
85+
assert isinstance(result, pd.Series)
86+
87+
# Verify that all values are between 0 and 1 (valid precision values)
88+
assert all(result > 0)
89+
assert all(result < 1)
90+
91+
assert all(result <= 0.0001)
92+
assert all(result >= 0.00000001)

tests/optimize/test_backtesting.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from freqtrade.enums import CandleType, ExitType, RunMode
2323
from freqtrade.exceptions import DependencyException, OperationalException
2424
from freqtrade.exchange import timeframe_to_next_date, timeframe_to_prev_date
25+
from freqtrade.exchange.exchange_utils import DECIMAL_PLACES, TICK_SIZE
2526
from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename, get_strategy_run_id
2627
from freqtrade.optimize.backtesting import Backtesting
2728
from freqtrade.persistence import LocalTrade, Trade
@@ -348,6 +349,29 @@ def test_data_to_dataframe_bt(default_conf, mocker, testdatadir) -> None:
348349
assert processed["UNITTEST/BTC"].equals(processed2["UNITTEST/BTC"])
349350

350351

352+
def test_get_pair_precision_bt(default_conf, mocker) -> None:
353+
patch_exchange(mocker)
354+
default_conf["timeframe"] = "30m"
355+
backtesting = Backtesting(default_conf)
356+
backtesting._set_strategy(backtesting.strategylist[0])
357+
pair = "UNITTEST/BTC"
358+
backtesting.pairlists._whitelist = [pair]
359+
ex_mock = mocker.patch(f"{EXMS}.get_precision_price", return_value=1e-5)
360+
data, timerange = backtesting.load_bt_data()
361+
assert data
362+
363+
assert backtesting.get_pair_precision(pair, dt_utc(2018, 1, 1)) == (1e-8, TICK_SIZE)
364+
assert ex_mock.call_count == 0
365+
assert backtesting.get_pair_precision(pair, dt_utc(2017, 12, 15)) == (1e-8, TICK_SIZE)
366+
assert ex_mock.call_count == 0
367+
368+
# Fallback to exchange logic
369+
assert backtesting.get_pair_precision(pair, dt_utc(2017, 1, 15)) == (1e-5, DECIMAL_PLACES)
370+
assert ex_mock.call_count == 1
371+
assert backtesting.get_pair_precision("ETH/BTC", dt_utc(2017, 1, 15)) == (1e-5, DECIMAL_PLACES)
372+
assert ex_mock.call_count == 2
373+
374+
351375
def test_backtest_abort(default_conf, mocker, testdatadir) -> None:
352376
patch_exchange(mocker)
353377
backtesting = Backtesting(default_conf)

0 commit comments

Comments
 (0)