From 77bea7fcb9c31964d0a9d3dd84d8de749e5bbebe Mon Sep 17 00:00:00 2001 From: Ichinga Samuel Date: Thu, 16 Jan 2025 19:32:41 +0100 Subject: [PATCH] v4.0.7 --- CHANGELOG.MD | 15 +++++ README.md | 5 ++ .../backtest_data_01_01_24_06_05_24.json | 17 ++++++ .../backtest_data_01_05_24_06_05_24.json | 24 ++++---- examples/sample_backtester.py | 8 +-- examples/sample_bot.py | 1 - examples/trade_records/Chaos.csv | 4 ++ .../core/backtesting/backtest_controller.py | 2 + .../core/backtesting/backtest_engine.py | 5 ++ src/aiomql/core/backtesting/get_data.py | 1 - src/aiomql/core/config.py | 8 ++- src/aiomql/lib/backtester.py | 1 - src/aiomql/lib/bot.py | 1 - src/aiomql/lib/candle.py | 57 +++++++++++++++---- src/aiomql/lib/executor.py | 10 ++-- tests/live/unit/test_candles.py | 1 - 16 files changed, 122 insertions(+), 38 deletions(-) create mode 100644 CHANGELOG.MD create mode 100644 examples/backtesting/backtest_data_01_01_24_06_05_24.json create mode 100644 examples/trade_records/Chaos.csv diff --git a/CHANGELOG.MD b/CHANGELOG.MD new file mode 100644 index 0000000..45a0919 --- /dev/null +++ b/CHANGELOG.MD @@ -0,0 +1,15 @@ +# Changelog + +## [4.0.7](https://github.com/Ichinga-Samuel/aiomql/releases/edit/untagged-7d784e53ee13316aac97) - 2025-01-16 + +### Changed +- Candles underlying DataFrame is now indexed by datetime. +- Executor runs a strategy via the `run_strategy` method directly with `asyncio.run` without creating as a task. +- `Strategy` class now has a initialize method that is called before the strategy is run. + +### Added +- `__add__` and `__iadd__` dunder methods for addition and inplace addition of dataframes or series objects to the Candles object. +- `add` method for adding dataframes or series objects to the Candles object. + +### Fixed +- Candles timeframe attribute returns the correct TimeFrame object. diff --git a/README.md b/README.md index 5b5731c..8b4fca4 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,11 @@ see [API Documentation](docs) for more details ### Contributing Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. + +### Changelog + +See [CHANGELOG](CHANGELOG.md) for more details + ### Support Feeling generous, like the package or want to see it become a more mature package? diff --git a/examples/backtesting/backtest_data_01_01_24_06_05_24.json b/examples/backtesting/backtest_data_01_01_24_06_05_24.json new file mode 100644 index 0000000..234edaf --- /dev/null +++ b/examples/backtesting/backtest_data_01_01_24_06_05_24.json @@ -0,0 +1,17 @@ +{ + "balance": 297007.45, + "profit": 0, + "equity": 297007.45, + "margin": 0.0, + "margin_free": 297007.45, + "margin_level": 0, + "wins": 307, + "losses": 336, + "total": 643, + "win_percentage": 47.74, + "win": 619421.92, + "loss": -323164.47, + "net_profit": 296257.45, + "profit_factor": 1.92, + "profitability": 39500.99 +} \ No newline at end of file diff --git a/examples/backtesting/backtest_data_01_05_24_06_05_24.json b/examples/backtesting/backtest_data_01_05_24_06_05_24.json index d399abf..c7956f0 100644 --- a/examples/backtesting/backtest_data_01_05_24_06_05_24.json +++ b/examples/backtesting/backtest_data_01_05_24_06_05_24.json @@ -1,17 +1,17 @@ { - "balance": 504.26, + "balance": 1251.1, "profit": 0, - "equity": 504.26, + "equity": 1251.1, "margin": 0.0, - "margin_free": 504.26, + "margin_free": 1251.1, "margin_level": 0, - "wins": 12, - "losses": 15, - "total": 27, - "win_percentage": 44.44, - "win": 326.55, - "loss": -172.29, - "net_profit": 154.26, - "profit_factor": 1.9, - "profitability": 44.07 + "wins": 29, + "losses": 31, + "total": 60, + "win_percentage": 48.33, + "win": 1530.54, + "loss": -1029.44, + "net_profit": 501.1, + "profit_factor": 1.49, + "profitability": 66.81 } \ No newline at end of file diff --git a/examples/sample_backtester.py b/examples/sample_backtester.py index b204fee..f4eee11 100644 --- a/examples/sample_backtester.py +++ b/examples/sample_backtester.py @@ -14,12 +14,12 @@ def back_tester(): syms = ["Volatility 75 Index", "Volatility 100 Index", "Volatility 25 Index", "Volatility 10 Index"] symbols = [ForexSymbol(name=sym) for sym in syms] strategies = [FingerTrap(symbol=symbol) for symbol in symbols] - start = datetime(2024, 5, 1, tzinfo=UTC) - stop_time = datetime(2024, 5, 2, tzinfo=UTC) + start = datetime(2024, 1, 1, tzinfo=UTC) + stop_time = datetime(2024, 12, 2, tzinfo=UTC) end = datetime(2024, 5, 7, tzinfo=UTC) - back_test_engine = BackTestEngine(start=start, end=end, speed=7200, + back_test_engine = BackTestEngine(start=start, end=end, speed=3600, close_open_positions_on_exit=True, assign_to_config=True, preload=True, - account_info={"balance": 350}) + account_info={"balance": 750}) backtester = BackTester(backtest_engine=back_test_engine) backtester.add_strategies(strategies=strategies) backtester.execute() diff --git a/examples/sample_bot.py b/examples/sample_bot.py index 627168f..9581346 100644 --- a/examples/sample_bot.py +++ b/examples/sample_bot.py @@ -13,7 +13,6 @@ def sample_bot(): strategies = [Chaos(symbol=symbol) for symbol in symbols] bot = Bot() bot.executor.timeout = 10 - bot.add_coroutine(coroutine=sleep_run) bot.add_strategies(strategies=strategies) bot.execute() diff --git a/examples/trade_records/Chaos.csv b/examples/trade_records/Chaos.csv new file mode 100644 index 0000000..1383aec --- /dev/null +++ b/examples/trade_records/Chaos.csv @@ -0,0 +1,4 @@ +slow_ema,fast_ema,order,htf,actual_profit,symbol,date,closed,name,price,deal,ltf,bid,win,ask,lcc,volume,expected_profit,hcc +20,8,8218315320,TIMEFRAME_M2,0,Volatility 75 Index,2025-01-16 11:49:50.421946,False,Chaos,96536.19,8126025355,TIMEFRAME_M1,96536.19,False,96562.33,100,0.001,0,100 +20,8,8218315356,TIMEFRAME_M2,0,Volatility 50 Index,2025-01-16 11:49:54.394496,False,Chaos,272.9174,8126025389,TIMEFRAME_M1,272.9174,False,272.9584,100,4.0,0,100 +20,8,8218315338,TIMEFRAME_M2,0,Volatility 100 Index,2025-01-16 11:49:54.395497,False,Chaos,1939.81,8126025382,TIMEFRAME_M1,1939.27,False,1939.81,100,0.5,0,100 diff --git a/src/aiomql/core/backtesting/backtest_controller.py b/src/aiomql/core/backtesting/backtest_controller.py index 7961cee..e45cae0 100644 --- a/src/aiomql/core/backtesting/backtest_controller.py +++ b/src/aiomql/core/backtesting/backtest_controller.py @@ -95,6 +95,8 @@ class BackTestController: await self.backtest_engine.wrap_up() self.stop_backtesting() except BrokenBarrierError: + await self.backtest_engine.wrap_up() + self.stop_backtesting() return except Exception as err: diff --git a/src/aiomql/core/backtesting/backtest_engine.py b/src/aiomql/core/backtesting/backtest_engine.py index 3a1e2c1..06bb563 100644 --- a/src/aiomql/core/backtesting/backtest_engine.py +++ b/src/aiomql/core/backtesting/backtest_engine.py @@ -71,6 +71,7 @@ class BackTestEngine: preload: bool account_lock: RLock account_info: dict + checkpoint: float def __init__( self, @@ -87,6 +88,7 @@ class BackTestEngine: preload=True, assign_to_config: bool = True, account_info: dict = None, + checkpoint: float = 0.02 ): self._data = data or BackTestData() self.mt5 = MetaTrader() @@ -115,6 +117,7 @@ class BackTestEngine: self.preloaded_ticks = {} self.account_lock = RLock() self.account_info = account_info or {} + self.checkpoint = checkpoint def __next__(self) -> Cursor: try: @@ -252,6 +255,8 @@ class BackTestEngine: profit = sum(pos.profit for pos in self.positions.open_positions) self.update_account(profit=profit) self.check_account() + if int(self.cursor.index % (self.range.stop * self.checkpoint)) == 0: + await asyncio.to_thread(self.save_result_to_json) except Exception as exe: logger.critical("Error in tracker: %s at %d", exe, self.cursor.time) diff --git a/src/aiomql/core/backtesting/get_data.py b/src/aiomql/core/backtesting/get_data.py index 57f1bf7..5c68261 100644 --- a/src/aiomql/core/backtesting/get_data.py +++ b/src/aiomql/core/backtesting/get_data.py @@ -19,7 +19,6 @@ logger = getLogger(__name__) class Cursor(NamedTuple): """A cursor to iterate over the data. Marks the current position.""" - index: int time: int diff --git a/src/aiomql/core/config.py b/src/aiomql/core/config.py index 211265f..1e42dab 100644 --- a/src/aiomql/core/config.py +++ b/src/aiomql/core/config.py @@ -26,6 +26,8 @@ class Config: record_trades: bool records_dir: Path backtest_dir: Path + records_dir_name: str + backtest_dir_name: str #Todo: add to docs task_queue: TaskQueue _backtest_engine: BackTestEngine bot: Bot @@ -38,6 +40,8 @@ class Config: _defaults = { "timeout": 60000, "record_trades": True, + "records_dir_name": "trade_records", + "backtest_dir_name": "backtesting", "config_file": None, "trade_record_mode": "csv", "mode": "live", @@ -168,13 +172,13 @@ class Config: @property def records_dir(self): - rec_dir = self.root / 'trade_records' + rec_dir = self.root / self.records_dir_name or 'trade_records' rec_dir.mkdir(parents=True, exist_ok=True) if rec_dir.exists() is False else ... return rec_dir @property def backtest_dir(self) -> Path: - b_dir = self.root / 'backtesting' + b_dir = self.root / self.backtest_dir_name or 'backtesting' b_dir.mkdir(parents=True, exist_ok=True) if b_dir.exists() is False else ... return b_dir diff --git a/src/aiomql/lib/backtester.py b/src/aiomql/lib/backtester.py index ab57270..8b89cf6 100644 --- a/src/aiomql/lib/backtester.py +++ b/src/aiomql/lib/backtester.py @@ -149,7 +149,6 @@ class BackTester: strategy (Strategy): Strategy class params (dict): A dictionary of parameters for the strategy symbols (list): A list of symbols to run the strategy on - **kwargs: Additional keyword arguments for the strategy """ [self.add_strategy(strategy=strategy(symbol=symbol, params=params, **kwargs)) for symbol in symbols] diff --git a/src/aiomql/lib/bot.py b/src/aiomql/lib/bot.py index d1fbb83..52f5153 100644 --- a/src/aiomql/lib/bot.py +++ b/src/aiomql/lib/bot.py @@ -157,7 +157,6 @@ class Bot: strategy (Strategy): Strategy class params (dict): A dictionary of parameters for the strategy symbols (list): A list of symbols to run the strategy on - **kwargs: Additional keyword arguments for the strategy """ [self.add_strategy(strategy=strategy(symbol=symbol, params=params, **kwargs)) for symbol in symbols] diff --git a/src/aiomql/lib/candle.py b/src/aiomql/lib/candle.py index f6fa323..43b78a2 100644 --- a/src/aiomql/lib/candle.py +++ b/src/aiomql/lib/candle.py @@ -5,6 +5,7 @@ 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 ..core.constants import TimeFrame @@ -27,8 +28,7 @@ class Candle: spread (float): Spread Index (int): Custom attribute representing the position of the candle in a sequence. """ - - time: float + time: int open: float high: float low: float @@ -47,8 +47,8 @@ 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", time.monotonic_ns()) - self.Index = kwargs.pop("Index", 0) + self.time = kwargs.pop("time", int(time.time())) + self.Index = kwargs.pop("Index", self.time) self.real_volume = kwargs.pop("real_volume", 0) self.spread = kwargs.pop("spread", 0) self.tick_volume = kwargs.pop("tick_volume", 0) @@ -188,7 +188,9 @@ class Candles: else: raise ValueError(f"Cannot create DataFrame from object of {type(data)}") - self._data = data.loc[::-1].reset_index(drop=True) if flip else 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) self.Candle = candle_class or Candle def __repr__(self): @@ -198,13 +200,12 @@ class Candles: return len(self._data.index) def __contains__(self, item: Candle): - return item.time == self[item.Index].time + return item.time == self._data.loc[int(item.Index)].time def __getitem__(self, index: slice | int | str) -> Self | Series | Candle: if isinstance(index, slice): cls = self.__class__ data = self._data.iloc[index] - data.reset_index(drop=True, inplace=True) return cls(data=data) elif isinstance(index, str): @@ -213,8 +214,8 @@ class Candles: return self._data[index] elif isinstance(index, int): - index_ = index if index >= 0 else len(self) + index - return self.Candle(**self._data.iloc[index], Index=index_) + candle = self._data.iloc[index] + return self.Candle(**candle, Index=int(candle.time)) raise TypeError(f"Expected int, slice or str got {type(index)}") def __setitem__(self, index, value: Series): @@ -236,7 +237,7 @@ class Candles: @property def timeframe(self): - tf = self.time[1] - self.time[0] + tf = self.time.iloc[1] - self.time.iloc[0] return TimeFrame.get_timeframe(abs(tf)) @property @@ -278,3 +279,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 + + return self + + def __add__(self, row: DataFrame | Series): + """Add a new row to the candles class.""" + if isinstance(row, Series): + return pd.concat([self._data, pd.DataFrame(row).T]) + + elif isinstance(row, DataFrame): + return pd.concat([self._data, row]) + + + 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 diff --git a/src/aiomql/lib/executor.py b/src/aiomql/lib/executor.py index 7e12830..6a0fac4 100644 --- a/src/aiomql/lib/executor.py +++ b/src/aiomql/lib/executor.py @@ -60,10 +60,10 @@ class Executor: """ self.strategy_runners.append(strategy) - async def create_strategy_task(self, strategy: Strategy): - task = asyncio.create_task(strategy.run_strategy()) - self.tasks.append(task) - await task + # async def create_strategy_task(self, strategy: Strategy): + # task = asyncio.create_task(strategy.run_strategy()) + # self.tasks.append(task) + # await task def run_strategy(self, strategy: Strategy): """Wraps the coroutine trade method of each strategy with 'asyncio.run'. @@ -71,7 +71,7 @@ class Executor: Args: strategy (Strategy): A strategy object """ - asyncio.run(self.create_strategy_task(strategy)) + asyncio.run(strategy.run_strategy()) async def create_coroutine_task(self, coroutine: Coroutine): task = asyncio.create_task(coroutine) diff --git a/tests/live/unit/test_candles.py b/tests/live/unit/test_candles.py index 934068a..331526b 100644 --- a/tests/live/unit/test_candles.py +++ b/tests/live/unit/test_candles.py @@ -66,7 +66,6 @@ class TestCandles: candle = candles[10] assert isinstance(candle, Candle) assert candle in candles - assert candle.Index == 10 def test_slice(self, candles): sliced = candles[10:15]