Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[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",
"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]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.mypy]
python_version = "3.11"
strict = true
5 changes: 5 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-r requirements.txt
pytest>=7.4
pytest-mock>=3.12
mypy>=1.5
black>=23.0
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pandas>=2.0
numpy>=1.24
python-binance>=1.0.19
Empty file added src/simple_trader/__init__.py
Empty file.
67 changes: 67 additions & 0 deletions src/simple_trader/data/data_point.py
Original file line number Diff line number Diff line change
@@ -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."
)
108 changes: 108 additions & 0 deletions src/simple_trader/data/indicators.py
Original file line number Diff line number Diff line change
@@ -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, index=df.index)
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, 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, 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()
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), index=df.index)
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)
30 changes: 30 additions & 0 deletions src/simple_trader/data/levels.py
Original file line number Diff line number Diff line change
@@ -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))
33 changes: 33 additions & 0 deletions src/simple_trader/data/simulation_data.py
Original file line number Diff line number Diff line change
@@ -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)]
35 changes: 35 additions & 0 deletions src/simple_trader/data/stocks/base_stock.py
Original file line number Diff line number Diff line change
@@ -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."""
65 changes: 65 additions & 0 deletions src/simple_trader/data/stocks/binance_stock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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)
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,
limit=count,
)

def trade(
self,
symbol: str,
side: str,
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(),
"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)
Loading