From 79d705a746ae1fbbc9f92a0e18d21e621723b944 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 22 May 2026 12:29:40 +0200 Subject: [PATCH] C13: report clear CSV errors in the offline examples backtest.py and multi_timeframe.py read OHLCV CSVs with csv.DictReader and crashed opaquely on malformed input: a missing column raised a bare KeyError, a non-numeric cell surfaced NumPy's column-less ValueError, and an empty or all-NaN series hit IndexError deep in summarize. Validate the header against the required columns up front, catch non-numeric cells and report the offending row/column, reject a header-only file distinctly from a headerless one, and guard resample and summarize against empty input. Every failure mode now raises a ValueError naming the file, row and column. --- examples/python/backtest.py | 50 +++++++++++++++++++------- examples/python/multi_timeframe.py | 57 ++++++++++++++++++++++++------ 2 files changed, 84 insertions(+), 23 deletions(-) diff --git a/examples/python/backtest.py b/examples/python/backtest.py index d31ea8ac..d45704df 100644 --- a/examples/python/backtest.py +++ b/examples/python/backtest.py @@ -24,6 +24,10 @@ import numpy as np import wickra as ta +# Columns the OHLCV layout requires; the CSV header must name every one. +REQUIRED_COLUMNS = ("timestamp", "open", "high", "low", "close", "volume") + + @dataclass class History: timestamp: np.ndarray @@ -35,24 +39,44 @@ class History: def read_history(path: str) -> History: - """Load an OHLCV CSV into typed NumPy columns.""" + """Load an OHLCV CSV into typed NumPy columns. + + Raises: + ValueError: if the file has no header, is missing a required column, + holds no data rows, or contains a non-numeric value (the message + pinpoints the offending row and column instead of surfacing an + opaque ``KeyError`` or NumPy ``ValueError``). + """ rows: List[List[str]] = [] with open(path, newline="") as f: reader = csv.DictReader(f) - for row in reader: - rows.append( - [ - row["timestamp"], - row["open"], - row["high"], - row["low"], - row["close"], - row["volume"], - ] + if reader.fieldnames is None: + raise ValueError(f"{path}: CSV has no header row") + missing = [c for c in REQUIRED_COLUMNS if c not in reader.fieldnames] + if missing: + raise ValueError( + f"{path}: CSV is missing required column(s): " + f"{', '.join(missing)}; found: {', '.join(reader.fieldnames)}" ) + for row in reader: + rows.append([row[c] for c in REQUIRED_COLUMNS]) if not rows: - raise ValueError("CSV is empty") - arr = np.array(rows, dtype=np.float64) + raise ValueError(f"{path}: CSV has a header but no data rows") + try: + arr = np.array(rows, dtype=np.float64) + except (TypeError, ValueError) as exc: + # NumPy's own message names neither the row nor the column — locate + # the first non-numeric cell and report it precisely. + for line_no, row in enumerate(rows, start=2): + for col, value in zip(REQUIRED_COLUMNS, row): + try: + float(value) + except (TypeError, ValueError): + raise ValueError( + f"{path}: row {line_no} column '{col}' is not " + f"numeric: {value!r}" + ) from exc + raise # could not localise — surface the original error return History( timestamp=arr[:, 0].astype(np.int64), open=arr[:, 1], diff --git a/examples/python/multi_timeframe.py b/examples/python/multi_timeframe.py index 53ca9fee..e6f19cf3 100644 --- a/examples/python/multi_timeframe.py +++ b/examples/python/multi_timeframe.py @@ -21,17 +21,43 @@ import numpy as np import wickra as ta +# Columns the OHLCV layout requires; the CSV header must name every one. +REQUIRED_COLUMNS = ("timestamp", "open", "high", "low", "close", "volume") + + def read_csv(path: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """Read an OHLCV CSV into typed columns.""" + """Read an OHLCV CSV into typed columns. + + Raises: + ValueError: if the file has no header, is missing a required column, + holds no data rows, or contains a non-numeric value (the message + names the offending row). + """ ts, o, h, l, c, v = [], [], [], [], [], [] with open(path, newline="") as f: - for row in csv.DictReader(f): - ts.append(int(row["timestamp"])) - o.append(float(row["open"])) - h.append(float(row["high"])) - l.append(float(row["low"])) - c.append(float(row["close"])) - v.append(float(row["volume"])) + reader = csv.DictReader(f) + if reader.fieldnames is None: + raise ValueError(f"{path}: CSV has no header row") + missing = [col for col in REQUIRED_COLUMNS if col not in reader.fieldnames] + if missing: + raise ValueError( + f"{path}: CSV is missing required column(s): " + f"{', '.join(missing)}; found: {', '.join(reader.fieldnames)}" + ) + for line_no, row in enumerate(reader, start=2): + try: + ts.append(int(row["timestamp"])) + o.append(float(row["open"])) + h.append(float(row["high"])) + l.append(float(row["low"])) + c.append(float(row["close"])) + v.append(float(row["volume"])) + except (TypeError, ValueError) as exc: + raise ValueError( + f"{path}: row {line_no} has a non-numeric value: {exc}" + ) from exc + if not ts: + raise ValueError(f"{path}: CSV has a header but no data rows") return ( np.asarray(ts, dtype=np.int64), np.asarray(o, dtype=np.float64), @@ -51,7 +77,13 @@ def resample( v: np.ndarray, bucket: int, ) -> Tuple[np.ndarray, ...]: - """Aggregate bar-level columns into coarser buckets of size `bucket`.""" + """Aggregate bar-level columns into coarser buckets of size `bucket`. + + Raises: + ValueError: if the input series is empty. + """ + if ts.size == 0: + raise ValueError("resample received an empty series") bucket_index = (ts // bucket).astype(np.int64) boundaries = np.diff(bucket_index, prepend=bucket_index[0] - 1) != 0 group_ids = np.cumsum(boundaries) - 1 @@ -76,14 +108,19 @@ def resample( def summarize(label: str, close: np.ndarray, high: np.ndarray, low: np.ndarray) -> None: + if close.size == 0: + print(f" {label:<10} (empty series — nothing to summarize)") + return rsi = ta.RSI(14).batch(close) macd = ta.MACD().batch(close) adx = ta.ADX(14).batch(high, low, close) last_macd_hist = macd[~np.isnan(macd[:, 2])][-1, 2] if np.any(~np.isnan(macd[:, 2])) else float("nan") last_adx = adx[~np.isnan(adx[:, 2])][-1, 2] if np.any(~np.isnan(adx[:, 2])) else float("nan") + valid_rsi = rsi[~np.isnan(rsi)] + last_rsi = valid_rsi[-1] if valid_rsi.size else float("nan") print( f" {label:<10} bars={close.size:>5} " - f"last_close={close[-1]:>10.4f} rsi={rsi[~np.isnan(rsi)][-1]:>6.2f} " + f"last_close={close[-1]:>10.4f} rsi={last_rsi:>6.2f} " f"macd_hist={last_macd_hist:+.4f} adx={last_adx:>6.2f}" )