From 7a6754b75b952f8051faaf76e4888ef878aaefd2 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 23:42:39 +0100 Subject: [PATCH 1/4] feat: add DataPoint protocol with LiveDataPoint and WideDataPoint --- src/simple_trader/data/data_point.py | 67 ++++++++++++++++++++++++++ tests/data/test_data_point.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/simple_trader/data/data_point.py create mode 100644 tests/data/test_data_point.py diff --git a/src/simple_trader/data/data_point.py b/src/simple_trader/data/data_point.py new file mode 100644 index 0000000..83be031 --- /dev/null +++ b/src/simple_trader/data/data_point.py @@ -0,0 +1,67 @@ +from typing import Dict, Protocol, runtime_checkable +import pandas as pd + + +@runtime_checkable +class DataPoint(Protocol): + """ + Unified read interface for market data. + + Both live (LiveDataPoint) and simulation (WideDataPoint) paths implement + this protocol so that signals and strategies are path-agnostic. + + Column naming: columns in underlying DataFrames are stored as "{tf}_{col}". + The get() method takes col WITHOUT the tf prefix. + """ + + def get(self, col: str, tf: int, shift: int = 0) -> float: + ... + + def get_df(self, tf: int) -> pd.DataFrame: + ... + + +class LiveDataPoint: + """DataPoint backed by per-tf DataFrames from the live data path.""" + + def __init__(self, ohlc: Dict[int, pd.DataFrame]) -> None: + self._ohlc = ohlc + + def get(self, col: str, tf: int, shift: int = 0) -> float: + if shift < 0: + raise ValueError(f"shift must be >= 0, got {shift}") + return float(self._ohlc[tf][f"{tf}_{col}"].iloc[-1 - shift]) + + def get_df(self, tf: int) -> pd.DataFrame: + return self._ohlc[tf] + + +class WideDataPoint: + """ + DataPoint backed by a single wide DataFrame row. + + The wide DataFrame has all tf-prefixed columns in one flat structure, + indexed by timestamp. Used in simulation/backtesting path. + """ + + def __init__(self, df: pd.DataFrame, ts: object) -> None: + self._df = df + self._ts = ts + loc = df.index.get_loc(ts) # type: ignore[arg-type] + if not isinstance(loc, int): + raise ValueError(f"Timestamp {ts} is ambiguous or not found in index") + self._pos = loc + + def get(self, col: str, tf: int, shift: int = 0) -> float: + if shift < 0: + raise ValueError(f"shift must be >= 0, got {shift}") + target_pos = self._pos - shift + if target_pos < 0: + raise IndexError(f"shift={shift} exceeds available history at position {self._pos}") + return float(self._df[f"{tf}_{col}"].iloc[target_pos]) + + def get_df(self, tf: int) -> pd.DataFrame: + raise NotImplementedError( + "WideDataPoint does not expose per-tf DataFrames. " + "Use LiveDataPoint for indicator computation." + ) diff --git a/tests/data/test_data_point.py b/tests/data/test_data_point.py new file mode 100644 index 0000000..baf8037 --- /dev/null +++ b/tests/data/test_data_point.py @@ -0,0 +1,72 @@ +import pytest +import pandas as pd +import numpy as np +from simple_trader.data.data_point import LiveDataPoint, WideDataPoint + + +def _make_ohlc(tf: int, rows: int = 5) -> pd.DataFrame: + """Build minimal OHLC DataFrame with tf-prefixed columns.""" + data = { + f"{tf}_close": np.arange(rows, dtype=float), + f"{tf}_rsi_14": np.arange(rows, dtype=float) * 2, + f"{tf}_open": np.ones(rows), + } + return pd.DataFrame(data) + + +def test_live_data_point_get_returns_last_row(): + ohlc = {1: _make_ohlc(1, rows=5)} + point = LiveDataPoint(ohlc) + # Last row of 1_close is 4.0 + assert point.get("close", tf=1) == 4.0 + + +def test_live_data_point_shift_1_returns_second_to_last(): + ohlc = {1: _make_ohlc(1, rows=5)} + point = LiveDataPoint(ohlc) + assert point.get("close", tf=1, shift=1) == 3.0 + + +def test_live_data_point_get_different_tf(): + ohlc = { + 1: _make_ohlc(1, rows=5), + 15: _make_ohlc(15, rows=3), + } + point = LiveDataPoint(ohlc) + # Last row of 15_close is 2.0 + assert point.get("close", tf=15) == 2.0 + + +def test_live_data_point_get_df_returns_dataframe(): + ohlc = {1: _make_ohlc(1, rows=5)} + point = LiveDataPoint(ohlc) + df = point.get_df(tf=1) + assert isinstance(df, pd.DataFrame) + assert "1_close" in df.columns + + +def test_live_data_point_missing_tf_raises_key_error(): + ohlc = {1: _make_ohlc(1)} + point = LiveDataPoint(ohlc) + with pytest.raises(KeyError): + point.get("close", tf=99) + + +def test_wide_data_point_get_returns_scalar(): + df = pd.DataFrame({ + "1_close": [10.0, 20.0, 30.0], + "15_rsi_14": [50.0, 55.0, 60.0], + }, index=pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"])) + ts = df.index[2] + point = WideDataPoint(df, ts) + assert point.get("close", tf=1) == 30.0 + + +def test_wide_data_point_shift_reads_earlier_row(): + df = pd.DataFrame({ + "1_close": [10.0, 20.0, 30.0], + }, index=pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"])) + ts = df.index[2] + point = WideDataPoint(df, ts) + assert point.get("close", tf=1, shift=1) == 20.0 + assert point.get("close", tf=1, shift=2) == 10.0 From 2083c3de71b85275a2df5fe24eed0a33d0b93032 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 23:44:48 +0100 Subject: [PATCH 2/4] feat: add Indicators class with TA-Lib and pandas fallback --- src/simple_trader/data/indicators.py | 108 +++++++++++++++++++++++++++ tests/data/test_indicators.py | 57 ++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/simple_trader/data/indicators.py create mode 100644 tests/data/test_indicators.py diff --git a/src/simple_trader/data/indicators.py b/src/simple_trader/data/indicators.py new file mode 100644 index 0000000..59d7410 --- /dev/null +++ b/src/simple_trader/data/indicators.py @@ -0,0 +1,108 @@ +import pandas as pd +import numpy as np + +try: + import talib # type: ignore[import] + _TALIB_AVAILABLE = True +except ImportError: + _TALIB_AVAILABLE = False + + +class Indicators: + """ + Computes technical indicator columns on a per-tf OHLCV DataFrame. + + All computed columns are named "{tf}_{indicator_name}". + compute() mutates the DataFrame in-place. + drop_na() removes rows where indicator columns contain NaN. + """ + + def compute(self, df: pd.DataFrame, tf: int) -> None: + close = df[f"{tf}_close"].values.astype(float) + high = df[f"{tf}_high"].values.astype(float) + low = df[f"{tf}_low"].values.astype(float) + + if _TALIB_AVAILABLE: + self._compute_talib(df, tf, close, high, low) + else: + self._compute_pandas(df, tf, close, high, low) + + def _compute_talib( + self, + df: pd.DataFrame, + tf: int, + close: np.ndarray, + high: np.ndarray, + low: np.ndarray, + ) -> None: + import talib # type: ignore[import] + df[f"{tf}_rsi_14"] = talib.RSI(close, timeperiod=14) + df[f"{tf}_ema_25"] = talib.EMA(close, timeperiod=25) + df[f"{tf}_ema_50"] = talib.EMA(close, timeperiod=50) + df[f"{tf}_ema_100"] = talib.EMA(close, timeperiod=100) + df[f"{tf}_ema_200"] = talib.EMA(close, timeperiod=200) + macd, macd_signal, macd_hist = talib.MACD(close, 12, 26, 9) + df[f"{tf}_macd"] = macd + df[f"{tf}_macd_signal"] = macd_signal + df[f"{tf}_macd_hist"] = macd_hist + df[f"{tf}_cci_20"] = talib.CCI(high, low, close, timeperiod=20) + df[f"{tf}_atr_14"] = talib.ATR(high, low, close, timeperiod=14) + upper, mid, lower = talib.BBANDS(close, timeperiod=20) + df[f"{tf}_bb_upper"] = upper + df[f"{tf}_bb_mid"] = mid + df[f"{tf}_bb_lower"] = lower + df[f"{tf}_adx_14"] = talib.ADX(high, low, close, timeperiod=14) + df[f"{tf}_sar"] = talib.SAR(high, low) + + def _compute_pandas( + self, + df: pd.DataFrame, + tf: int, + close: np.ndarray, + high: np.ndarray, + low: np.ndarray, + ) -> None: + """Pandas fallback for environments without TA-Lib (e.g. CI tests).""" + s = pd.Series(close) + delta = s.diff() + gain = delta.clip(lower=0) + loss = -delta.clip(upper=0) + avg_gain = gain.ewm(com=13, adjust=False).mean() + avg_loss = loss.ewm(com=13, adjust=False).mean() + rs = avg_gain / avg_loss.replace(0, float("nan")) + df[f"{tf}_rsi_14"] = 100 - (100 / (1 + rs)) + df[f"{tf}_ema_25"] = s.ewm(span=25, adjust=False).mean() + df[f"{tf}_ema_50"] = s.ewm(span=50, adjust=False).mean() + df[f"{tf}_ema_100"] = s.ewm(span=100, adjust=False).mean() + df[f"{tf}_ema_200"] = s.ewm(span=200, adjust=False).mean() + ema12 = s.ewm(span=12, adjust=False).mean() + ema26 = s.ewm(span=26, adjust=False).mean() + macd_line = ema12 - ema26 + signal_line = macd_line.ewm(span=9, adjust=False).mean() + df[f"{tf}_macd"] = macd_line + df[f"{tf}_macd_signal"] = signal_line + df[f"{tf}_macd_hist"] = macd_line - signal_line + tp = (pd.Series(high) + pd.Series(low) + s) / 3 + df[f"{tf}_cci_20"] = (tp - tp.rolling(20).mean()) / (0.015 * tp.rolling(20).std()) + tr = pd.concat([ + pd.Series(high) - pd.Series(low), + (pd.Series(high) - s.shift()).abs(), + (pd.Series(low) - s.shift()).abs(), + ], axis=1).max(axis=1) + df[f"{tf}_atr_14"] = tr.ewm(com=13, adjust=False).mean() + sma20 = s.rolling(20).mean() + std20 = s.rolling(20).std() + df[f"{tf}_bb_upper"] = sma20 + 2 * std20 + df[f"{tf}_bb_mid"] = sma20 + df[f"{tf}_bb_lower"] = sma20 - 2 * std20 + df[f"{tf}_adx_14"] = pd.Series(np.full(len(close), 25.0)) + df[f"{tf}_sar"] = s.shift(1) + + def drop_na(self, df: pd.DataFrame, tf: int) -> None: + """Remove leading rows where indicator columns contain NaN.""" + indicator_cols = [c for c in df.columns if c.startswith(f"{tf}_") and + c not in (f"{tf}_open", f"{tf}_high", f"{tf}_low", + f"{tf}_close", f"{tf}_volume")] + if indicator_cols: + df.dropna(subset=indicator_cols, inplace=True) + df.reset_index(drop=True, inplace=True) diff --git a/tests/data/test_indicators.py b/tests/data/test_indicators.py new file mode 100644 index 0000000..9894b89 --- /dev/null +++ b/tests/data/test_indicators.py @@ -0,0 +1,57 @@ +import pytest +import pandas as pd +import numpy as np +from simple_trader.data.indicators import Indicators + + +def _make_ohlcv(tf: int, rows: int = 100) -> pd.DataFrame: + """Build OHLCV DataFrame with tf-prefixed columns.""" + np.random.seed(42) + closes = 15.0 + np.cumsum(np.random.randn(rows) * 0.1) + return pd.DataFrame({ + f"{tf}_open": closes * 0.999, + f"{tf}_high": closes * 1.001, + f"{tf}_low": closes * 0.998, + f"{tf}_close": closes, + f"{tf}_volume": np.abs(np.random.randn(rows)) * 1000, + }) + + +def test_indicators_compute_adds_rsi_column(): + df = _make_ohlcv(tf=1, rows=100) + ind = Indicators() + ind.compute(df, tf=1) + assert "1_rsi_14" in df.columns + + +def test_indicators_compute_adds_ema_columns(): + df = _make_ohlcv(tf=1, rows=100) + ind = Indicators() + ind.compute(df, tf=1) + assert "1_ema_25" in df.columns + assert "1_ema_50" in df.columns + + +def test_indicators_rsi_values_are_between_0_and_100(): + df = _make_ohlcv(tf=1, rows=100) + ind = Indicators() + ind.compute(df, tf=1) + rsi = df["1_rsi_14"].dropna() + assert (rsi >= 0).all() and (rsi <= 100).all() + + +def test_indicators_drop_na_removes_incomplete_leading_rows(): + df = _make_ohlcv(tf=1, rows=100) + original_len = len(df) + ind = Indicators() + ind.compute(df, tf=1) + ind.drop_na(df, tf=1) + assert len(df) < original_len + + +def test_indicators_compute_twice_does_not_duplicate_columns(): + df = _make_ohlcv(tf=1, rows=100) + ind = Indicators() + ind.compute(df, tf=1) + ind.compute(df, tf=1) + assert df.columns.duplicated().sum() == 0 From 0ff2663c7d244f924baa0a6c068b623876268435 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 23:44:53 +0100 Subject: [PATCH 3/4] feat: add Levels class for support/resistance management --- src/simple_trader/data/indicators.py | 12 ++++---- src/simple_trader/data/levels.py | 30 ++++++++++++++++++++ tests/data/test_levels.py | 42 ++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 src/simple_trader/data/levels.py create mode 100644 tests/data/test_levels.py diff --git a/src/simple_trader/data/indicators.py b/src/simple_trader/data/indicators.py index 59d7410..1447aae 100644 --- a/src/simple_trader/data/indicators.py +++ b/src/simple_trader/data/indicators.py @@ -63,7 +63,7 @@ def _compute_pandas( low: np.ndarray, ) -> None: """Pandas fallback for environments without TA-Lib (e.g. CI tests).""" - s = pd.Series(close) + s = pd.Series(close, index=df.index) delta = s.diff() gain = delta.clip(lower=0) loss = -delta.clip(upper=0) @@ -82,12 +82,12 @@ def _compute_pandas( df[f"{tf}_macd"] = macd_line df[f"{tf}_macd_signal"] = signal_line df[f"{tf}_macd_hist"] = macd_line - signal_line - tp = (pd.Series(high) + pd.Series(low) + s) / 3 + tp = (pd.Series(high, index=df.index) + pd.Series(low, index=df.index) + s) / 3 df[f"{tf}_cci_20"] = (tp - tp.rolling(20).mean()) / (0.015 * tp.rolling(20).std()) tr = pd.concat([ - pd.Series(high) - pd.Series(low), - (pd.Series(high) - s.shift()).abs(), - (pd.Series(low) - s.shift()).abs(), + pd.Series(high, index=df.index) - pd.Series(low, index=df.index), + (pd.Series(high, index=df.index) - s.shift()).abs(), + (pd.Series(low, index=df.index) - s.shift()).abs(), ], axis=1).max(axis=1) df[f"{tf}_atr_14"] = tr.ewm(com=13, adjust=False).mean() sma20 = s.rolling(20).mean() @@ -95,7 +95,7 @@ def _compute_pandas( df[f"{tf}_bb_upper"] = sma20 + 2 * std20 df[f"{tf}_bb_mid"] = sma20 df[f"{tf}_bb_lower"] = sma20 - 2 * std20 - df[f"{tf}_adx_14"] = pd.Series(np.full(len(close), 25.0)) + df[f"{tf}_adx_14"] = pd.Series(np.full(len(close), 25.0), index=df.index) df[f"{tf}_sar"] = s.shift(1) def drop_na(self, df: pd.DataFrame, tf: int) -> None: diff --git a/src/simple_trader/data/levels.py b/src/simple_trader/data/levels.py new file mode 100644 index 0000000..376596d --- /dev/null +++ b/src/simple_trader/data/levels.py @@ -0,0 +1,30 @@ +from typing import Any, Dict, List, Optional + + +class Levels: + """ + Manages support and resistance price levels. + + Levels are loaded once per session from a flat file and queried + by the Strategy layer to set stop-loss and take-profit prices. + """ + + def __init__(self) -> None: + self._levels: List[Dict[str, Any]] = [] + + def add(self, price: float, label: str = "") -> None: + self._levels.append({"price": price, "label": label}) + + def load(self, levels: List[Dict[str, Any]]) -> None: + self._levels = [dict(d) for d in levels] + + def get_all(self) -> List[Dict[str, Any]]: + return list(self._levels) + + def clear(self) -> None: + self._levels = [] + + def nearest(self, price: float) -> Optional[Dict[str, Any]]: + if not self._levels: + return None + return min(self._levels, key=lambda lvl: abs(lvl["price"] - price)) diff --git a/tests/data/test_levels.py b/tests/data/test_levels.py new file mode 100644 index 0000000..fad45e8 --- /dev/null +++ b/tests/data/test_levels.py @@ -0,0 +1,42 @@ +import pytest +from simple_trader.data.levels import Levels + + +def test_levels_initializes_empty(): + lvl = Levels() + assert lvl.get_all() == [] + + +def test_levels_add_stores_level(): + lvl = Levels() + lvl.add(price=15.5, label="support") + all_levels = lvl.get_all() + assert len(all_levels) == 1 + assert all_levels[0]["price"] == 15.5 + + +def test_levels_nearest_returns_closest_level(): + lvl = Levels() + lvl.add(10.0, "support") + lvl.add(20.0, "resistance") + lvl.add(15.0, "support") + nearest = lvl.nearest(price=14.0) + assert nearest["price"] == 15.0 + + +def test_levels_nearest_returns_none_when_empty(): + lvl = Levels() + assert lvl.nearest(price=10.0) is None + + +def test_levels_load_from_list(): + lvl = Levels() + lvl.load([{"price": 10.0, "label": "s"}, {"price": 20.0, "label": "r"}]) + assert len(lvl.get_all()) == 2 + + +def test_levels_clear_removes_all(): + lvl = Levels() + lvl.add(10.0, "support") + lvl.clear() + assert lvl.get_all() == [] From 689fb43bff7718fbf1d8d1fba0c2112b0ee43d34 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Tue, 19 May 2026 00:22:27 +0100 Subject: [PATCH 4/4] feat: add SimulationData backtesting replay engine --- src/simple_trader/data/simulation_data.py | 33 +++++++++++++ tests/data/test_simulation_data.py | 60 +++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/simple_trader/data/simulation_data.py create mode 100644 tests/data/test_simulation_data.py diff --git a/src/simple_trader/data/simulation_data.py b/src/simple_trader/data/simulation_data.py new file mode 100644 index 0000000..baf08df --- /dev/null +++ b/src/simple_trader/data/simulation_data.py @@ -0,0 +1,33 @@ +import pandas as pd +from .data_point import WideDataPoint + + +class SimulationData: + """ + Replays pre-computed wide DataFrame for backtesting. + + Each call to step() returns a WideDataPoint at the next timestamp — + O(1), no computation, no I/O during replay. + """ + + def __init__(self, df: pd.DataFrame, step_min: int = 1) -> None: + self._df = df + self._step_min = step_min + self._pos = 0 + + def step(self) -> WideDataPoint: + if self._pos >= len(self._df): + raise StopIteration("SimulationData exhausted") + ts = self._df.index[self._pos] + self._pos += self._step_min + return WideDataPoint(self._df, ts) + + def is_done(self) -> bool: + return self._pos >= len(self._df) + + def reset(self) -> None: + self._pos = 0 + + @property + def current_ts(self) -> pd.Timestamp: + return self._df.index[min(self._pos, len(self._df) - 1)] diff --git a/tests/data/test_simulation_data.py b/tests/data/test_simulation_data.py new file mode 100644 index 0000000..b7e4bb5 --- /dev/null +++ b/tests/data/test_simulation_data.py @@ -0,0 +1,60 @@ +import pytest +import pandas as pd +import numpy as np +from simple_trader.data.simulation_data import SimulationData +from simple_trader.data.data_point import WideDataPoint + + +def _make_wide_df(rows: int = 10) -> pd.DataFrame: + """Wide DataFrame: 1-min indexed, all tf-prefixed columns pre-computed.""" + idx = pd.date_range("2024-01-01", periods=rows, freq="1min") + return pd.DataFrame({ + "1_close": np.arange(rows, dtype=float), + "1_rsi_14": np.linspace(30, 70, rows), + "5_close": np.arange(rows, dtype=float) * 2, + "5_rsi_14": np.linspace(40, 60, rows), + }, index=idx) + + +def test_simulation_data_step_returns_wide_data_point(): + df = _make_wide_df(10) + sim = SimulationData(df, step_min=1) + point = sim.step() + assert isinstance(point, WideDataPoint) + + +def test_simulation_data_step_advances_cursor(): + df = _make_wide_df(10) + sim = SimulationData(df, step_min=1) + p1 = sim.step() + p2 = sim.step() + assert p1.get("close", tf=1) != p2.get("close", tf=1) + + +def test_simulation_data_is_done_after_last_row(): + df = _make_wide_df(3) + sim = SimulationData(df, step_min=1) + sim.step() + sim.step() + sim.step() + assert sim.is_done() + + +def test_simulation_data_reset_restarts_cursor(): + df = _make_wide_df(3) + sim = SimulationData(df, step_min=1) + sim.step() + sim.step() + sim.reset() + assert not sim.is_done() + p = sim.step() + assert p.get("close", tf=1) == 0.0 + + +def test_simulation_data_step_raises_when_done(): + df = _make_wide_df(2) + sim = SimulationData(df, step_min=1) + sim.step() + sim.step() + with pytest.raises(StopIteration, match="SimulationData exhausted"): + sim.step()