diff --git a/src/aiomql/lib/candle.py b/src/aiomql/lib/candle.py index da3ab59..7cbdeb1 100644 --- a/src/aiomql/lib/candle.py +++ b/src/aiomql/lib/candle.py @@ -1,12 +1,12 @@ """Candle and Candles classes for handling bars from the MetaTrader 5 terminal.""" -import time +from datetime import datetime from typing import Type, Self, Iterable from logging import getLogger -from pandas import DataFrame, Series import pandas as pd import pandas_ta as ta +from pandas import DataFrame, Series, DatetimeIndex, Timestamp from ..core.constants import TimeFrame @@ -18,17 +18,18 @@ class Candle: Candlesticks. You can subclass this class for added customization. Attributes: - time (int): Period start time. - open (int): Open price + time (float): Period start time. + open (float): Open price high (float): The highest price of the period low (float): The lowest price of the period close (float): Close price tick_volume (float): Tick volume real_volume (float): Trade volume spread (float): Spread - Index (int): Custom attribute representing the position of the candle in a sequence. + index (Timestamp): Index of the object in the DataFrame, a timestamp + Index (int): Custom attribute representing the position of the candle for integer-location based indexing """ - time: int + time: float open: float high: float low: float @@ -36,6 +37,7 @@ class Candle: real_volume: float spread: float tick_volume: float + index: Timestamp Index: int def __init__(self, **kwargs): @@ -47,8 +49,9 @@ class Candle: """ if not all(i in kwargs for i in ["open", "high", "low", "close"]): raise ValueError("Candle must be instantiated with open, high, low and close prices") - self.time = kwargs.pop("time", int(time.time())) + self.time = kwargs.pop("time", Timestamp.now().timestamp()) self.Index = kwargs.pop("Index", 0) + self.index = kwargs.pop("index", Timestamp(self.time, unit="s", tz=datetime.now().astimezone().tzinfo)) self.real_volume = kwargs.pop("real_volume", 0) self.spread = kwargs.pop("spread", 0) self.tick_volume = kwargs.pop("tick_volume", 0) @@ -56,7 +59,7 @@ class Candle: def __repr__(self): return ( - "%(class)s(Index=%(Index)s, time=%(time)s, open=%(open)s, high=%(high)s, low=%(low)s, close=%(close)s)" + "%(class)s(Index=%(Index)s, time=%(time)s, open=%(open)s, high=%(high)s, low=%(low)s, close=%(close)s, index=%(index)s)" % { "class": self.__class__.__name__, "open": self.open, @@ -64,6 +67,7 @@ class Candle: "low": self.low, "close": self.close, "time": self.time, + "index": self.index.isoformat(), "Index": self.Index, } ) @@ -131,13 +135,18 @@ class Candle: keys = include or set(self.__dict__.keys()).difference(exclude) return {k: v for k, v in self if k in keys} + def to_series(self) -> Series: + """Returns a Series Object""" + return Series(self.dict(exclude={"Index", "index"})) + class Candles: """An iterable container class of Candle objects in chronological order. Attributes: - Index (Series['int']): A pandas Series of the indexes of all candles in the object. - time (Series['int']): A pandas Series of the time of all candles in the object. + index (DatetimeIndex): DatetimeIndex of the DataFrame object. + Index (Series['int']): A pandas Series of the indexes of all candles in the object: + time (Series['float']): A pandas Series of the time of all candles in the object. open (Series[float]): A pandas Series of the opening price of all candles in the object. high (Series[float]): A pandas Series of the high price of all candles in the object. low (Series[float]): A pandas Series of the low price of all candles in the object. @@ -155,7 +164,7 @@ class Candles: The candle class can be customized by subclassing the Candle class and passing the subclass as the candle keyword argument, or defining it on the class body as a class attribute. """ - + index: DatetimeIndex Index: Series time: Series open: Series @@ -189,18 +198,20 @@ class Candles: raise ValueError(f"Cannot create DataFrame from object of {type(data)}") self._data = data.loc[::-1] if flip else data - if 'time' in self._data.columns and self._data.index.name != 'time': - self._data.set_index('time', inplace=True, drop=False) + if 'time' in self._data.columns: + dtype = pd.DatetimeTZDtype(unit='s', tz=datetime.now().astimezone().tzinfo) + self._data.index = pd.DatetimeIndex(self._data.time, dtype=dtype) + self.Candle = candle_class or Candle def __repr__(self): - return self._data.__repr__() + return repr(self._data) def __len__(self): return len(self._data.index) def __contains__(self, item: Candle): - return item.time == self._data.loc[int(item.time)].time + return item.time == self[item.Index].time def __getitem__(self, index: slice | int | str) -> Self | Series | Candle: if isinstance(index, slice): @@ -208,15 +219,18 @@ class Candles: data = self._data.iloc[index] return cls(data=data) - elif isinstance(index, str): + if isinstance(index, str): + if index == "index": + return self._data.index if index == "Index": - return Series(self._data.index) + return Series(range(len(self._data))) return self._data[index] - elif isinstance(index, int): + if isinstance(index, int): candle = self._data.iloc[index] - _index = index if index >= 0 else len(self) + index - return self.Candle(**candle, Index=_index) + Index = index if index >= 0 else len(self) + index + _index = self._data.index[index] + return self.Candle(**candle, Index=Index, index=_index) raise TypeError(f"Expected int, slice or str got {type(index)}") def __setitem__(self, index, value: Series): @@ -229,13 +243,27 @@ class Candles: if item in self._data.columns: return self._data[item] + if item == "index": + return self._data.index + if item == "Index": - return Series(self._data.index) + return Series(range(len(self._data))) raise AttributeError(f"Attribute {item} not defined on class {self.__class__.__name__}") + def __reversed__(self): + for index, row in enumerate(iter(self._data[::-1].iloc)): + row = row.to_dict() + index = len(self._data) - index - 1 + row["Index"] = index + row["index"] = self._data.index[index] + yield self.Candle(**row) + def __iter__(self): - return (self.Candle(**row.to_dict(), Index=ind) for ind, row in enumerate(self._data.iloc)) - # return (self.Candle(**row._asdict()) for row in self._data.itertuples()) + for index, row in enumerate(iter(self._data.iloc)): + row = row.to_dict() + row["Index"] = index + row["index"] = self._data.index[index] + yield self.Candle(**row) @property def timeframe(self): @@ -282,39 +310,39 @@ class Candles: res = self._data.rename(columns=kwargs, inplace=inplace) return self if inplace else self.__class__(data=res) - def __iadd__(self, row: DataFrame | Series): - """Add a new row to the candles class.""" - # self._data = pd.concat([self._data, row]) - if isinstance(row, Series): - self._data.loc[int(row.time)] = row - - elif isinstance(row, DataFrame): - for r in row.iloc: - self._data.loc[int(r.time)] = r - + def __iadd__(self, other: Self) -> Self: + """Perform in place addition of candles""" + data_copy = self._data.copy() + other = other._data + for index, row in zip(other.index, iter(other.iloc)): + data_copy.loc[index] = row + self._data = data_copy.sort_index() return self - def __add__(self, row: DataFrame | Series): - """Add a new row to the candles class.""" - if isinstance(row, Series): - data = self. pd.concat([self._data, pd.DataFrame(row).T]) - return self.__class__(data=data) + def __add__(self, other: Self) -> Self: + """Add two candles object and return a new one""" + data = self._data.copy() + for index, row in zip(other._data.index, iter(other._data.iloc)): + data.loc[index] = row + return self.__class__(data=data.sort_index()) - elif isinstance(row, DataFrame): - data = pd.concat([self._data, row]) - return self.__class__(data=data) - - def add(self, row: DataFrame | Series) -> bool: - """Add a new row to the candles class.""" - new = True - if isinstance(row, Series): - if (index := int(row.time)) in self._data.index: - new = False - self._data.loc[index] = row - - elif isinstance(row, DataFrame): - for r in row.iloc: - if (index := int(r.time)) in self._data.index: - new = False - self._data.loc[index] = r - return new + def add(self, obj: DataFrame | Series | Candle) -> Self: + """Add new row(s) to the candles class.""" + if isinstance(obj, Series): + index = Timestamp(obj.time, unit="s", tz=datetime.now().astimezone().tzinfo) + self._data.loc[index] = obj + self._data = self._data.sort_index() + return self + elif isinstance(obj, DataFrame): + data = self._data.copy() + for index, row in zip(obj.index, iter(obj.iloc)): + index = index if isinstance(index, Timestamp) else Timestamp(row.time, unit="s", tz=datetime.now().astimezone().tzinfo) + data.loc[index] = row + self._data = data.sort_index() + return self + elif isinstance(obj, Candle): + self._data.loc[obj.index] = obj.to_series() + self._data = self._data.sort_index() + return self + else: + raise TypeError("Expected Series, DataFrame or Candle, got {}".format(type(obj))) diff --git a/src/aiomql/lib/symbol.py b/src/aiomql/lib/symbol.py index 2f11ef1..8387c65 100644 --- a/src/aiomql/lib/symbol.py +++ b/src/aiomql/lib/symbol.py @@ -63,7 +63,7 @@ class Symbol(_Base, SymbolInfo): if tick is not None: tick = Tick(**tick._asdict()) setattr(self, "tick", tick) if not name else ... - return tick + return tick except Exception as err: logger.warning("%s: Unable to get tick for %s", err, self.name) return None @@ -92,7 +92,7 @@ class Symbol(_Base, SymbolInfo): info = await self.mt5.symbol_info(self.name) if info is not None: info = info._asdict() - # self.set_attributes(**info) + self.set_attributes(**info) return SymbolInfo(**info) return None @@ -100,7 +100,7 @@ class Symbol(_Base, SymbolInfo): """Initialize the symbol by pulling properties from the terminal Returns: - bool: Returns True if symbol info was successful initialized + bool: Returns True if symbol info was successfully initialized """ try: select = await self.mt5.symbol_select(self.name, True) @@ -229,7 +229,7 @@ class Symbol(_Base, SymbolInfo): """ return self.volume_min - async def convert_currency(self, *, amount: float, from_currency: str, to_currency: str) -> float: + async def convert_currency(self, *, amount: float, from_currency: str, to_currency: str) -> float | None: """Convert a given amount from one currency to the other. Args: amount: Amount to convert @@ -245,10 +245,10 @@ class Symbol(_Base, SymbolInfo): pair = f"{base}{quote}" tick = await self.info_tick(name=pair) - if tick is not None: - return round(amount / tick.ask, 2) + return round(amount / tick.ask, 2) except Exception as err: logger.warning(f"{err}: Currency conversion failed: Unable to convert {amount} in {quote} to {base}") + return None @backoff_decorator async def copy_rates_from(self, *, timeframe: TimeFrame, date_from: datetime | int, count: int = 500) -> Candles: diff --git a/src/aiomql/lib/ticks.py b/src/aiomql/lib/ticks.py index 820ea80..510ab2d 100644 --- a/src/aiomql/lib/ticks.py +++ b/src/aiomql/lib/ticks.py @@ -1,8 +1,9 @@ """Module for working with price ticks.""" from typing import Iterable, Self -import time +from datetime import datetime +import pandas as pd from pandas import DataFrame, Series import pandas_ta as ta @@ -23,7 +24,6 @@ class Tick: volume_real (float): Volume for the current Last price Index (int): Custom attribute representing the position of the tick in a sequence. """ - time: float bid: float ask: float @@ -32,6 +32,7 @@ class Tick: time_msc: float flags: TickFlag volume_real: float + index: int | float Index: int def __init__(self, **kwargs): @@ -39,14 +40,15 @@ class Tick: present""" if not all(key in kwargs for key in ["bid", "ask", "last", "volume"]): raise ValueError("bid, ask, last and volume, time must be present in the keyword arguments") + self.time = kwargs.pop("time", datetime.now().timestamp()) + self.time_msc = kwargs.pop("time_msc", self.time * 1000) self.Index = kwargs.pop("Index", 0) - self.time = kwargs.pop("time", time.monotonic()) - self.time_msc = int(self.time * 1000) + self.index = kwargs.pop("index", self.time_msc) self.set_attributes(**kwargs) def __repr__(self): return ( - "%(class)s(Index=%(Index)s, time=%(time)s, bid=%(bid)s, ask=%(ask)s, last=%(last)s, volume=%(volume)s)" + "%(class)s(Index=%(Index)s, time=%(time)s, bid=%(bid)s, ask=%(ask)s, last=%(last)s, volume=%(volume)s, index=%(index)s)" % { "class": self.__class__.__name__, "time": self.time, @@ -54,18 +56,19 @@ class Tick: "ask": self.ask, "last": self.last, "volume": self.volume, + "index": self.index, "Index": self.Index, } ) def __eq__(self, other: Self): - return self.time == other.time + return self.time_msc == other.time_msc def __lt__(self, other: Self): - return self.time < other.time + return self.time_msc < other.time_msc def __hash__(self): - return hash(self.time) + return hash(self.time_msc) def __getitem__(self, item): return self.__dict__[item] @@ -102,10 +105,12 @@ class Tick: for key, value in kwargs.items(): setattr(self, key, value) + def to_series(self) -> pd.Series: + """Returns a Series Object""" + return Series(self.dict(exclude={"Index", "index"})) class Ticks: """Container class for price ticks. Arrange in chronological order. Supports iteration, slicing and assignment""" - time: Series bid: Series ask: Series @@ -115,6 +120,7 @@ class Ticks: flags: Series volume_real: Series Index: Series + index: Series def __init__(self, *, data: DataFrame | Iterable | Self, flip=False): """Initialize the Ticks class. Creates a DataFrame of price ticks from the data argument. @@ -134,8 +140,11 @@ class Ticks: raise ValueError(f"Cannot create DataFrame from object of {type(data)}") self._data = data.iloc[::-1] if flip else data + if 'time_msc' in self._data.columns: + self._data.index = self._data.time_msc + def __repr__(self): - return self._data.__repr__() + return repr(self._data) def __len__(self): return self._data.shape[0] @@ -146,20 +155,34 @@ class Ticks: def __getattr__(self, item): if item in list(self._data.columns.values): return self._data[item] + + if item == "index": + return self._data.index + + if item == "Index": + return Series(range(len(self._data))) raise AttributeError(f"Attribute {item} not defined on class {self.__class__.__name__}") def __getitem__(self, index) -> Tick | Self: if isinstance(index, slice): cls = self.__class__ data = self._data.iloc[index] - data.reset_index(drop=True, inplace=True) return cls(data=data) if isinstance(index, str): + if index == "index": + return self._data.index + if index == "Index": + return Series(range(len(self._data))) return self._data[index] - item = self._data.iloc[index] - return Tick(**item, Index=index) + if isinstance(index, int): + tick = self._data.iloc[index] + Index = index if index >= 0 else len(self) + index + _index = self._data.index[index] + return Tick(**tick, Index=Index, index=_index) + + raise TypeError(f"Expected int, slice or str got {type(index)}") def __setitem__(self, index, value: Series): if isinstance(value, Series): @@ -167,8 +190,20 @@ class Ticks: return raise TypeError(f"Expected Series got {type(value)}") + def __reversed__(self): + for index, row in enumerate(iter(self._data[::-1].iloc)): + row = row.to_dict() + index = len(self._data) - index - 1 + row["Index"] = index + row["index"] = self._data.index[index] + yield Tick(**row) + def __iter__(self): - return (Tick(**row._asdict()) for row in self._data.itertuples()) + for index, row in enumerate(iter(self._data.iloc)): + row = row.to_dict() + row["Index"] = index + row["index"] = self._data.index[index] + yield Tick(**row) @property def ta(self): @@ -206,3 +241,39 @@ class Ticks: """ res = self._data.rename(columns=kwargs, inplace=inplace) return res if inplace else self.__class__(data=res) + + def __iadd__(self, other: Self) -> Self: + """Perform in place addition of candles""" + data_copy = self._data.copy() + other = other._data + for index, row in zip(other.index, iter(other.iloc)): + data_copy.loc[index] = row + self._data = data_copy.sort_index() + return self + + def __add__(self, other: Self) -> Self: + """Add two candles object and return a new one""" + data = self._data.copy() + for index, row in zip(other._data.index, iter(other._data.iloc)): + data.loc[index] = row + return self.__class__(data=data.sort_index()) + + def add(self, obj: DataFrame | Series | Tick) -> Self: + """Add new row(s) to the candles class.""" + if isinstance(obj, Series): + self._data.loc[obj.index] = obj + self._data = self._data.sort_index() + return self + elif isinstance(obj, DataFrame): + data = self._data.copy() + for index, row in zip(obj.index, iter(obj.iloc)): + index = index + data.loc[index] = row + self._data = data.sort_index() + return self + elif isinstance(obj, Tick): + self._data.loc[obj.index] = obj.to_series() + self._data = self._data.sort_index() + return self + else: + raise TypeError("Expected Series, DataFrame or Candle, got {}".format(type(obj))) diff --git a/tests/live/unit/test_candles.py b/tests/live/unit/test_candles.py index 331526b..f44c8a0 100644 --- a/tests/live/unit/test_candles.py +++ b/tests/live/unit/test_candles.py @@ -1,8 +1,9 @@ from datetime import datetime import pytest -import pytz import pandas as pd +from pandas import Series + from aiomql.lib.candle import Candle, Candles from aiomql.core.meta_trader import MetaTrader from aiomql.core.constants import TimeFrame @@ -11,8 +12,8 @@ from aiomql.core.constants import TimeFrame class TestCandle: @classmethod def setup_class(cls): - cls.bullish_candle = Candle(open=1.3421, high=1.3462, low=1.3405, close=1.3452, time=0, Index=0) - cls.bearish_candle = Candle(open=1.3452, high=1.3405, low=1.3462, close=1.3421, time=1, Index=1) + cls.bullish_candle = Candle(open=1.3421, high=1.3462, low=1.3405, close=1.3452) + cls.bearish_candle = Candle(open=1.3452, high=1.3405, low=1.3462, close=1.3421) def test_repr(self): repr_str = repr(self.bearish_candle) @@ -48,6 +49,10 @@ class TestCandle: assert self.bearish_candle.is_bearish() assert self.bullish_candle.is_bullish() + def test_to_series(self): + ser = self.bearish_candle.to_series() + assert isinstance(ser, Series) + class TestCandles: @pytest.fixture(scope="class") @@ -57,6 +62,13 @@ class TestCandles: rates = await mt.copy_rates_from("BTCUSD", mt.TIMEFRAME_H1, start, 200) return Candles(data=rates) + @pytest.fixture(scope="class") + async def candles_2(self): + mt = MetaTrader() + start = datetime(day=5, month=10, year=2023) + rates = await mt.copy_rates_from("BTCUSD", mt.TIMEFRAME_H1, start, 300) + return Candles(data=rates) + def test_get_series(self, candles): series = candles["open"] assert isinstance(series, pd.Series) @@ -102,3 +114,35 @@ class TestCandles: assert isinstance(fas, pd.Series) candles["fas"] = fas assert "fas" in candles.data.columns + + def test_add_candles(self, candles, candles_2): + nc = candles + candles_2 + candles += candles_2 + assert len(nc) == 300 + assert len(candles) == 300 + + def test_add_candle(self, candles): + length = len(candles) + candle = candles[-1] + now = datetime.now() + candle.time = now.timestamp() + candle.index = pd.Timestamp(candle.time, unit="s", tz=now.astimezone().tzinfo) + candles.add(candle) + assert len(candles) == length + 1 + + def test_add_series(self, candles): + candle = candles[-1] + length = len(candles) + now = datetime.now() + candle.time = now.timestamp() + series = candle.to_series() + candles.add(series) + assert len(candles) == length + 1 + + def test_add_dataframe(self, candles, candles_2): + df = candles_2._data.iloc[0:3] + now = datetime.now() + df.index = pd.DatetimeIndex(df.time, tz=now.astimezone().tzinfo) + length = len(candles) + candles.add(df) + assert len(candles) == length + len(df) diff --git a/tests/live/unit/test_symbol.py b/tests/live/unit/test_symbol.py index 40a049f..23744bb 100644 --- a/tests/live/unit/test_symbol.py +++ b/tests/live/unit/test_symbol.py @@ -11,8 +11,7 @@ class TestSymbol: @pytest.fixture(scope="class", autouse=True) async def btc(self): symbol = Symbol(name="BTCUSD") - select = getattr(symbol, "select", False) - if select is False: + if symbol.initialized is False: await symbol.initialize() return symbol