From 9011758605426e940dbea2c810d830ce156654be Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 21:29:10 +0100 Subject: [PATCH 01/14] chore: add project skeleton and pyproject.toml Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 28 ++++++++++++++++++++ requirements-dev.txt | 5 ++++ requirements.txt | 3 +++ src/simple_trader/__init__.py | 0 src/simple_trader/infrastructure/__init__.py | 0 tests/__init__.py | 0 tests/conftest.py | 1 + tests/infrastructure/__init__.py | 0 8 files changed, 37 insertions(+) create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 src/simple_trader/__init__.py create mode 100644 src/simple_trader/infrastructure/__init__.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/infrastructure/__init__.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c8ae59c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "simple_trader" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "pandas>=2.0", + "numpy>=1.24", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4", + "pytest-mock>=3.12", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.11" +strict = true diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..0e3abfb --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest>=7.4 +pytest-mock>=3.12 +mypy>=1.5 +black>=23.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d496d4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pandas>=2.0 +numpy>=1.24 +python-binance>=1.0.19 diff --git a/src/simple_trader/__init__.py b/src/simple_trader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/simple_trader/infrastructure/__init__.py b/src/simple_trader/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5871ed8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +import pytest diff --git a/tests/infrastructure/__init__.py b/tests/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 From 85b718382e7957bc84d1d4e30ebac49179e24bd5 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 21:34:39 +0100 Subject: [PATCH 02/14] fix: add missing dev deps and python-binance to pyproject.toml --- pyproject.toml | 3 +++ tests/conftest.py | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c8ae59c..745aaa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,12 +9,15 @@ requires-python = ">=3.11" dependencies = [ "pandas>=2.0", "numpy>=1.24", + "python-binance>=1.0.19", ] [project.optional-dependencies] dev = [ "pytest>=7.4", "pytest-mock>=3.12", + "mypy>=1.5", + "black>=23.0", ] [tool.setuptools.packages.find] diff --git a/tests/conftest.py b/tests/conftest.py index 5871ed8..e69de29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +0,0 @@ -import pytest From 4c4e6aea3b7fe4916da9558a88fcea0ca9ae88b7 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 21:37:43 +0100 Subject: [PATCH 03/14] feat: add infrastructure constants (StrategyAction, PositionState, PositionType) --- src/simple_trader/infrastructure/constants.py | 26 ++++++++++++++ tests/infrastructure/test_constants.py | 36 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/simple_trader/infrastructure/constants.py create mode 100644 tests/infrastructure/test_constants.py diff --git a/src/simple_trader/infrastructure/constants.py b/src/simple_trader/infrastructure/constants.py new file mode 100644 index 0000000..4662935 --- /dev/null +++ b/src/simple_trader/infrastructure/constants.py @@ -0,0 +1,26 @@ +from enum import Enum, auto + + +class StrategyAction(Enum): + NOTHING = auto() + OPEN_LONG = auto() + OPEN_SHORT = auto() + CLOSE_LONG = auto() + CLOSE_SHORT = auto() + MOVE_STOP_LOSS_LONG = auto() + MOVE_STOP_LOSS_SHORT = auto() + DO_STOP_LOSS = auto() + + +class PositionState(Enum): + WAIT = "WAIT" + WAIT_BUY = "WAIT_BUY" + WAIT_SELL = "WAIT_SELL" + WAIT_SAFETY_BUY = "WAIT_SAFETY_BUY" + WAIT_SAFETY_SELL = "WAIT_SAFETY_SELL" + + +class PositionType(Enum): + UNKNOWN = "UNKNOWN" + LONG = "LONG" + SHORT = "SHORT" diff --git a/tests/infrastructure/test_constants.py b/tests/infrastructure/test_constants.py new file mode 100644 index 0000000..5bdd37d --- /dev/null +++ b/tests/infrastructure/test_constants.py @@ -0,0 +1,36 @@ +from simple_trader.infrastructure.constants import StrategyAction, PositionState, PositionType + +def test_strategy_action_nothing_exists(): + assert StrategyAction.NOTHING is not None + +def test_strategy_action_open_long_exists(): + assert StrategyAction.OPEN_LONG is not None + +def test_strategy_action_all_distinct(): + actions = [ + StrategyAction.NOTHING, + StrategyAction.OPEN_LONG, + StrategyAction.OPEN_SHORT, + StrategyAction.CLOSE_LONG, + StrategyAction.CLOSE_SHORT, + StrategyAction.MOVE_STOP_LOSS_LONG, + StrategyAction.MOVE_STOP_LOSS_SHORT, + StrategyAction.DO_STOP_LOSS, + ] + assert len(set(actions)) == len(actions) + +def test_position_state_wait_is_string_wait(): + assert PositionState.WAIT.value == "WAIT" + +def test_position_state_all_states_exist(): + states = [ + PositionState.WAIT, + PositionState.WAIT_BUY, + PositionState.WAIT_SELL, + PositionState.WAIT_SAFETY_BUY, + PositionState.WAIT_SAFETY_SELL, + ] + assert len(states) == 5 + +def test_position_type_unknown_exists(): + assert PositionType.UNKNOWN is not None From b254b5d034063337605f6865a3619ee7d5d4b6c4 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 21:59:18 +0100 Subject: [PATCH 04/14] feat: add Config dataclass reading from environment variables --- src/simple_trader/infrastructure/config.py | 42 +++++++++++++++++++++ tests/infrastructure/test_config.py | 44 ++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/simple_trader/infrastructure/config.py create mode 100644 tests/infrastructure/test_config.py diff --git a/src/simple_trader/infrastructure/config.py b/src/simple_trader/infrastructure/config.py new file mode 100644 index 0000000..bd99cb5 --- /dev/null +++ b/src/simple_trader/infrastructure/config.py @@ -0,0 +1,42 @@ +import os +from dataclasses import dataclass, field + + +@dataclass +class Config: + pair: str = field( + default_factory=lambda: os.environ.get("PAIR", "link_usdt") + ) + data_set_name: str = field( + default_factory=lambda: os.environ.get("DATA_SET_NAME", "default") + ) + root_folder: str = field( + default_factory=lambda: os.environ.get("ROOT_FOLDER", "local") + ) + data_root: str = field( + default_factory=lambda: os.environ.get("DATA_ROOT", "trader_data") + ) + data_start: int = field( + default_factory=lambda: int(os.environ.get("DATA_START", "0")) + ) + data_end: int = field( + default_factory=lambda: int(os.environ.get("DATA_END", "9999999999999")) + ) + trainer_time_step: int = field( + default_factory=lambda: int(os.environ.get("TRAINER_TIME_STEP", "1")) + ) + available_threads: int = field( + default_factory=lambda: int(os.environ.get("AVAILABLE_THREADS", "1")) + ) + use_nn_simulation: bool = field( + default_factory=lambda: os.environ.get("USE_NN_SIMULATION", "False") == "True" + ) + is_trader_test: bool = field( + default_factory=lambda: os.environ.get("IS_TRAIDER_TEST", "0") == "1" + ) + binance_api_key: str = field( + default_factory=lambda: os.environ.get("BINANCE_API_KEY", "") + ) + binance_api_secret: str = field( + default_factory=lambda: os.environ.get("BINANCE_API_SECRET", "") + ) diff --git a/tests/infrastructure/test_config.py b/tests/infrastructure/test_config.py new file mode 100644 index 0000000..dd2b12c --- /dev/null +++ b/tests/infrastructure/test_config.py @@ -0,0 +1,44 @@ +import pytest +from simple_trader.infrastructure.config import Config + + +def test_config_reads_pair_from_env(monkeypatch): + monkeypatch.setenv("PAIR", "btc_usdt") + config = Config() + assert config.pair == "btc_usdt" + + +def test_config_defaults_pair_to_link_usdt(monkeypatch): + monkeypatch.delenv("PAIR", raising=False) + config = Config() + assert config.pair == "link_usdt" + + +def test_config_reads_available_threads_as_int(monkeypatch): + monkeypatch.setenv("AVAILABLE_THREADS", "4") + config = Config() + assert config.available_threads == 4 + + +def test_config_use_nn_simulation_true(monkeypatch): + monkeypatch.setenv("USE_NN_SIMULATION", "True") + config = Config() + assert config.use_nn_simulation is True + + +def test_config_use_nn_simulation_false_by_default(monkeypatch): + monkeypatch.delenv("USE_NN_SIMULATION", raising=False) + config = Config() + assert config.use_nn_simulation is False + + +def test_config_is_trader_test_when_env_is_one(monkeypatch): + monkeypatch.setenv("IS_TRAIDER_TEST", "1") + config = Config() + assert config.is_trader_test is True + + +def test_config_data_start_as_int(monkeypatch): + monkeypatch.setenv("DATA_START", "1700000000000") + config = Config() + assert config.data_start == 1700000000000 From 437e0f88e795de137ea5c42c79425e8dec5bc0bc Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 22:03:22 +0100 Subject: [PATCH 05/14] feat: add paths module for storage path construction --- src/simple_trader/infrastructure/paths.py | 30 ++++++++++++ tests/infrastructure/test_paths.py | 58 +++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/simple_trader/infrastructure/paths.py create mode 100644 tests/infrastructure/test_paths.py diff --git a/src/simple_trader/infrastructure/paths.py b/src/simple_trader/infrastructure/paths.py new file mode 100644 index 0000000..93f766b --- /dev/null +++ b/src/simple_trader/infrastructure/paths.py @@ -0,0 +1,30 @@ +from pathlib import Path +from .config import Config + + +def root_folder(config: Config) -> Path: + if config.root_folder == "remote": + base = Path("/trader_data") + else: + base = Path("/trader_data_local") + return base / config.data_root / f"{config.data_set_name}_{config.pair}" + + +def data_folder(config: Config) -> Path: + return root_folder(config) / "data" + + +def shared_folder(config: Config) -> Path: + return root_folder(config) / "shared" + + +def nn_folder(config: Config) -> Path: + return shared_folder(config) / "nn_data" + + +def action_folder(config: Config) -> Path: + return shared_folder(config) / "actions" + + +def stats_folder(config: Config) -> Path: + return Path("stats") / config.data_root / config.pair diff --git a/tests/infrastructure/test_paths.py b/tests/infrastructure/test_paths.py new file mode 100644 index 0000000..b7c1d5d --- /dev/null +++ b/tests/infrastructure/test_paths.py @@ -0,0 +1,58 @@ +from pathlib import Path +import pytest +from simple_trader.infrastructure.config import Config +from simple_trader.infrastructure.paths import ( + root_folder, data_folder, shared_folder, + nn_folder, action_folder, stats_folder, +) + + +def make_config(**kwargs): + import os + defaults = { + "PAIR": "link_usdt", + "DATA_SET_NAME": "train", + "ROOT_FOLDER": "local", + "DATA_ROOT": "trader_data", + } + defaults.update(kwargs) + for k, v in defaults.items(): + os.environ[k] = v + return Config() + + +def test_root_folder_local_uses_trader_data_local(): + config = make_config(ROOT_FOLDER="local", DATA_ROOT="mydata", + DATA_SET_NAME="train", PAIR="link_usdt") + assert root_folder(config) == Path("/trader_data_local/mydata/train_link_usdt") + + +def test_root_folder_remote_uses_trader_data(): + config = make_config(ROOT_FOLDER="remote", DATA_ROOT="mydata", + DATA_SET_NAME="train", PAIR="link_usdt") + assert root_folder(config) == Path("/trader_data/mydata/train_link_usdt") + + +def test_data_folder_is_root_slash_data(): + config = make_config() + assert data_folder(config) == root_folder(config) / "data" + + +def test_shared_folder_is_root_slash_shared(): + config = make_config() + assert shared_folder(config) == root_folder(config) / "shared" + + +def test_nn_folder_is_shared_slash_nn_data(): + config = make_config() + assert nn_folder(config) == shared_folder(config) / "nn_data" + + +def test_action_folder_is_shared_slash_actions(): + config = make_config() + assert action_folder(config) == shared_folder(config) / "actions" + + +def test_stats_folder_is_repo_local(): + config = make_config(DATA_ROOT="trader_data", PAIR="link_usdt") + assert stats_folder(config) == Path("stats/trader_data/link_usdt") From c42ce268ff93e7614b227b67f054d937fb1f7b99 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 22:14:51 +0100 Subject: [PATCH 06/14] feat: add TradeLogger for per-pair file logging --- src/simple_trader/infrastructure/logging.py | 29 ++++++++++++++ tests/infrastructure/test_logging.py | 42 +++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/simple_trader/infrastructure/logging.py create mode 100644 tests/infrastructure/test_logging.py diff --git a/src/simple_trader/infrastructure/logging.py b/src/simple_trader/infrastructure/logging.py new file mode 100644 index 0000000..d91db24 --- /dev/null +++ b/src/simple_trader/infrastructure/logging.py @@ -0,0 +1,29 @@ +from pathlib import Path +from .config import Config + + +class TradeLogger: + def __init__(self, config: Config, log_dir: Path = Path("output")) -> None: + self._config = config + self._log_dir = log_dir + self._log_dir.mkdir(parents=True, exist_ok=True) + self._log_path = self._log_dir / f"LOG_{config.pair}.txt" + + def log(self, msg: str, do_print: bool = False) -> None: + with open(self._log_path, "a") as f: + f.write(f"{msg}\n") + if do_print: + print(msg) + + def log_error(self, msg: str) -> None: + self.log(f"ERROR: {msg}", do_print=True) + + def log_warn(self, msg: str) -> None: + self.log(f"WARN: {msg}", do_print=True) + + def log_revenue(self, msg: str, time_point: int, thread: int) -> None: + line = f"[t={time_point} th={thread}] {msg}" + print(line) + rev_path = self._log_dir / f"REVENUE_{self._config.pair}_{thread}.txt" + with open(rev_path, "a") as f: + f.write(f"{line}\n") diff --git a/tests/infrastructure/test_logging.py b/tests/infrastructure/test_logging.py new file mode 100644 index 0000000..fbf3c12 --- /dev/null +++ b/tests/infrastructure/test_logging.py @@ -0,0 +1,42 @@ +import pytest +from pathlib import Path +from simple_trader.infrastructure.config import Config +from simple_trader.infrastructure.logging import TradeLogger + + +@pytest.fixture +def tmp_config(monkeypatch, tmp_path): + monkeypatch.setenv("PAIR", "link_usdt") + return Config() + + +def test_log_writes_to_file(tmp_config, tmp_path): + logger = TradeLogger(tmp_config, log_dir=tmp_path) + logger.log("hello") + log_file = tmp_path / "LOG_link_usdt.txt" + assert log_file.read_text() == "hello\n" + + +def test_log_appends_multiple_messages(tmp_config, tmp_path): + logger = TradeLogger(tmp_config, log_dir=tmp_path) + logger.log("line1") + logger.log("line2") + content = (tmp_path / "LOG_link_usdt.txt").read_text() + assert "line1" in content + assert "line2" in content + + +def test_log_revenue_writes_to_per_thread_file(tmp_config, tmp_path): + logger = TradeLogger(tmp_config, log_dir=tmp_path) + logger.log_revenue("profit=5%", time_point=12345, thread=0) + rev_file = tmp_path / "REVENUE_link_usdt_0.txt" + assert rev_file.exists() + assert "profit=5%" in rev_file.read_text() + + +def test_log_error_prefixes_error(tmp_config, tmp_path): + logger = TradeLogger(tmp_config, log_dir=tmp_path) + logger.log_error("something failed") + content = (tmp_path / "LOG_link_usdt.txt").read_text() + assert "ERROR" in content + assert "something failed" in content From 7bd8eabaca48e279542109a654e0ec2643afe9b4 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 23:04:12 +0100 Subject: [PATCH 07/14] feat: add BaseStock abstract interface for exchange adapters --- src/simple_trader/data/stocks/base_stock.py | 35 +++++++++++ tests/data/test_base_stock.py | 64 +++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/simple_trader/data/stocks/base_stock.py create mode 100644 tests/data/test_base_stock.py diff --git a/src/simple_trader/data/stocks/base_stock.py b/src/simple_trader/data/stocks/base_stock.py new file mode 100644 index 0000000..da664df --- /dev/null +++ b/src/simple_trader/data/stocks/base_stock.py @@ -0,0 +1,35 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + + +class BaseStock(ABC): + + @property + @abstractmethod + def fee(self) -> float: + """Taker fee as a decimal fraction (e.g. 0.001 = 0.1%).""" + + @abstractmethod + def get_candles_history( + self, + count: int, + symbol: str, + time_point: int, + tf_minutes: int = 1, + ) -> List[Any]: + """Fetch OHLCV candles from the exchange.""" + + @abstractmethod + def trade( + self, + symbol: str, + side: str, + order_type: str, + quantity: float, + price: Optional[float] = None, + ) -> Dict[str, Any]: + """Place an order on the exchange.""" + + @abstractmethod + def get_order_status(self, symbol: str, order_id: str) -> Dict[str, Any]: + """Query the fill status of an existing order.""" diff --git a/tests/data/test_base_stock.py b/tests/data/test_base_stock.py new file mode 100644 index 0000000..8f8d597 --- /dev/null +++ b/tests/data/test_base_stock.py @@ -0,0 +1,64 @@ +import pytest +from typing import Any, Dict, List, Optional +from simple_trader.data.stocks.base_stock import BaseStock + + +def test_base_stock_cannot_be_instantiated(): + with pytest.raises(TypeError): + BaseStock() # type: ignore[abstract] + + +def test_subclass_missing_trade_raises_type_error(): + class Partial(BaseStock): + @property + def fee(self) -> float: + return 0.001 + + def get_candles_history(self, count: int, symbol: str, time_point: int, tf_minutes: int = 1) -> List[Any]: + return [] + + def get_order_status(self, symbol: str, order_id: str) -> Dict[str, Any]: + return {} + + with pytest.raises(TypeError): + Partial() + + +def test_fully_implemented_subclass_instantiates(): + class FakeStock(BaseStock): + @property + def fee(self) -> float: + return 0.001 + + def get_candles_history(self, count: int, symbol: str, time_point: int, tf_minutes: int = 1) -> List[Any]: + return [] + + def trade(self, symbol: str, side: str, order_type: str, quantity: float, price: Optional[float] = None) -> Dict[str, Any]: + return {"orderId": "1"} + + def get_order_status(self, symbol: str, order_id: str) -> Dict[str, Any]: + return {"status": "FILLED", "executedQty": "1.0"} + + stock = FakeStock() + assert stock.fee == 0.001 + + +def test_get_candles_history_returns_list(): + class FakeStock(BaseStock): + @property + def fee(self) -> float: + return 0.001 + + def get_candles_history(self, count: int, symbol: str, time_point: int, tf_minutes: int = 1) -> List[Any]: + return [["t", "o", "h", "l", "c", "v"]] + + def trade(self, symbol: str, side: str, order_type: str, quantity: float, price: Optional[float] = None) -> Dict[str, Any]: + return {"orderId": "1"} + + def get_order_status(self, symbol: str, order_id: str) -> Dict[str, Any]: + return {"status": "FILLED", "executedQty": "1.0"} + + stock = FakeStock() + result = stock.get_candles_history(1, "LINKUSDT", 0) + assert isinstance(result, list) + assert len(result) == 1 From 16c71d08c1b0575f0cd34ae03c39ac055d1d8ef2 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 23:06:03 +0100 Subject: [PATCH 08/14] feat: add StockItem module-level singleton --- src/simple_trader/data/stocks/stock_item.py | 17 +++++++ tests/data/test_stock_item.py | 52 +++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/simple_trader/data/stocks/stock_item.py create mode 100644 tests/data/test_stock_item.py diff --git a/src/simple_trader/data/stocks/stock_item.py b/src/simple_trader/data/stocks/stock_item.py new file mode 100644 index 0000000..7da9185 --- /dev/null +++ b/src/simple_trader/data/stocks/stock_item.py @@ -0,0 +1,17 @@ +from typing import Optional +from .base_stock import BaseStock + +_instance: Optional[BaseStock] = None + + +def set(stock: Optional[BaseStock]) -> None: + global _instance + _instance = stock + + +def get() -> BaseStock: + if _instance is None: + raise RuntimeError( + "StockItem not initialized. Call stock_item.set(instance) before use." + ) + return _instance diff --git a/tests/data/test_stock_item.py b/tests/data/test_stock_item.py new file mode 100644 index 0000000..c8cce33 --- /dev/null +++ b/tests/data/test_stock_item.py @@ -0,0 +1,52 @@ +import pytest +import simple_trader.data.stocks.stock_item as stock_item +from simple_trader.data.stocks.base_stock import BaseStock + + +class FakeStock(BaseStock): + @property + def fee(self) -> float: + return 0.002 + + def get_candles_history(self, count: int, symbol: str, time_point: int, tf_minutes: int = 1) -> list: + return [] + + def trade(self, symbol: str, side: str, order_type: str, quantity: float, price: float | None = None) -> dict: + return {"orderId": "99"} + + def get_order_status(self, symbol: str, order_id: str) -> dict: + return {"status": "FILLED", "executedQty": "1.0"} + + +@pytest.fixture(autouse=True) +def reset(): + """Ensure singleton is cleared before and after each test.""" + stock_item.set(None) + yield + stock_item.set(None) + + +def test_get_raises_before_set(): + with pytest.raises(RuntimeError, match="not initialized"): + stock_item.get() + + +def test_get_returns_instance_after_set(): + fake = FakeStock() + stock_item.set(fake) + assert stock_item.get() is fake + + +def test_set_overwrites_previous(): + fake1 = FakeStock() + fake2 = FakeStock() + stock_item.set(fake1) + stock_item.set(fake2) + assert stock_item.get() is fake2 + + +def test_set_none_clears_instance(): + stock_item.set(FakeStock()) + stock_item.set(None) + with pytest.raises(RuntimeError): + stock_item.get() From d4a34d6cc6555cc1c342a4890bab49fb7cd8b930 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 23:07:50 +0100 Subject: [PATCH 09/14] feat: add BinanceStock exchange adapter wrapping python-binance Co-Authored-By: Claude Sonnet 4.6 --- .../data/stocks/binance_stock.py | 61 +++++++++++++++++++ tests/data/test_binance_stock.py | 50 +++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/simple_trader/data/stocks/binance_stock.py create mode 100644 tests/data/test_binance_stock.py diff --git a/src/simple_trader/data/stocks/binance_stock.py b/src/simple_trader/data/stocks/binance_stock.py new file mode 100644 index 0000000..d7928b6 --- /dev/null +++ b/src/simple_trader/data/stocks/binance_stock.py @@ -0,0 +1,61 @@ +from typing import Any, Dict, List, Optional +from binance.client import Client +from .base_stock import BaseStock + +_INTERVAL_MAP = { + 1: Client.KLINE_INTERVAL_1MINUTE, + 3: Client.KLINE_INTERVAL_3MINUTE, + 5: Client.KLINE_INTERVAL_5MINUTE, + 15: Client.KLINE_INTERVAL_15MINUTE, + 60: Client.KLINE_INTERVAL_1HOUR, + 240: Client.KLINE_INTERVAL_4HOUR, + 1440: Client.KLINE_INTERVAL_1DAY, +} + + +class BinanceStock(BaseStock): + def __init__( + self, api_key: str, api_secret: str, taker_fee: float = 0.001 + ) -> None: + self._client = Client(api_key, api_secret) + self._fee = taker_fee + + @property + def fee(self) -> float: + return self._fee + + def get_candles_history( + self, + count: int, + symbol: str, + time_point: int, + tf_minutes: int = 1, + ) -> List[Any]: + interval = _INTERVAL_MAP.get(tf_minutes, Client.KLINE_INTERVAL_1MINUTE) + return self._client.get_klines( + symbol=symbol.upper(), + interval=interval, + limit=count, + ) + + def trade( + self, + symbol: str, + side: str, + order_type: str = "LIMIT", + quantity: float = 0.0, + price: Optional[float] = None, + ) -> Dict[str, Any]: + kwargs: Dict[str, Any] = { + "symbol": symbol.upper(), + "side": side.upper(), + "type": order_type, + "quantity": quantity, + } + if price is not None: + kwargs["price"] = str(price) + kwargs["timeInForce"] = "GTC" + return self._client.create_order(**kwargs) + + def get_order_status(self, symbol: str, order_id: str) -> Dict[str, Any]: + return self._client.get_order(symbol=symbol.upper(), orderId=order_id) diff --git a/tests/data/test_binance_stock.py b/tests/data/test_binance_stock.py new file mode 100644 index 0000000..4be8746 --- /dev/null +++ b/tests/data/test_binance_stock.py @@ -0,0 +1,50 @@ +import pytest +from unittest.mock import MagicMock, patch +from simple_trader.data.stocks.binance_stock import BinanceStock + + +@pytest.fixture +def mock_client(): + with patch("simple_trader.data.stocks.binance_stock.Client") as MockClient: + client = MagicMock() + MockClient.return_value = client + yield client + + +def test_fee_returns_constructor_value(mock_client): + stock = BinanceStock("key", "secret", taker_fee=0.001) + assert stock.fee == 0.001 + + +def test_get_candles_history_calls_get_klines(mock_client): + mock_client.get_klines.return_value = [["ts", "o", "h", "l", "c", "v"]] + stock = BinanceStock("key", "secret") + result = stock.get_candles_history(count=100, symbol="LINKUSDT", time_point=0) + mock_client.get_klines.assert_called_once() + assert result == [["ts", "o", "h", "l", "c", "v"]] + + +def test_get_candles_history_uses_tf_minutes_map(mock_client): + mock_client.get_klines.return_value = [] + stock = BinanceStock("key", "secret") + stock.get_candles_history(count=10, symbol="LINKUSDT", time_point=0, tf_minutes=60) + call_kwargs = mock_client.get_klines.call_args[1] + assert "1h" in call_kwargs["interval"] or call_kwargs["interval"].endswith("h") + + +def test_trade_calls_create_order(mock_client): + mock_client.create_order.return_value = {"orderId": "42"} + stock = BinanceStock("key", "secret") + result = stock.trade("LINKUSDT", "BUY", "LIMIT", 10.0, price=15.5) + assert result["orderId"] == "42" + mock_client.create_order.assert_called_once() + call_kwargs = mock_client.create_order.call_args[1] + assert call_kwargs.get("timeInForce") == "GTC" + + +def test_get_order_status_calls_get_order(mock_client): + mock_client.get_order.return_value = {"status": "FILLED", "executedQty": "10.0"} + stock = BinanceStock("key", "secret") + result = stock.get_order_status("LINKUSDT", "42") + assert result["status"] == "FILLED" + mock_client.get_order.assert_called_once_with(symbol="LINKUSDT", orderId="42") From bc3da5e5f655f8e5ae4670094c296d37485c35ec Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 23:10:56 +0100 Subject: [PATCH 10/14] fix: raise ValueError for invalid tf_minutes and non-positive quantity in BinanceStock --- src/simple_trader/data/stocks/binance_stock.py | 10 +++++++--- tests/data/test_binance_stock.py | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/simple_trader/data/stocks/binance_stock.py b/src/simple_trader/data/stocks/binance_stock.py index d7928b6..0d3e125 100644 --- a/src/simple_trader/data/stocks/binance_stock.py +++ b/src/simple_trader/data/stocks/binance_stock.py @@ -31,7 +31,9 @@ def get_candles_history( time_point: int, tf_minutes: int = 1, ) -> List[Any]: - interval = _INTERVAL_MAP.get(tf_minutes, Client.KLINE_INTERVAL_1MINUTE) + interval = _INTERVAL_MAP.get(tf_minutes) + if interval is None: + raise ValueError(f"Unsupported tf_minutes: {tf_minutes}. Valid values: {sorted(_INTERVAL_MAP)}") return self._client.get_klines( symbol=symbol.upper(), interval=interval, @@ -42,10 +44,12 @@ def trade( self, symbol: str, side: str, - order_type: str = "LIMIT", - quantity: float = 0.0, + order_type: str, + quantity: float, price: Optional[float] = None, ) -> Dict[str, Any]: + if quantity <= 0: + raise ValueError(f"quantity must be positive, got {quantity}") kwargs: Dict[str, Any] = { "symbol": symbol.upper(), "side": side.upper(), diff --git a/tests/data/test_binance_stock.py b/tests/data/test_binance_stock.py index 4be8746..8992793 100644 --- a/tests/data/test_binance_stock.py +++ b/tests/data/test_binance_stock.py @@ -29,7 +29,7 @@ def test_get_candles_history_uses_tf_minutes_map(mock_client): stock = BinanceStock("key", "secret") stock.get_candles_history(count=10, symbol="LINKUSDT", time_point=0, tf_minutes=60) call_kwargs = mock_client.get_klines.call_args[1] - assert "1h" in call_kwargs["interval"] or call_kwargs["interval"].endswith("h") + assert call_kwargs["interval"] == "1h" def test_trade_calls_create_order(mock_client): @@ -48,3 +48,18 @@ def test_get_order_status_calls_get_order(mock_client): result = stock.get_order_status("LINKUSDT", "42") assert result["status"] == "FILLED" mock_client.get_order.assert_called_once_with(symbol="LINKUSDT", orderId="42") + + +def test_trade_market_order_no_time_in_force(mock_client): + mock_client.create_order.return_value = {"orderId": "55"} + stock = BinanceStock("key", "secret") + stock.trade("LINKUSDT", "BUY", "MARKET", 5.0) + call_kwargs = mock_client.create_order.call_args[1] + assert "timeInForce" not in call_kwargs + assert "price" not in call_kwargs + + +def test_get_candles_history_raises_for_unknown_tf_minutes(mock_client): + stock = BinanceStock("key", "secret") + with pytest.raises(ValueError, match="Unsupported tf_minutes"): + stock.get_candles_history(count=10, symbol="LINKUSDT", time_point=0, tf_minutes=30) From 7a6754b75b952f8051faaf76e4888ef878aaefd2 Mon Sep 17 00:00:00 2001 From: Oleksii Marchenko Date: Mon, 18 May 2026 23:42:39 +0100 Subject: [PATCH 11/14] 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 12/14] 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 13/14] 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 14/14] 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()