mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-11-02 11:36:31 +00:00
Merge pull request #44 from KhizarImran/fix/14-normalize-dataframe-columns
Fix case-insensitive OHLCV DataFrame conversion
This commit is contained in:
@@ -116,6 +116,8 @@ class _Adapter:
|
||||
class Backtest:
|
||||
"""Backtest midpoint OHLC data with a full bid–ask spread in price units.
|
||||
|
||||
OHLCV and timestamp column names are case-insensitive.
|
||||
|
||||
Fills apply half the spread on each side of the midpoint. Open equity marks
|
||||
positions at their closing bid/ask, excluding future exit commission.
|
||||
"""
|
||||
@@ -142,7 +144,15 @@ class Backtest:
|
||||
|
||||
def _to_bars(self, df):
|
||||
required = {"open", "high", "low", "close"}
|
||||
missing = required - set(df.columns.str.lower())
|
||||
columns = {}
|
||||
for column in df.columns:
|
||||
normalized = column.lower() if isinstance(column, str) else column
|
||||
if normalized in columns:
|
||||
raise ValueError(
|
||||
f"DataFrame has duplicate columns after normalization: {normalized!r}"
|
||||
)
|
||||
columns[normalized] = column
|
||||
missing = required - columns.keys()
|
||||
if missing:
|
||||
raise ValueError(f"DataFrame missing required columns: {sorted(missing)}")
|
||||
|
||||
@@ -151,16 +161,16 @@ class Backtest:
|
||||
if isinstance(idx, pd.Timestamp):
|
||||
ts = int(idx.timestamp())
|
||||
else:
|
||||
ts = int(pd.Timestamp(row["timestamp"]).timestamp()) # type: ignore
|
||||
ts = int(pd.Timestamp(row[columns["timestamp"]]).timestamp()) # type: ignore
|
||||
|
||||
bars.append(
|
||||
_rust.Bar( # type: ignore
|
||||
timestamp=ts,
|
||||
open=float(row["open"]),
|
||||
high=float(row["high"]),
|
||||
low=float(row["low"]),
|
||||
close=float(row["close"]),
|
||||
volume=float(row.get("volume", 0.0)),
|
||||
open=float(row[columns["open"]]),
|
||||
high=float(row[columns["high"]]),
|
||||
low=float(row[columns["low"]]),
|
||||
close=float(row[columns["close"]]),
|
||||
volume=float(row[columns["volume"]]) if "volume" in columns else 0.0,
|
||||
)
|
||||
)
|
||||
return bars
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import unittest
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtestingfx import Backtest
|
||||
|
||||
|
||||
class ColumnConversionTest(unittest.TestCase):
|
||||
def test_case_variants_convert_all_fields_without_mutating_input(self):
|
||||
for names in (
|
||||
("open", "high", "low", "close", "volume", "timestamp"),
|
||||
("Open", "High", "Low", "Close", "Volume", "Timestamp"),
|
||||
("oPeN", "HIGH", "low", "cLoSe", "VOLUME", "TimeStamp"),
|
||||
):
|
||||
for use_index in (False, True):
|
||||
with self.subTest(names=names, use_index=use_index):
|
||||
data = pd.DataFrame(dict(zip(
|
||||
names,
|
||||
([1.1, 1.2], [1.3, 1.4], [1.0, 1.1],
|
||||
[1.2, 1.3], [10.0, 20.0],
|
||||
["2026-01-01 00:00", "2026-01-01 01:00"]),
|
||||
)))
|
||||
if use_index:
|
||||
data.index = pd.to_datetime(data.pop(names[-1]), utc=True)
|
||||
original = data.copy(deep=True)
|
||||
bars = Backtest(data)._to_bars(data)
|
||||
self.assertEqual(
|
||||
[(b.timestamp, b.open, b.high, b.low, b.close, b.volume)
|
||||
for b in bars],
|
||||
[(1767225600, 1.1, 1.3, 1.0, 1.2, 10.0),
|
||||
(1767229200, 1.2, 1.4, 1.1, 1.3, 20.0)],
|
||||
)
|
||||
pd.testing.assert_frame_equal(data, original)
|
||||
|
||||
def test_missing_volume_defaults_to_zero(self):
|
||||
data = pd.DataFrame(
|
||||
{"Open": [1.1], "High": [1.3], "Low": [1.0], "Close": [1.2]},
|
||||
index=pd.to_datetime(["2026-01-01"], utc=True),
|
||||
)
|
||||
self.assertEqual(Backtest(data)._to_bars(data)[0].volume, 0.0)
|
||||
|
||||
def test_missing_required_columns_raise_clear_error(self):
|
||||
data = pd.DataFrame({"Open": [1.1], "Close": [1.2]})
|
||||
with self.assertRaisesRegex(ValueError, r"missing required columns: \['high', 'low'\]"):
|
||||
Backtest(data)._to_bars(data)
|
||||
|
||||
def test_ambiguous_duplicate_columns_are_rejected(self):
|
||||
for duplicate in ("open", "volume", "timestamp"):
|
||||
for second in (duplicate, duplicate.title()):
|
||||
with self.subTest(duplicate=duplicate, second=second):
|
||||
names = ["open", "high", "low", "close"]
|
||||
if duplicate != "open":
|
||||
names.append(duplicate)
|
||||
names.append(second)
|
||||
data = pd.DataFrame([[1.0] * len(names)], columns=names)
|
||||
original = data.copy(deep=True)
|
||||
with self.assertRaisesRegex(ValueError, "duplicate columns after normalization"):
|
||||
Backtest(data)._to_bars(data)
|
||||
pd.testing.assert_frame_equal(data, original)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user