Skip to content

Commit 9d5295f

Browse files
committed
feat: Support for unbounded start and/or end in timerange filtering, added associated tests
1 parent 3ff1e31 commit 9d5295f

File tree

2 files changed

+118
-17
lines changed

2 files changed

+118
-17
lines changed

freqtrade/data/history/datahandlers/featherdatahandler.py

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,36 @@ def trades_append(self, pair: str, data: DataFrame):
112112
"""
113113
raise NotImplementedError()
114114

115+
def _build_arrow_time_filter(self, timerange: TimeRange | None):
116+
"""
117+
Build Arrow predicate filter for timerange filtering.
118+
Treats 0 as unbounded (no filter on that side).
119+
:param timerange: TimeRange object with start/stop timestamps
120+
:return: Arrow filter expression or None if fully unbounded
121+
"""
122+
if not timerange:
123+
return None
124+
125+
# Treat 0 as unbounded
126+
start_set = bool(timerange.startts and timerange.startts > 0)
127+
stop_set = bool(timerange.stopts and timerange.stopts > 0)
128+
129+
if not (start_set or stop_set):
130+
return None
131+
132+
ts_field = dataset.field("timestamp")
133+
exprs = []
134+
135+
if start_set:
136+
exprs.append(ts_field >= timerange.startts)
137+
if stop_set:
138+
exprs.append(ts_field <= timerange.stopts)
139+
140+
if len(exprs) == 1:
141+
return exprs[0]
142+
else:
143+
return exprs[0] & exprs[1]
144+
115145
def _trades_load(
116146
self, pair: str, trading_mode: TradingMode, timerange: TimeRange | None = None
117147
) -> DataFrame:
@@ -126,25 +156,27 @@ def _trades_load(
126156
if not filename.exists():
127157
return DataFrame(columns=DEFAULT_TRADES_COLUMNS)
128158

129-
# Load trades data with optional timerange filtering
130-
if timerange is None:
131-
# No timerange filter - load entire file
132-
logger.debug(f"Loading entire trades file for {pair}")
133-
tradesdata = read_feather(filename)
134-
else:
135-
# Use Arrow dataset with predicate pushdown for efficient filtering
136-
try:
137-
dataset_reader = dataset.dataset(filename, format="feather")
138-
time_filter = (dataset.field("timestamp") >= timerange.startts) & (
139-
dataset.field("timestamp") <= timerange.stopts
140-
)
159+
# Use Arrow dataset with optional timerange filtering, fallback to read_feather
160+
try:
161+
dataset_reader = dataset.dataset(filename, format="feather")
162+
time_filter = self._build_arrow_time_filter(timerange)
163+
164+
if time_filter is not None and timerange is not None:
141165
tradesdata = dataset_reader.to_table(filter=time_filter).to_pandas()
142-
logger.debug(f"Loaded {len(tradesdata)} trades for {pair}")
166+
start_desc = timerange.startts if timerange.startts > 0 else "unbounded"
167+
stop_desc = timerange.stopts if timerange.stopts > 0 else "unbounded"
168+
logger.debug(
169+
f"Loaded {len(tradesdata)} trades for {pair} "
170+
f"(filtered start={start_desc}, stop={stop_desc})"
171+
)
172+
else:
173+
tradesdata = dataset_reader.to_table().to_pandas()
174+
logger.debug(f"Loaded {len(tradesdata)} trades for {pair} (unfiltered)")
143175

144-
except (ImportError, AttributeError, ValueError) as e:
145-
# Fallback: load entire file
146-
logger.warning(f"Unable to use Arrow filtering, loading entire trades file: {e}")
147-
tradesdata = read_feather(filename)
176+
except (ImportError, AttributeError, ValueError) as e:
177+
# Fallback: load entire file
178+
logger.warning(f"Unable to use Arrow filtering, loading entire trades file: {e}")
179+
tradesdata = read_feather(filename)
148180

149181
return tradesdata
150182

tests/data/test_datahandler.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,3 +571,72 @@ def raise_err(*args, **kwargs):
571571
"Unable to use Arrow filtering, loading entire trades file" in r.message
572572
for r in caplog.records
573573
)
574+
575+
576+
def test_feather_trades_timerange_open_start(feather_dh, trades_full):
577+
# Open start: stop timestamp but no start (startts=0)
578+
stop_ts = int(trades_full["timestamp"].iloc[(2 * len(trades_full)) // 3])
579+
tr = TimeRange(None, "date", startts=0, stopts=stop_ts)
580+
581+
filtered = feather_dh.trades_load("XRP/ETH", TradingMode.SPOT, timerange=tr)
582+
assert 0 < len(filtered) < len(trades_full)
583+
assert filtered["timestamp"].max() <= stop_ts
584+
# First row should match full's first row
585+
assert filtered.iloc[0]["timestamp"] == trades_full.iloc[0]["timestamp"]
586+
587+
588+
def test_feather_trades_timerange_open_end(feather_dh, trades_full):
589+
# Open end: start timestamp but no stop (stopts=0)
590+
start_ts = int(trades_full["timestamp"].iloc[len(trades_full) // 3])
591+
tr = TimeRange("date", None, startts=start_ts, stopts=0)
592+
593+
filtered = feather_dh.trades_load("XRP/ETH", TradingMode.SPOT, timerange=tr)
594+
assert 0 < len(filtered) < len(trades_full)
595+
assert filtered["timestamp"].min() >= start_ts
596+
# Last row should match full's last row
597+
assert filtered.iloc[-1]["timestamp"] == trades_full.iloc[-1]["timestamp"]
598+
599+
600+
def test_feather_trades_timerange_fully_open(feather_dh, trades_full):
601+
# Fully open: no start or stop bounds (both 0)
602+
tr = TimeRange(None, None, startts=0, stopts=0)
603+
604+
filtered = feather_dh.trades_load("XRP/ETH", TradingMode.SPOT, timerange=tr)
605+
# Should equal unfiltered load
606+
assert_frame_equal(
607+
trades_full.reset_index(drop=True), filtered.reset_index(drop=True), check_exact=True
608+
)
609+
610+
611+
def test_feather_build_arrow_time_filter(feather_dh):
612+
# None timerange should return None
613+
assert feather_dh._build_arrow_time_filter(None) is None
614+
615+
# Fully open (both bounds 0) should return None
616+
tr_fully_open = TimeRange(None, None, startts=0, stopts=0)
617+
assert feather_dh._build_arrow_time_filter(tr_fully_open) is None
618+
619+
# Open start (startts=0) should return stop filter only
620+
tr_open_start = TimeRange(None, "date", startts=0, stopts=1000)
621+
filter_open_start = feather_dh._build_arrow_time_filter(tr_open_start)
622+
assert filter_open_start is not None
623+
# Should be a single expression (timestamp <= stopts)
624+
assert str(filter_open_start).count("<=") == 1
625+
assert str(filter_open_start).count(">=") == 0
626+
627+
# Open end (stopts=0) should return start filter only
628+
tr_open_end = TimeRange("date", None, startts=500, stopts=0)
629+
filter_open_end = feather_dh._build_arrow_time_filter(tr_open_end)
630+
assert filter_open_end is not None
631+
# Should be a single expression (timestamp >= startts)
632+
assert str(filter_open_end).count(">=") == 1
633+
assert str(filter_open_end).count("<=") == 0
634+
635+
# Closed range should return combined filter
636+
tr_closed = TimeRange("date", "date", startts=500, stopts=1000)
637+
filter_closed = feather_dh._build_arrow_time_filter(tr_closed)
638+
assert filter_closed is not None
639+
# Should contain both >= and <= (combined with &)
640+
filter_str = str(filter_closed)
641+
assert ">=" in filter_str
642+
assert "<=" in filter_str

0 commit comments

Comments
 (0)