Merge pull request #44 from KhizarImran/fix/14-normalize-dataframe-columns

Fix case-insensitive OHLCV DataFrame conversion
This commit is contained in:
Khizar Imran
2026-10-06 23:09:07 +01:00
committed by GitHub
2 changed files with 80 additions and 7 deletions
+17 -7
View File
@@ -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
+63
View File
@@ -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()