diff --git a/.gitignore b/.gitignore index 416a263..2cf959f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,6 @@ nosetests.xml coverage.xml *,cover .hypothesis/ -scrap.py # Translations *.mo @@ -74,9 +73,9 @@ target/ .vscode/ # config files -config.json aiomql.json -config/ -test_data/ -test.json + +# development +terminals/ + diff --git a/src/aiomql/__init__.py b/src/aiomql/__init__.py index b45e7af..d1eded6 100644 --- a/src/aiomql/__init__.py +++ b/src/aiomql/__init__.py @@ -1,21 +1,3 @@ from .core import * -from .account import Account -from .ram import RAM -from .symbol import Symbol -from .strategy import Strategy -from .bot_builder import Bot -from .result import Result -from .records import Records -from .trade_records import TradeRecords -from .candle import Candle, Candles -from .positions import Positions -from .executor import Executor -from .order import Order -from .ticks import Tick, Ticks -from .history import History -from .trader import Trader -from .terminal import Terminal -from .sessions import Session, Sessions -from .utils import dict_to_string, round_off, backoff_decorator, error_handler, error_handler_sync, round_up, round_down from .lib import * -# from .contrib import * +from .contrib import * diff --git a/src/aiomql/utils.py b/src/aiomql/_utils.py similarity index 94% rename from src/aiomql/utils.py rename to src/aiomql/_utils.py index bddbb23..4ba1538 100644 --- a/src/aiomql/utils.py +++ b/src/aiomql/_utils.py @@ -22,7 +22,7 @@ def dict_to_string(data: dict, multi=False) -> str: return f"{sep}".join(f"{key}: {value}" for key, value in data.items()) -def backoff_decorator(func=None, *, max_retries: int = 5, retries: int = 0, error='') -> callable: +def backoff_decorator(func=None, *, max_retries: int = 3, retries: int = 0, error='') -> callable: if func is None: return partial(backoff_decorator, max_retries=max_retries, retries=retries, error=error) @@ -37,7 +37,9 @@ def backoff_decorator(func=None, *, max_retries: int = 5, retries: int = 0, erro res = await func(*args, **kwargs) if error != '' and res == error: raise TypeError('Invalid return type') - return res + else: + retries = 0 + return res except Exception as err: logger.error(f'Error in {func.__name__}: {err}') await asyncio.sleep(retries + random.randint(1, max_retries)) @@ -72,6 +74,7 @@ def error_handler_sync(func=None, *, msg='', exe=Exception, response=None): return res except exe as err: logger.error(f'Error in {func.__name__}: {msg or err}') + return response return wrapper @@ -83,6 +86,7 @@ def round_up(value: int, base: int) -> int: return value if value % base == 0 else value + base - (value % base) +# noinspection PyShadowingNames def round_off(value: float, step: float, round_down: bool = False) -> float: """Round off a number to the nearest step.""" with decimal.localcontext() as ctx: diff --git a/src/aiomql/contrib/__init__.py b/src/aiomql/contrib/__init__.py index f7744cc..020221f 100644 --- a/src/aiomql/contrib/__init__.py +++ b/src/aiomql/contrib/__init__.py @@ -1 +1,4 @@ -# from .backtester import * +from .backtesting import * +from .strategies import * +from .candle_patterns import * +from .symbols import * diff --git a/src/aiomql/contrib/backtester/__init__.py b/src/aiomql/contrib/backtester/__init__.py deleted file mode 100644 index 2a332aa..0000000 --- a/src/aiomql/contrib/backtester/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .meta_tester import MetaTester -from .backtest_engine import BackTestEngine -from .get_data import GetData, TestData -from .strategy_tester import StrategyTester -from .event_manager import EventManager -from .backtester import BackTester -from .test_account import TestAccount -from .trades_manager import PositionsManager, OrdersManager, DealsManager diff --git a/src/aiomql/contrib/backtester/strategy_tester.py b/src/aiomql/contrib/backtester/strategy_tester.py deleted file mode 100644 index b2956bd..0000000 --- a/src/aiomql/contrib/backtester/strategy_tester.py +++ /dev/null @@ -1,27 +0,0 @@ -from .event_manager import EventManager -from ...core.config import Config - - -class StrategyTester: - event_manager: EventManager - config: Config - - def set_up(self): - self.event_manager = EventManager() - - async def sleep(self, secs: float): - time = self.config.backtest_engine.cursor.time - mod = time % secs - secs = secs - mod if mod != 0 else mod - if self.event_manager.num_main_tasks == 1: - self.config.backtest_engine.fast_forward(secs) - await self.event_manager.wait() - elif self.event_manager.num_main_tasks > 1: - time = self.config.backtest_engine.cursor.time + secs - while time > self.config.backtest_engine.cursor.time: - await self.event_manager.wait() - else: - ... - - def test(self): - raise NotImplementedError("Implement this method in your subclass") diff --git a/src/aiomql/contrib/backtesting/__init__.py b/src/aiomql/contrib/backtesting/__init__.py new file mode 100644 index 0000000..c57b924 --- /dev/null +++ b/src/aiomql/contrib/backtesting/__init__.py @@ -0,0 +1,4 @@ +from .get_data import GetData, TestData +from .backtest_engine import BackTestEngine +from .backtest_account import BackTestAccount +from .trades_manager import PositionsManager, OrdersManager, DealsManager diff --git a/src/aiomql/contrib/backtester/test_account.py b/src/aiomql/contrib/backtesting/backtest_account.py similarity index 98% rename from src/aiomql/contrib/backtester/test_account.py rename to src/aiomql/contrib/backtesting/backtest_account.py index cb0be52..5560e68 100644 --- a/src/aiomql/contrib/backtester/test_account.py +++ b/src/aiomql/contrib/backtesting/backtest_account.py @@ -5,7 +5,7 @@ from ...core.constants import AccountTradeMode, AccountMarginMode, AccountStopOu @dataclass -class TestAccount: +class BackTestAccount: login: int = 0 trade_mode: AccountTradeMode = AccountTradeMode.DEMO leverage: float = 0 diff --git a/src/aiomql/contrib/backtester/backtest_engine.py b/src/aiomql/contrib/backtesting/backtest_engine.py similarity index 97% rename from src/aiomql/contrib/backtester/backtest_engine.py rename to src/aiomql/contrib/backtesting/backtest_engine.py index affc1b1..b088041 100644 --- a/src/aiomql/contrib/backtester/backtest_engine.py +++ b/src/aiomql/contrib/backtesting/backtest_engine.py @@ -15,20 +15,19 @@ from MetaTrader5 import (Tick, SymbolInfo, AccountInfo, TradeOrder, TradePositio from ...core.meta_trader import MetaTrader from ...core.constants import (TimeFrame, OrderType, TradeAction, AccountStopOutMode, PositionReason, - DealType, DealReason, DealEntry, OrderReason, CopyTicks) -from ...core.config import Config -from ...utils import round_down, round_up, error_handler, error_handler_sync, async_cache + DealType, DealReason, DealEntry, OrderReason, CopyTicks) + +from ..._utils import round_down, round_up, error_handler, error_handler_sync, async_cache from .get_data import TestData, GetData, Cursor -from .test_account import TestAccount +from .backtest_account import BackTestAccount from .trades_manager import PositionsManager, OrdersManager, DealsManager -tz = pytz.timezone('Etc/UTC') logger = getLogger(__name__) class BackTestEngine: - mt5: MetaTrader = MetaTrader() + mt5: MetaTrader span: range range: range cursor: Cursor @@ -39,15 +38,19 @@ class BackTestEngine: orders: OrdersManager deals: DealsManager positions: PositionsManager - _account: TestAccount + _account: BackTestAccount + def __init__(self, *, data: TestData = None, speed: int = 1, start: float | datetime = 0, end: float | datetime = 0, restart: bool = False, name: str = ''): self._data = data or TestData() - self.config = Config(backtest_engine=self) + self.mt5 = MetaTrader() + self.config = self.mt5.config + self.config.backtest_engine = self self.set_up(start=start, end=end, speed=speed, restart=restart) self.prepare_data() - _name = f"{datetime.fromtimestamp(self.span[0]):%d-%m-%y}_{datetime.fromtimestamp(self.span[-1]):%d-%m-%y}" + start, end = (self.span[0], self.span[-1]) if self.span else ((now := datetime.now(pytz.UTC).timestamp()), now) + _name = f"{datetime.fromtimestamp(start):%d-%m-%y}_{datetime.fromtimestamp(end):%d-%m-%y}" self.name = name or _name def __next__(self) -> Cursor: @@ -91,7 +94,7 @@ class BackTestEngine: deals[ticket] = TradeDeal((deal.get(k) for k in TradeDeal.__match_args__)) self.deals = DealsManager(data=deals) - self._account: TestAccount = TestAccount(**self._data.account) + self._account: BackTestAccount = BackTestAccount(**self._data.account) def next(self) -> Cursor: return next(self) @@ -99,7 +102,7 @@ class BackTestEngine: @property def data(self): return self._data - + def reset(self): self.iter = zip_longest(self.range, self.span) self.cursor = Cursor(index=self.range.start, time=self.span.start) @@ -107,7 +110,7 @@ class BackTestEngine: def go_to(self, *, time: datetime | float): time = int(time.timestamp()) if isinstance(time, datetime) else int(time) steps = time - self.cursor.time - + if steps > 0: self.fast_forward(steps=steps) return @@ -451,11 +454,11 @@ class BackTestEngine: current_price = price if tp and sl: if action == TradeAction.SLTP: - pos = self.positions.get(request.get('position')) + pos = self.positions.get(request.get('position')) sym = await self.get_symbol_info(pos.symbol) current_tick = sym or await self.get_price_tick(pos.symbol, self.cursor.time) current_price = current_tick.bid if pos.type == OrderType.BUY else current_tick.ask - + min_sl = min(sl, tp) dsl = abs(current_price - min_sl) / sym.point tsl = sym.trade_stops_level + sym.spread @@ -549,7 +552,7 @@ class BackTestEngine: @error_handler async def get_rates_from_pos(self, symbol: str, timeframe: TimeFrame, start_pos: int, count: int) -> np.ndarray: if self.config.use_terminal_for_backtesting: - now = datetime.now(tz=tz) + now = datetime.now(tz=pytz.UTC) b_now = self.cursor.time diff = (now.timestamp() - b_now) // timeframe.time start_pos = int(diff + start_pos) diff --git a/src/aiomql/contrib/backtester/get_data.py b/src/aiomql/contrib/backtesting/get_data.py similarity index 99% rename from src/aiomql/contrib/backtester/get_data.py rename to src/aiomql/contrib/backtesting/get_data.py index 993545d..856adcc 100644 --- a/src/aiomql/contrib/backtester/get_data.py +++ b/src/aiomql/contrib/backtesting/get_data.py @@ -13,7 +13,7 @@ from ...core.meta_trader import MetaTrader from ...core.config import Config from ...core.constants import TimeFrame from ...core.task_queue import TaskQueue, QueueItem -from ...utils import backoff_decorator +from ..._utils import backoff_decorator logger = getLogger(__name__) diff --git a/src/aiomql/contrib/backtester/trades_manager.py b/src/aiomql/contrib/backtesting/trades_manager.py similarity index 99% rename from src/aiomql/contrib/backtester/trades_manager.py rename to src/aiomql/contrib/backtesting/trades_manager.py index 5260257..a9b210d 100644 --- a/src/aiomql/contrib/backtester/trades_manager.py +++ b/src/aiomql/contrib/backtesting/trades_manager.py @@ -1,8 +1,10 @@ from datetime import datetime from typing import TypeVar, Generic +from logging import getLogger + from MetaTrader5 import TradePosition, TradeOrder, TradeDeal -from aiomql.utils import logger +logger = getLogger(__name__) TradeData = TypeVar('TradeData', bound=TradePosition | TradeOrder | TradeDeal) diff --git a/src/aiomql/lib/candle_patterns/__init__.py b/src/aiomql/contrib/candle_patterns/__init__.py similarity index 100% rename from src/aiomql/lib/candle_patterns/__init__.py rename to src/aiomql/contrib/candle_patterns/__init__.py diff --git a/src/aiomql/lib/candle_patterns/fractals.py b/src/aiomql/contrib/candle_patterns/fractals.py similarity index 92% rename from src/aiomql/lib/candle_patterns/fractals.py rename to src/aiomql/contrib/candle_patterns/fractals.py index fbd001a..013c3d4 100644 --- a/src/aiomql/lib/candle_patterns/fractals.py +++ b/src/aiomql/contrib/candle_patterns/fractals.py @@ -1,4 +1,4 @@ -from ...candle import Candle, Candles +from ...lib.candle import Candle, Candles def find_bearish_fractal(candles: Candles) -> Candle | None: diff --git a/src/aiomql/contrib/strategies/__init__.py b/src/aiomql/contrib/strategies/__init__.py new file mode 100644 index 0000000..625296d --- /dev/null +++ b/src/aiomql/contrib/strategies/__init__.py @@ -0,0 +1,2 @@ +from .finger_trap import FingerTrap +from .tracker import Tracker diff --git a/src/aiomql/lib/strategies/finger_trap.py b/src/aiomql/contrib/strategies/finger_trap.py similarity index 95% rename from src/aiomql/lib/strategies/finger_trap.py rename to src/aiomql/contrib/strategies/finger_trap.py index 0837ea2..dd92e2f 100644 --- a/src/aiomql/lib/strategies/finger_trap.py +++ b/src/aiomql/contrib/strategies/finger_trap.py @@ -1,15 +1,15 @@ import asyncio import logging -from .tracker import Tracker -from ..traders import SimpleTrader -from ...symbol import Symbol -from ...trader import Trader -from ...candle import Candles -from ...strategy import Strategy -from ...core import TimeFrame, OrderType -from ...sessions import Sessions +from ...lib.symbol import Symbol +from ...lib.trader import Trader +from ...lib.candle import Candles +from ...lib.strategy import Strategy +from ...core.constants import TimeFrame, OrderType +from ...lib.sessions import Sessions from ..candle_patterns import find_bearish_fractal, find_bullish_fractal +from ..traders import SimpleTrader +from .tracker import Tracker logger = logging.getLogger(__name__) diff --git a/src/aiomql/lib/strategies/tracker.py b/src/aiomql/contrib/strategies/tracker.py similarity index 100% rename from src/aiomql/lib/strategies/tracker.py rename to src/aiomql/contrib/strategies/tracker.py diff --git a/src/aiomql/lib/symbols/__init__.py b/src/aiomql/contrib/symbols/__init__.py similarity index 100% rename from src/aiomql/lib/symbols/__init__.py rename to src/aiomql/contrib/symbols/__init__.py diff --git a/src/aiomql/lib/symbols/forex_symbol.py b/src/aiomql/contrib/symbols/forex_symbol.py similarity index 93% rename from src/aiomql/lib/symbols/forex_symbol.py rename to src/aiomql/contrib/symbols/forex_symbol.py index cfb0fcf..fdaf2bd 100644 --- a/src/aiomql/lib/symbols/forex_symbol.py +++ b/src/aiomql/contrib/symbols/forex_symbol.py @@ -1,4 +1,4 @@ -from ...symbol import Symbol +from ...lib.symbol import Symbol from ...core.exceptions import VolumeError @@ -6,6 +6,16 @@ class ForexSymbol(Symbol): """Subclass of Symbol for Forex Symbols. Handles the conversion of currency and the computation of stop loss, take profit and volume. """ + + @property + def pip(self): + """Returns the pip value of the symbol. This is ten times the point value for forex symbols. + + Returns: + float: The pip value of the symbol. + """ + return self.point * 10 + def compute_points(self, *, amount: float, volume) -> float: """Compute the number of points required for a trade. Given the amount and the volume of the trade. Args: diff --git a/src/aiomql/lib/traders/__init__.py b/src/aiomql/contrib/traders/__init__.py similarity index 100% rename from src/aiomql/lib/traders/__init__.py rename to src/aiomql/contrib/traders/__init__.py diff --git a/src/aiomql/lib/traders/simple_trader.py b/src/aiomql/contrib/traders/simple_trader.py similarity index 96% rename from src/aiomql/lib/traders/simple_trader.py rename to src/aiomql/contrib/traders/simple_trader.py index 860f95e..2691918 100644 --- a/src/aiomql/lib/traders/simple_trader.py +++ b/src/aiomql/contrib/traders/simple_trader.py @@ -1,9 +1,9 @@ from logging import getLogger -from ..symbols import ForexSymbol -from ...ram import RAM +from ...lib.ram import RAM from ...core.models import OrderType -from ...trader import Trader +from ...lib.trader import Trader +from ..symbols import ForexSymbol logger = getLogger(__name__) diff --git a/src/aiomql/core/__init__.py b/src/aiomql/core/__init__.py index 33ebb21..7ba1d44 100644 --- a/src/aiomql/core/__init__.py +++ b/src/aiomql/core/__init__.py @@ -2,7 +2,7 @@ from .meta_trader import MetaTrader from .config import Config from .models import * from .constants import * -from .base import Base +from .base import Base, _Base from .errors import Error from .exceptions import * from .task_queue import TaskQueue diff --git a/src/aiomql/core/base.py b/src/aiomql/core/base.py index ef5bf05..0db9001 100644 --- a/src/aiomql/core/base.py +++ b/src/aiomql/core/base.py @@ -4,7 +4,7 @@ from logging import getLogger from .config import Config from .meta_trader import MetaTrader -from ..contrib.backtester import MetaTester +from .meta_backtester import MetaBackTester logger = getLogger(__name__) @@ -13,18 +13,17 @@ class Base: """A base class for all data structure classes in the aiomql package. This class provides a set of common methods and attributes for handling data. """ - mt5: MetaTrader - config: Config + exclude: set + include: set def __init__(self, **kwargs): """ Initialize a new instance of the Base class + Args: **kwargs: Set instance attributes with keyword arguments. Only if they are annotated on the class body. """ - self.config = Config() - self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester() - self.exclude = {'mt5', "config", 'exclude', 'include', 'annotations', 'class_vars', 'dict'} + self.exclude = {'mt5', "config", 'exclude', 'include', 'annotations', 'class_vars', 'dict', '_instance'} self.include = set() self.set_attributes(**kwargs) @@ -37,7 +36,7 @@ class Base: def set_attributes(self, **kwargs): """Set keyword arguments as object attributes - + Keyword Args: **kwargs: Object attributes and values as keyword arguments @@ -51,15 +50,15 @@ class Base: try: setattr(self, i, self.annotations[i](j)) except KeyError: - logger.warning(f"Attribute {i} does not belong to class {self.__class__.__name__}") + logger.debug(f"Attribute {i} does not belong to class {self.__class__.__name__}") continue - except ValueError: - logger.warning(f'Cannot covert object of type {type(j)} to type {self.annotations[i]}') - continue + except (ValueError, TypeError): + logger.debug(f'Cannot covert object of type {type(j)} to type {self.annotations[i]}') + setattr(self, i, j) except Exception as exe: - logger.warning(f'Did not set attribute {i} on class {self.__class__.__name__} due to {exe}') + logger.debug(f'Did not set attribute {i} on class {self.__class__.__name__} due to {exe}') continue @property @@ -71,7 +70,7 @@ class Base: dict: A dictionary of class annotations """ annots = {} - for base in self.__class__.__mro__[-3::-1]: + for base in self.__class__.__mro__[::-1]: annots |= getattr(base, '__annotations__', {}) return annots @@ -100,7 +99,7 @@ class Base: Returns: dict: A dictionary of available class attributes in all ancestor classes and the current class. """ - clss = self.__class__.__mro__[-3::-1] + clss = self.__class__.__mro__[::-1] cls_dict = {} for cls in clss: cls_dict |= cls.__dict__ @@ -119,3 +118,10 @@ class Base: key not in _filter} except Exception as err: logger.warning(err) + + +class _Base(Base): + def __init__(self, **kwargs): + self.config = Config() + self.mt5 = MetaTrader() if self.config.mode != 'backtest' else MetaBackTester() + super().__init__(**kwargs) diff --git a/src/aiomql/core/config.py b/src/aiomql/core/config.py index f159244..2bf1e6e 100644 --- a/src/aiomql/core/config.py +++ b/src/aiomql/core/config.py @@ -1,3 +1,4 @@ +import inspect import os from pathlib import Path from typing import Iterator, Literal, TypeVar, Self @@ -7,10 +8,15 @@ from logging import getLogger from .task_queue import TaskQueue logger = getLogger(__name__) - Bot = TypeVar("Bot") BackTestEngine = TypeVar("BackTestEngine") +def func(): + stack = inspect.stack() + calling_context = next(context for context in stack if context.filename != __file__) + print(calling_context.filename) + return calling_context.filename + class Config: """A class for handling configuration settings for the aiomql package. @@ -26,7 +32,7 @@ class Config: path (str): Path to terminal file timeout (int): Timeout for terminal connection state (dict): A global state dictionary for storing data across the framework - root (str): Root directory of the project + root (Path): Root directory of the project Notes: By default, the config class looks for a file named aiomql.json. @@ -80,11 +86,13 @@ class Config: self._backtest_engine = value def set_attributes(self, **kwargs): - """Set keyword arguments as object attributes + """Set keyword arguments as object attributes, The root folder attribute can't be set here. Keyword Args: **kwargs: Object attributes and values as keyword arguments """ + if kwargs.pop('root', None) is not None: + logger.warning('Tried setting root from set_attributes. Use load_config to change project root') [setattr(self, key, value) for key, value in kwargs.items()] @staticmethod @@ -113,7 +121,7 @@ class Config: logger.debug(f"Error finding config file: {err}") return - def load_config(self, *, file: str | Path = None, filename: str = None, root: str | Path = None, **kwargs): + def load_config(self, *, file: str | Path = None, filename: str = None, root: str | Path = None, **kwargs) -> Self: """Load configuration settings from a file. Keyword Args: @@ -128,7 +136,6 @@ class Config: self.root = root else: self.root = self.root if hasattr(self, 'root') else Path.cwd() - if file is not None: file = Path(file).resolve() if not file.exists(): @@ -151,6 +158,9 @@ class Config: data = file_config | kwargs self.set_attributes(**data) + if self.path: + self.path = self.root / self.path if not Path(self.path).resolve().exists() else self.path + if self.record_trades and not hasattr(self, "records_dir"): self.records_dir = self.root / self.records_dir_name self.records_dir.mkdir(parents=True, exist_ok=True) @@ -159,6 +169,8 @@ class Config: self.backtest_dir = self.root / self.backtest_dir_name self.backtest_dir.mkdir(parents=True, exist_ok=True) + return self + def account_info(self) -> dict[str, int | str]: """Returns Account login details as found in the config object if available diff --git a/src/aiomql/contrib/backtester/event_manager.py b/src/aiomql/core/event_manager.py similarity index 92% rename from src/aiomql/contrib/backtester/event_manager.py rename to src/aiomql/core/event_manager.py index 814ca2f..a3c9b53 100644 --- a/src/aiomql/contrib/backtester/event_manager.py +++ b/src/aiomql/core/event_manager.py @@ -2,7 +2,7 @@ import asyncio from asyncio import Condition, Task from typing import Self from datetime import datetime -from ...core import Config +from .config import Config class EventManager: @@ -42,7 +42,7 @@ class EventManager: async def event_monitor(self): while True: async with self.condition: - if self.task_tracker == self.num_main_tasks: + if self.task_tracker == self.num_main_tasks: # all main tasks have been completed in the current cycle self.task_tracker = 0 await self.config.backtest_engine.tracker() self.config.backtest_engine.next() diff --git a/src/aiomql/contrib/backtester/meta_tester.py b/src/aiomql/core/meta_backtester.py similarity index 97% rename from src/aiomql/contrib/backtester/meta_tester.py rename to src/aiomql/core/meta_backtester.py index 2c10e25..e2b13b5 100644 --- a/src/aiomql/contrib/backtester/meta_tester.py +++ b/src/aiomql/core/meta_backtester.py @@ -1,21 +1,20 @@ from datetime import datetime from logging import getLogger -from typing import Literal +from typing import Literal, TypeVar from numpy import ndarray from MetaTrader5 import (Tick, SymbolInfo, AccountInfo, TerminalInfo, TradeOrder, TradePosition, TradeDeal, OrderCheckResult, OrderSendResult) -from . import BackTestEngine - -from ...core.meta_trader import MetaTrader -from ...core.constants import TimeFrame, CopyTicks, OrderType -from ...utils import error_handler +from .meta_trader import MetaTrader +from .constants import TimeFrame, CopyTicks, OrderType +from .._utils import error_handler logger = getLogger(__name__) +BackTestEngine = TypeVar('BackTestEngine') -class MetaTester(MetaTrader): +class MetaBackTester(MetaTrader): """A class for testing trading strategies in the MetaTrader 5 terminal. A subclass of MetaTrader.""" backtest_engine: BackTestEngine @@ -29,7 +28,7 @@ class MetaTester(MetaTrader): @backtest_engine.setter def backtest_engine(self, value: BackTestEngine): - if isinstance(value, BackTestEngine): + if BackTestEngine is not None: self.config.backtest_engine = value async def last_error(self) -> tuple[int, str]: diff --git a/src/aiomql/core/meta_trader.py b/src/aiomql/core/meta_trader.py index a17b261..32d6b3d 100644 --- a/src/aiomql/core/meta_trader.py +++ b/src/aiomql/core/meta_trader.py @@ -1,5 +1,5 @@ -from datetime import datetime import asyncio +from datetime import datetime from logging import getLogger from typing import Literal @@ -17,6 +17,7 @@ logger = getLogger() class MetaTrader(MetaCore): + def __init__(self): self.config = Config() self.error: Error = Error(1) @@ -29,7 +30,8 @@ class MetaTrader(MetaCore): Returns: MetaTrader: An instance of the MetaTrader class. """ - await self.initialize(**Config().account_info()) + await self.initialize() + await self.login() return self async def __aexit__(self, exc_type, exc_val, exc_tb): @@ -50,7 +52,7 @@ class MetaTrader(MetaCore): self.error = Error(*err) if self.error.is_connection_error(): - await self.initialize(path=self.config.path) + await self.initialize() await self.login() res = await asyncio.to_thread(func, *args, **kwargs) @@ -81,7 +83,7 @@ class MetaTrader(MetaCore): server = server or acc_details.get('server', '') return await asyncio.to_thread(self._login, login, password=password, server=server, timeout=timeout) - async def initialize(self, path: str = "", login: int = 0, password: str = "", server: str = "", + async def initialize(self, path: str = None, login: int = 0, password: str = "", server: str = "", timeout: int | None = None, portable=False) -> bool: """ Initializes the connection to the MetaTrader terminal. All parameters are optional. @@ -97,16 +99,23 @@ class MetaTrader(MetaCore): Returns: bool: True if successful, False otherwise. """ - path = path or self.config.path - args = (str(path),) if path else () - acc = self.config.account_info() - kwargs = {key: value for key, value in (('login', login or acc.get('login')), - ('password', password or acc.get('password')), - ('server', server or acc.get('server')), - ('timeout', timeout or 60000), - ('portable', portable)) if key is not None} - res = await asyncio.to_thread(self._initialize, *args, **kwargs) - return res + async with asyncio.Lock() as _: + path = self.config.path if path is None else path + args = (str(path),) if path else () + acc = self.config.account_info() + kwargs = {key: value for key, value in (('login', login or acc.get('login')), + ('password', password or acc.get('password')), + ('server', server or acc.get('server')), + ('timeout', timeout or 60000), + ('portable', portable)) if key is not None} + res = await asyncio.to_thread(self._initialize, *args, **kwargs) + if res is False: + await self.shutdown() + res = await asyncio.to_thread(self._initialize, *args, **kwargs) + if not res: + err = await self.last_error() + self.error = Error(*err) + return res async def shutdown(self) -> None: """ @@ -228,7 +237,8 @@ class MetaTrader(MetaCore): res = await self._handler(api) return res - async def order_calc_margin(self, action: OrderType, symbol: str, volume: float, price: float) -> float | None: + async def order_calc_margin(self, action: Literal[OrderType.BUY, OrderType.SELL], + symbol: str, volume: float, price: float) -> float | None: api = {'func': self._order_calc_margin, 'args': (action, symbol, volume, price), 'error_msg': 'Error in calculating margin.'} res = await self._handler(api) diff --git a/src/aiomql/core/models.py b/src/aiomql/core/models.py index 35a4546..c51936e 100644 --- a/src/aiomql/core/models.py +++ b/src/aiomql/core/models.py @@ -327,13 +327,9 @@ class SymbolInfo(Base): exchange: str formula: str isin: str - name: str page: str path: str - - def __init__(self, **kwargs): - assert 'name' in kwargs, "Symbol Object Must be initialized with a name" - super().__init__(**kwargs) + name: str = '' def __repr__(self): return '%(class)s(name=%(name)s)' % {'class': self.__class__.__name__, 'name': self.name} @@ -346,6 +342,7 @@ class SymbolInfo(Base): def __hash__(self): return hash(self.name) + # return hash(id(self)) class BookInfo(Base): diff --git a/src/aiomql/core/task_queue.py b/src/aiomql/core/task_queue.py index ce8e8e1..bd02f85 100644 --- a/src/aiomql/core/task_queue.py +++ b/src/aiomql/core/task_queue.py @@ -54,34 +54,35 @@ class TaskQueue: item = (priority, item) self.priority_tasks.add(item) if item.must_complete else ... self.queue.put_nowait(item) - except asyncio.QueueFull: - logger.error(f"Queue is full") + logger.error("Queue is full") async def worker(self): while True: - if isinstance(self.queue, asyncio.PriorityQueue): - _, item = await self.queue.get() + try: + if isinstance(self.queue, asyncio.PriorityQueue): + _, item = self.queue.get_nowait() - else: - item = await self.queue.get() - - if not self.stop or item.must_complete: - await item.run() + else: + item = self.queue.get_nowait() - self.queue.task_done() - self.priority_tasks.discard(item) + if not self.stop or item.must_complete: + await item.run() - if self.stop and len(self.priority_tasks) == 0: - print('All priority tasks completed') - self.cancel() - break + self.queue.task_done() + self.priority_tasks.discard(item) + if self.stop and len(self.priority_tasks) == 0: + logger.info('All priority tasks completed') + self.cancel() + break + except Exception as err: + logger.error(f"Error {err} occurred in worker") def sigint_handle(self, sig, frame): - print('SIGINT received, cleaning up...') + logger.info('SIGINT received, cleaning up...') if self.on_exit == 'complete_priority' and self.priority_tasks: - print(f'Completing {len(self.priority_tasks)} priority tasks...') + logger.info(f'Completing {len(self.priority_tasks)} priority tasks...') self.stop = True else: self.cancel() @@ -100,26 +101,24 @@ class TaskQueue: await asyncio.wait_for(task, timeout = timeout or self.timeout) except TimeoutError: - print(f"Timed out after {loop.time() - start} seconds. {self.queue.qsize()} tasks remaining") + logger.warning(f"Timed out after {loop.time() - start} seconds. {self.queue.qsize()} tasks remaining") if self.on_exit == 'complete_priority' and self.priority_tasks: - print(f'Completing {len(self.priority_tasks)} priority tasks...') + logger.info(f'Completing {len(self.priority_tasks)} priority tasks...') self.stop = True await self.queue.join() - else: self.cancel() except asyncio.CancelledError: - print('Tasks cancelled') + logger.debug('All tasks cancelled') finally: - print(f'Exiting queue after {(loop.time() - start)} seconds.' + logger.info(f'Exiting queue after {(loop.time() - start)} seconds.' f'{self.queue.qsize()} tasks remaining, {len(self.priority_tasks)} are priority tasks') self.cancel() def cancel(self): - cancelled = [task.cancel() for task in self.tasks if not task.done()] - print(f'Cancelled {len(cancelled)} worker tasks') if cancelled else ... + [task.cancel() for task in self.tasks if not task.done()] self.tasks.clear() diff --git a/src/aiomql/lib/__init__.py b/src/aiomql/lib/__init__.py index 34162fa..10aa7d3 100644 --- a/src/aiomql/lib/__init__.py +++ b/src/aiomql/lib/__init__.py @@ -1,4 +1,17 @@ -from .strategies import * -from .traders import * -from .symbols import * -from .candle_patterns import * +from .account import Account +from .backtest_runner import BackTestRunner +from .bot_factory import Bot +from .candle import Candle, Candles +from .executor import Executor +from .history import History +from .order import Order +from .positions import Positions +from .ram import RAM +from .result import Result +from .symbol import Symbol +from .ticks import Tick, Ticks +from .trader import Trader +from .strategy import Strategy +from .sessions import Sessions, Session +from .trade_records import TradeRecords +from .terminal import Terminal diff --git a/src/aiomql/account.py b/src/aiomql/lib/account.py similarity index 85% rename from src/aiomql/account.py rename to src/aiomql/lib/account.py index a76a263..bd619df 100644 --- a/src/aiomql/account.py +++ b/src/aiomql/lib/account.py @@ -1,13 +1,14 @@ from logging import getLogger from typing import Self -from .core.models import AccountInfo -from .core.exceptions import LoginError +from ..core.base import _Base +from ..core.models import AccountInfo +from ..core.exceptions import LoginError logger = getLogger(__name__) -class Account(AccountInfo): +class Account(_Base, AccountInfo): """A class for managing a trading account. A singleton class. A subclass of AccountInfo. All AccountInfo attributes are available in this class. @@ -23,15 +24,9 @@ class Account(AccountInfo): def __new__(cls, *args, **kwargs): if not hasattr(cls, '_instance'): cls._instance = super().__new__(cls) - cls._instance.exclude = cls._instance.exclude | {'_instance'} + cls._instance.connected = False return cls._instance - async def refresh(self): - """Refreshes the account instance with the latest account details from the MetaTrader 5 terminal""" - account_info = await self.mt5.account_info() - acc = account_info._asdict() - self.set_attributes(**acc) - async def __aenter__(self) -> Self: """Connect to a trading account and return the account instance. Async context manager for the Account class. @@ -42,6 +37,7 @@ class Account(AccountInfo): Raises: LoginError: If login fails """ + await self.mt5.initialize() self.connected = await self.mt5.login() if not self: raise LoginError('Login failed') @@ -50,3 +46,10 @@ class Account(AccountInfo): async def __aexit__(self, exc_type, exc_val, exc_tb): await self.mt5.shutdown() self.connected = False + + async def refresh(self): + """Refreshes the account instance with the latest account details from the MetaTrader 5 terminal""" + account_info = await self.mt5.account_info() + acc = account_info._asdict() + self.connected = True + self.set_attributes(**acc) diff --git a/src/aiomql/contrib/backtester/backtester.py b/src/aiomql/lib/backtest_runner.py similarity index 61% rename from src/aiomql/contrib/backtester/backtester.py rename to src/aiomql/lib/backtest_runner.py index c16efc3..b0fbecf 100644 --- a/src/aiomql/contrib/backtester/backtester.py +++ b/src/aiomql/lib/backtest_runner.py @@ -2,27 +2,28 @@ import asyncio import signal from logging import getLogger -from .event_manager import EventManager -from .meta_tester import MetaTester -from .backtest_engine import BackTestEngine -from .strategy_tester import StrategyTester +from ..core.event_manager import EventManager +from ..contrib.backtesting.backtest_engine import BackTestEngine +from .strategy import Strategy +from ..core.meta_backtester import MetaBackTester logger = getLogger(__name__) -class BackTester: - def __init__(self, *, strategies: list[StrategyTester] = None, backtest_engine: BackTestEngine = None): +class BackTestRunner: + def __init__(self, *, strategies: list[Strategy] = None, backtest_engine: BackTestEngine = None): self.strategies = strategies or [] self.event_manager = EventManager() - self.mt5 = MetaTester(backtest_engine=backtest_engine) + self.mt5 = MetaBackTester(backtest_engine=backtest_engine) signal.signal(signal.SIGINT, self.event_manager.sigint_handler) async def run(self): try: await self.mt5.initialize() + await self.mt5.login() strategies = [strategy for strategy in self.strategies if await strategy.symbol.init()] self.event_manager.num_main_tasks = len(strategies) - tasks = [*[asyncio.create_task(strategy.test()) for strategy in strategies], + tasks = [*[asyncio.create_task(strategy.run_strategy()) for strategy in strategies], asyncio.create_task(self.event_manager.event_monitor())] self.event_manager.add_tasks(*tasks) await asyncio.gather(*tasks, return_exceptions=True) if strategies else ... diff --git a/src/aiomql/bot_builder.py b/src/aiomql/lib/bot_factory.py similarity index 76% rename from src/aiomql/bot_builder.py rename to src/aiomql/lib/bot_factory.py index d17ab36..b409218 100644 --- a/src/aiomql/bot_builder.py +++ b/src/aiomql/lib/bot_factory.py @@ -4,8 +4,8 @@ from typing import Type, Iterable, Callable, Coroutine import logging from .executor import Executor -from .core.config import Config -from .core.meta_trader import MetaTrader +from ..core.config import Config +from ..core.meta_trader import MetaTrader from .symbol import Symbol as Symbol from .strategy import Strategy as Strategy @@ -51,37 +51,38 @@ class Bot: SystemExit if sign in was not successful """ try: + await self.mt.initialize() login = await self.mt.login() if not login: - logger.warning(f"Unable to sign in to MetaTrder 5 Terminal") + logger.critical(f"Unable to sign in to MetaTrder 5 Terminal") raise SystemExit logger.info("Login Successful") await self.init_strategies() - self.add_coroutine(self.config.task_queue.start) + self.add_coroutine(coroutine=self.config.task_queue.run) except Exception as err: logger.error(f"{err}. Bot initialization failed") raise SystemExit - def add_function(self, func: Callable[..., ...], **kwargs: dict): + def add_function(self, *, function: Callable[..., ...], **kwargs: dict): """Add a function to the executor. Args: - func (Callable): A function to be executed + function (Callable): A function to be executed **kwargs (dict): Keyword arguments for the function """ - self.executor.add_function(func, kwargs) + self.executor.add_function(function=function, kwargs=kwargs) - def add_coroutine(self, coro: Coroutine[..., ...], **kwargs): + def add_coroutine(self, *, coroutine: Callable[..., ...] | Coroutine, **kwargs): """Add a coroutine to the executor. Args: - coro (Coroutine): A coroutine to be executed + coroutine (Coroutine): A coroutine to be executed **kwargs (dict): keyword arguments for the coroutine Returns: """ - self.executor.add_coroutine(coro, kwargs) + self.executor.add_coroutine(coroutine=coroutine, kwargs=kwargs) def execute(self): """Execute the bot.""" @@ -92,7 +93,7 @@ class Bot: await self.initialize() await self.executor.execute() - def add_strategy(self, strategy: Strategy): + def add_strategy(self, *, strategy: Strategy): """Add a strategy to the executor. An added strategy will only run if it's symbol was successfully initialized. Args: @@ -101,15 +102,15 @@ class Bot: Notes: Make sure the symbol has been added to the market """ - self.executor.add_worker(strategy) + self.executor.add_strategy(strategy=strategy) - def add_strategies(self, strategies: Iterable[Strategy]): + def add_strategies(self, *, strategies: Iterable[Strategy]): """Add multiple strategies at the same time Args: strategies: A list of strategies """ - [self.add_strategy(strategy) for strategy in strategies] + [self.add_strategy(strategy=strategy) for strategy in strategies] def add_strategy_all(self, *, strategy: Type[Strategy], params: dict | None = None, symbols: list[Symbol] = None, **kwargs): @@ -122,21 +123,21 @@ class Bot: **kwargs: Additional keyword arguments for the strategy """ [ - self.add_strategy(strategy(symbol=symbol, params=params, **kwargs)) + self.add_strategy(strategy=strategy(symbol=symbol, params=params, **kwargs)) for symbol in symbols ] @staticmethod - async def init_strategy(strategy: Strategy) -> tuple[bool, Strategy]: + async def init_strategy(*, strategy: Strategy) -> tuple[bool, Strategy]: """Initialize a single strategy. This method is called internally by the bot.""" res = await strategy.symbol.init() return res, strategy async def init_strategies(self): """Initialize the symbols for the current trading session. This method is called internally by the bot.""" - tasks = [self.init_strategy(strategy) for strategy in self.executor.workers] + tasks = [self.init_strategy(strategy=strategy) for strategy in self.executor.strategy_runners] for task in asyncio.as_completed(tasks): res = await task if not res[0]: logger.warning(f"Failed to initialize symbol {res[1].symbol}") - self.executor.workers.remove(res[1]) + self.executor.strategy_runners.remove(res[1]) diff --git a/src/aiomql/candle.py b/src/aiomql/lib/candle.py similarity index 87% rename from src/aiomql/candle.py rename to src/aiomql/lib/candle.py index 04ee12e..20a95c0 100644 --- a/src/aiomql/candle.py +++ b/src/aiomql/lib/candle.py @@ -1,6 +1,6 @@ """Candle and Candles classes for handling bars from the MetaTrader 5 terminal.""" - -from typing import Type, TypeVar, Generic, Iterable +import time +from typing import Type, Self, Iterable from logging import getLogger from pandas import DataFrame, Series @@ -8,7 +8,7 @@ import pandas as pd import pandas_ta as ta import mplfinance as mplt -from .core.constants import TimeFrame +from ..core.constants import TimeFrame logger = getLogger(__name__) @@ -47,8 +47,11 @@ 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', 0) + self.time = kwargs.pop('time', time.monotonic_ns()) self.Index = kwargs.pop('Index', 0) + self.real_volume = kwargs.pop('real_volume', 0) + self.spread = kwargs.pop('spread', 0) + self.tick_volume = kwargs.pop('tick_volume', 0) self.set_attributes(**kwargs) def __repr__(self): @@ -56,27 +59,32 @@ class Candle: % {"class": self.__class__.__name__, "open": self.open, "high": self.high, "low": self.low, "close": self.close, "time": self.time, 'Index': self.Index}) - def __str__(self): - return str(self.dict()) + def __eq__(self, other: Self): + return self.time == other.time - def __eq__(self, other: "Candle"): - eq = self.open == other.open and self.high == other.high and self.low == other.low and self.close == other.close - return eq + def __lt__(self, other: Self): + return self.time < other.time def __hash__(self): return hash(self.time) - def __lt__(self, other: "Candle"): - return self.time < other.time - - def __gt__(self, other: "Candle"): - return self.time > other.time - def __getitem__(self, item): return self.__dict__[item] + def __setitem__(self, key, value): + self.__dict__[key] = value + + def __iter__(self): + return iter(self.__dict__.items()) + + def keys(self): + return self.__dict__.keys() + + def values(self): + return self.__dict__.values() + def set_attributes(self, **kwargs): - """Set keyword arguments as instance attributes + """Set keyword arguments as instance attributes and values. Keyword Args: **kwargs: Instance attributes and values as keyword arguments @@ -99,7 +107,7 @@ class Candle: """ return self.open > self.close - def dict(self, exclude: set = None, include: set = None) -> dict: + def dict(self, *, exclude: set = None, include: set = None) -> dict: """ Returns a dictionary of the instance attributes. @@ -112,14 +120,10 @@ class Candle: exclude = exclude or set() include = include or set() keys = include or set(self.__dict__.keys()).difference(exclude) - return {k: v for k, v in self.__dict__.items() if k in keys} + return {k: v for k, v in self if k in keys} -_Candle = TypeVar("_Candle", bound=Candle) -_Candles = TypeVar("_Candles", bound="Candles") - - -class Candles(Generic[_Candle]): +class Candles: """An iterable container class of Candle objects in chronological order. Attributes: @@ -155,7 +159,7 @@ class Candles(Generic[_Candle]): timeframe: TimeFrame _data: DataFrame - def __init__(self, *, data: DataFrame | _Candles | Iterable, flip=False, candle_class: Type[_Candle] = None): + def __init__(self, *, data: DataFrame | Self | Iterable, flip=False, candle_class: Candle = None): """A container class of Candle objects in chronological order. Args: @@ -183,10 +187,10 @@ class Candles(Generic[_Candle]): def __len__(self): return len(self._data.index) - def __contains__(self, item: _Candle): + def __contains__(self, item: Self): return item.time == self[item.Index].time - def __getitem__(self, index) -> _Candle | _Candles | Series: + def __getitem__(self, index) -> Self | Self | Series: if isinstance(index, slice): cls = self.__class__ data = self._data.iloc[index] @@ -212,6 +216,7 @@ class Candles(Generic[_Candle]): def __getattr__(self, item): if item in self._data.columns: return self._data[item] + if item == 'Index': return Series(self._data.index) raise AttributeError(f"Attribute {item} not defined on class {self.__class__.__name__}") @@ -224,6 +229,10 @@ class Candles(Generic[_Candle]): tf = self.time[1] - self.time[0] return TimeFrame.get(abs(tf)) + @property + def columns(self) -> DataFrame: + return self._data.columns + @property def ta(self): """Access to the pandas_ta library for performing technical analysis on the underlying data attribute. @@ -247,7 +256,7 @@ class Candles(Generic[_Candle]): """The original data passed to the class as a pandas DataFrame""" return self._data - def rename(self, inplace=True, **kwargs) -> _Candles: + def rename(self, inplace=True, **kwargs) -> Self: """Rename columns of the candles class. Keyword Args: @@ -274,13 +283,13 @@ class Candles(Generic[_Candle]): data.index = pd.to_datetime(data['time'], unit='s') return mplt.make_addplot(data[columns], **kwargs) - def visualize(self, *, count: int = 50, type='candle', savefig: str | dict = None, addplot: dict = None, + def visualize(self, *, count: int = 50, _type='candle', savefig: str | dict = None, addplot: dict = None, style: str = 'charles', ylabel: str = 'Price', title: str = 'Chart', **kwargs): """Visualize the candles using the mplfinance library. Args: count (int): The number of candles to visualize, counting from behind, i.e the most recent candles. Defaults to 50. - type: Type of chart, defaults to candle + _type: Type of chart, defaults to candle savefig (str|dict): The path to save the figure or a dictionary of parameters to pass to the savefig method. addplot: Additional plots to add to the chart. Defaults to None. They should match the dimension of the original data which is specified via the count parameter. @@ -290,7 +299,7 @@ class Candles(Generic[_Candle]): kwargs: valid kwargs for the plot function. """ kwargs |= {key: arg for key, arg in (('savefig', savefig), ('addplot', addplot), ('style', style), - ('ylabel', ylabel), ('title', title), ('type', type)) if arg} + ('ylabel', ylabel), ('title', title), ('type', _type)) if arg} data = self._data[-count:] data.index = pd.to_datetime(data['time'], unit='s') mplt.plot(data, **kwargs) diff --git a/src/aiomql/executor.py b/src/aiomql/lib/executor.py similarity index 57% rename from src/aiomql/executor.py rename to src/aiomql/lib/executor.py index 52fee19..2656bdf 100644 --- a/src/aiomql/executor.py +++ b/src/aiomql/lib/executor.py @@ -13,49 +13,50 @@ class Executor: Attributes: executor (ThreadPoolExecutor): The executor object. - workers (list): List of strategies. + strategy_runners (list): List of strategies. coroutines (dict[Coroutine, dict]): A dictionary of coroutines to run in the executor functions (dict[Callable, dict]): A dictionary of functions to run in the executor + loop (asyncio.AbstractEventLoop): The event loop """ + loop: asyncio.AbstractEventLoop def __init__(self): self.executor = ThreadPoolExecutor - self.workers: list[Strategy] = [] + self.strategy_runners: list[Strategy] = [] self.coroutines: dict[Coroutine | Callable: dict] = {} self.functions: dict[Callable: dict] = {} - def add_function(self, func: Callable, kwargs: dict): - self.functions[func] = kwargs + def add_function(self, *, function: Callable, kwargs: dict): + self.functions[function] = kwargs - def add_coroutine(self, coro: Coroutine, kwargs: dict): - self.coroutines[coro] = kwargs + def add_coroutine(self, *, coroutine: Coroutine, kwargs: dict): + self.coroutines[coroutine] = kwargs - def add_workers(self, strategies: tuple[Strategy]): + def add_strategies(self, *, strategies: tuple[Strategy]): """Add multiple strategies at once Args: strategies (Sequence[Strategy]): A sequence of strategies. """ - self.workers.extend(strategies) + self.strategy_runners.extend(strategies) - def add_worker(self, strategy: Strategy): + def add_strategy(self, *, strategy: Strategy): """Add a strategy instance to the list of workers Args: strategy (Strategy): A strategy object """ - self.workers.append(strategy) + self.strategy_runners.append(strategy) - @staticmethod - def trade(strategy: Strategy): + def run_strategy(self, strategy: Strategy): """Wraps the coroutine trade method of each strategy with 'asyncio.run'. Args: strategy (Strategy): A strategy object """ - asyncio.run(strategy.trade()) + self.loop.run_until_complete(strategy.run_strategy()) - def run(self, func, kwargs: dict): + def run_coroutine(self, func, kwargs: dict): """ Run a coroutine function @@ -64,11 +65,11 @@ class Executor: kwargs: A dictionary of keyword arguments for the function """ try: - asyncio.run(func(**kwargs)) + self.loop.run_until_complete(func(**kwargs)) except Exception as err: logger.error(f'Error: {err}. Unable to run function') - async def execute(self, workers: int = 5): + async def execute(self, *, workers: int = 5): """Run the strategies with a threadpool executor. Args: @@ -77,10 +78,11 @@ class Executor: Notes: No matter the number specified, the executor will always use a minimum of 5 workers. """ - workers_ = sum([len(self.workers), len(self.functions), len(self.coroutines)]) + workers_ = sum([len(self.strategy_runners), len(self.functions), len(self.coroutines)]) workers = max(workers, workers_) - loop = asyncio.get_running_loop() + self.loop = asyncio.get_running_loop() with self.executor(max_workers=workers) as executor: - [loop.run_in_executor(executor, self.trade, worker) for worker in self.workers] - [loop.run_in_executor(executor, self.run, coro, kwargs) for coro, kwargs in self.coroutines.items()] - [loop.run_in_executor(executor, func, kwargs) for func, kwargs in self.functions.items()] + [self.loop.run_in_executor(executor, self.run_strategy, worker) for worker in self.strategy_runners] + [self.loop.run_in_executor(executor, self.run_coroutine, coroutine, kwargs) for coroutine, + kwargs in self.coroutines.items()] + [self.loop.run_in_executor(executor, function, kwargs) for function, kwargs in self.functions.items()] diff --git a/src/aiomql/history.py b/src/aiomql/lib/history.py similarity index 91% rename from src/aiomql/history.py rename to src/aiomql/lib/history.py index b91417e..99a0824 100644 --- a/src/aiomql/history.py +++ b/src/aiomql/lib/history.py @@ -6,11 +6,11 @@ import pytz from pandas import DataFrame import pandas as pd -from .core.config import Config -from .core.meta_trader import MetaTrader, CopyTicks, OrderType -from .core.models import TradeDeal, TradeOrder -from .contrib.backtester.meta_tester import MetaTester -from .utils import backoff_decorator +from ..core.config import Config +from ..core.meta_trader import MetaTrader, CopyTicks, OrderType +from ..core.models import TradeDeal, TradeOrder +from ..core.meta_backtester import MetaBackTester +from .._utils import backoff_decorator logger = getLogger(__name__) @@ -27,7 +27,7 @@ class History: mt5 (MetaTrader): MetaTrader instance config (Config): Config instance """ - mt5: MetaTrader | MetaTester + mt5: MetaTrader | MetaBackTester config: Config def __init__(self, *, date_from: datetime | int, date_to: datetime | int, group: str = '', use_utc: bool = True): @@ -42,11 +42,11 @@ class History: group (str): Filter for selecting history by symbols. Defaults to an empty string """ self.config = Config() - self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester() + self.mt5 = MetaTrader() if self.config.mode != 'backtest' else MetaBackTester() date_from = date_from if isinstance(date_from, datetime) else datetime.fromtimestamp(date_from) date_to = date_to if isinstance(date_to, datetime) else datetime.fromtimestamp(date_to) - self.date_from = date_from.astimezone(pytz.utc) if use_utc else date_from - self.date_to = date_to.astimezone(pytz.utc) if use_utc else date_to + self.date_from = date_from.astimezone(pytz.UTC) if use_utc else date_from + self.date_to = date_to.astimezone(pytz.UTC) if use_utc else date_to self.group = group self.deals: tuple[TradeDeal, ...] = () self.orders: tuple[TradeOrder, ...] = () @@ -111,12 +111,12 @@ class History: logger.warning(f'Failed to get orders') return tuple() - def get_orders_by_ticket(self, ticket: int) -> tuple[TradeOrder, ...]: + def get_orders_by_ticket(self, *, ticket: int) -> tuple[TradeOrder, ...]: """filter orders by ticket""" return tuple(sorted((order for order in self.orders if order.ticket == ticket), key=lambda x: x.time_done_msc)) - def get_orders_by_position(self, position: int) -> tuple[TradeOrder, ...]: + def get_orders_by_position(self, *, position: int) -> tuple[TradeOrder, ...]: """ filter orders by position""" return tuple(sorted((order for order in self.orders if order.position_id == position), key=lambda x: x.time_done_msc)) diff --git a/src/aiomql/order.py b/src/aiomql/lib/order.py similarity index 88% rename from src/aiomql/order.py rename to src/aiomql/lib/order.py index 83ce8bc..e9a044b 100644 --- a/src/aiomql/order.py +++ b/src/aiomql/lib/order.py @@ -1,15 +1,15 @@ from logging import getLogger -from .core.models import TradeRequest, TradeOrder -from .core.constants import TradeAction, OrderTime, OrderFilling -from .core.exceptions import OrderError -from .utils import backoff_decorator, error_handler -from MetaTrader5 import OrderCheckResult, OrderSendResult +from ..core.models import TradeRequest, TradeOrder, OrderCheckResult, OrderSendResult +from ..core.constants import TradeAction, OrderTime, OrderFilling +from ..core.exceptions import OrderError +from ..core.base import _Base +from .._utils import backoff_decorator, error_handler logger = getLogger(__name__) -class Order(TradeRequest): +class Order(_Base, TradeRequest): """Trade order related functions and properties. Subclass of TradeRequest.""" def __init__(self, **kwargs): @@ -25,7 +25,7 @@ class Order(TradeRequest): type_time (OrderTime.DAY): Order time type_filling (OrderFilling.FOK): Order filling """ - kwargs = {'action': TradeAction.DEAL, OrderTime.DAY: self.type_time, 'type_filling': OrderFilling.FOK, **kwargs} + kwargs = {'action': TradeAction.DEAL, 'type_time': OrderTime.DAY, 'type_filling': OrderFilling.FOK, **kwargs} super().__init__(**kwargs) async def orders_total(self): @@ -81,7 +81,7 @@ class Order(TradeRequest): res = await self.mt5.order_check(req) if res is None: raise OrderError(f'Order check failed for {self.symbol}') - return res + return OrderCheckResult(**res._asdict()) @backoff_decorator async def send(self) -> OrderSendResult: @@ -96,7 +96,7 @@ class Order(TradeRequest): res = await self.mt5.order_send(self.dict) if res is None: raise OrderError(f'Failed to send order {self.symbol}') - return res + return OrderSendResult(**res._asdict()) async def calc_margin(self) -> float | None: """Return the required margin in the account currency to perform a specified trading operation. diff --git a/src/aiomql/positions.py b/src/aiomql/lib/positions.py similarity index 79% rename from src/aiomql/positions.py rename to src/aiomql/lib/positions.py index bdf222e..49c92f7 100644 --- a/src/aiomql/positions.py +++ b/src/aiomql/lib/positions.py @@ -2,13 +2,14 @@ import asyncio from logging import getLogger -from .core.meta_trader import MetaTrader -from .core.models import TradePosition, OrderSendResult -from .core.constants import OrderType, TradeAction -from .core.config import Config -from .contrib.backtester.meta_tester import MetaTester +from ..core.meta_trader import MetaTrader +from ..core.models import TradePosition, OrderSendResult +from ..core.constants import OrderType, TradeAction +from ..core.config import Config +from .._utils import backoff_decorator +from ..core.meta_backtester import MetaBackTester from .order import Order -from .utils import backoff_decorator + logger = getLogger(__name__) @@ -19,13 +20,13 @@ class Positions: Attributes: mt5 (MetaTrader): MetaTrader instance. """ - mt5: MetaTrader | MetaTester + mt5: MetaTrader | MetaBackTester positions: tuple[TradePosition, ...] def __init__(self): """Get Open Positions""" self.config = Config() - self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester() + self.mt5 = MetaTrader() if self.config.mode != 'backtest' else MetaBackTester() self.positions = () @backoff_decorator @@ -68,7 +69,7 @@ class Positions: return tuple(TradePosition(**pos._asdict()) for pos in (positions or ())) @staticmethod - async def close(ticket: int, symbol: str, price: float, volume: float, order_type: OrderType) -> OrderSendResult: + async def close(*, ticket: int, symbol: str, price: float, volume: float, order_type: OrderType) -> OrderSendResult: """Close an open position for the trading account using the ticket and other parameters. Args: @@ -83,14 +84,14 @@ class Positions: return await order.send() @staticmethod - async def close_by(pos: TradePosition) -> OrderSendResult: + async def close_by(*, position: TradePosition) -> OrderSendResult: """Close an open position for the trading account.""" - order = Order(position=pos.ticket, symbol=pos.symbol, volume=pos.volume, type=pos.type.opposite, - price=pos.price_current, action=TradeAction.DEAL) + order = Order(position=position.ticket, symbol=position.symbol, volume=position.volume, + type=position.type.opposite, price=position.price_current, action=TradeAction.DEAL) return await order.send() @staticmethod - async def close_position(position: TradePosition): + async def close_position(*, position: TradePosition): """Close an open position for the trading account. Using a position object.""" order = Order(position=position.ticket, symbol=position.symbol, volume=position.volume, type=position.type.opposite, price=position.price_current, action=TradeAction.DEAL) @@ -103,6 +104,6 @@ class Positions: int: Return number of positions closed. """ positions = self.positions or await self.get_positions() - results = await asyncio.gather(*(self.close_position(position) for position in positions), + results = await asyncio.gather(*(self.close_position(position=position) for position in positions), return_exceptions=True) return len([res for res in results if (isinstance(res, OrderSendResult) and res.retcode == 10009)]) diff --git a/src/aiomql/ram.py b/src/aiomql/lib/ram.py similarity index 64% rename from src/aiomql/ram.py rename to src/aiomql/lib/ram.py index a768ebb..ffdbba7 100644 --- a/src/aiomql/ram.py +++ b/src/aiomql/lib/ram.py @@ -7,23 +7,27 @@ class RAM: account: Account risk_to_reward: float risk: float - min_amount: float = 0 - max_amount: float = 0 - loss_limit: int = 3 - open_limit: int = 5 + min_amount: float + max_amount: float + loss_limit: int + open_limit: int - def __init__(self, *, risk_to_reward: float = 1, risk: float = 0.01, **kwargs): + def __init__(self, **kwargs): """Initialize Risk Assessment and Management with the provided keyword arguments. Keyword Args: risk_to_reward (float): Risk to reward ratio. Defaults to 1 - risk (float): Percentage of account balance to risk per trade 0.01 # 1% + risk (float): Percentage of capital to risk per trade 0.01 # 1% kwargs: extra keyword arguments are set as object attributes """ - self.risk_to_reward = risk_to_reward - self.risk = risk self.account = Account() - [setattr(self, key, value) for key, value in kwargs.items()] + self.positions = Positions() + self.risk_to_reward = kwargs.get('risk_to_reward', 1) + self.risk = kwargs.get('risk', 0.01) + self.min_amount = kwargs.get('min_amount', 0) + self.max_amount = kwargs.get('max_amount', 0) + self.loss_limit = kwargs.get('loss_limit', 1) + self.open_limit = kwargs.get('open_limit', 1) async def get_amount(self) -> float: """Calculate the amount to risk per trade as a percentage of margin_free. @@ -38,14 +42,14 @@ class RAM: return amount async def check_losing_positions(self) -> bool: - """Check if the number of losing positions is greater than or equal the loss limit + """Check if the number of losing positions is greater than the loss limit Returns: - bool: True if the number of losing positions is less than the loss limit + bool: True if the number of losing positions is less than or equal the loss limit """ - positions = await Positions().get_positions() + positions = await self.positions.get_positions() loosing = [position for position in positions if position.profit < 0] - return len(loosing) < self.loss_limit + return len(loosing) <= self.loss_limit async def check_open_positions(self) -> bool: """Check if the number of open positions is greater than or equal the loss limit. @@ -53,5 +57,5 @@ class RAM: Returns: bool: True if the number of open positions is less than the open limit """ - positions = await Positions().get_positions() - return len(positions) < self.open_limit + positions = await self.positions.get_positions() + return len(positions) <= self.open_limit diff --git a/src/aiomql/result.py b/src/aiomql/lib/result.py similarity index 83% rename from src/aiomql/result.py rename to src/aiomql/lib/result.py index da2a70d..6c4a154 100644 --- a/src/aiomql/result.py +++ b/src/aiomql/lib/result.py @@ -4,10 +4,8 @@ from logging import getLogger from typing import Iterable, Literal from asyncio import Lock -from _typeshed import SupportsWrite, SupportsRead - -from .core.config import Config -from .core.models import OrderSendResult +from ..core.config import Config +from ..core.models import OrderSendResult logger = getLogger(__name__) @@ -22,7 +20,7 @@ class Result: """ config: Config - def __init__(self, result: OrderSendResult, parameters: dict = None, name: str = ''): + def __init__(self, *, result: OrderSendResult, parameters: dict = None, name: str = ''): """ Prepare result data Args: @@ -33,7 +31,7 @@ class Result: self.config = Config() self.parameters = parameters or {} self.result = result - self.name = name or parameters.get('name', 'Trades') + self.name = name or self.parameters.get('name', 'Trades') self.lock = Lock() def get_data(self) -> dict: @@ -48,8 +46,10 @@ class Result: trade_record_mode = trade_record_mode or self.config.trade_record_mode if trade_record_mode == 'csv': await self.to_csv() - else: + elif trade_record_mode == 'json': await self.to_json() + else: + logger.error(f"Invalid trade record mode: {trade_record_mode}") async def to_csv(self): """Record trade results and associated parameters as a csv file @@ -61,13 +61,13 @@ class Result: file.touch(exist_ok=True) if not file.exists() else ... read_file = file.open('r', newline='') reader: Iterable[dict] = csv.DictReader(read_file) - read_file.close() rows: list[dict] = [] headers = set() [(rows.append(row), headers.update(row.keys())) for row in reader] rows.append(data) headers.update(data.keys()) - with file.open('w', newline='') as write_file: # type: SupportsWrite[str] + read_file.close() + with file.open('w', newline='') as write_file: writer = csv.DictWriter(write_file, fieldnames=headers, restval=None, extrasaction='ignore') writer.writeheader() writer.writerows(rows) @@ -93,12 +93,16 @@ class Result: try: file = self.config.records_dir / f"{self.name}.json" data = self.get_data() - file.touch(exist_ok=True) if not file.exists() else ... - with file.open('r') as fh: # type: SupportsRead[str] + if not file.exists(): + file.touch() + with file.open('w') as fh: + json.dump([], fh, indent=2) + + with file.open('r') as fh: rows = json.load(fh) rows.append(data) - with file.open('w') as fh: # type: SupportsWrite[str] + with file.open('w') as fh: json.dump(rows, fh, indent=2, skipkeys=True, default=self.serialize) except Exception as err: diff --git a/src/aiomql/sessions.py b/src/aiomql/lib/sessions.py similarity index 80% rename from src/aiomql/sessions.py rename to src/aiomql/lib/sessions.py index ccc5c0a..42ee2d1 100644 --- a/src/aiomql/sessions.py +++ b/src/aiomql/lib/sessions.py @@ -1,19 +1,24 @@ import asyncio from datetime import time, timedelta, datetime -from typing import Literal, Callable, Iterable +from typing import Literal, Callable, Iterable, NamedTuple from logging import getLogger import pytz -from . import TradePosition -from .core.models import OrderSendResult +from ..core.models import OrderSendResult, TradePosition +from ..core.config import Config +from ..core.event_manager import EventManager from .positions import Positions -from .core.config import Config -from.contrib.backtester.event_manager import EventManager logger = getLogger(__name__) +class Duration(NamedTuple): + hours: int + minutes: int + seconds: int + + def delta(obj: time) -> timedelta: """Get the timedelta of a datetime.time object. @@ -73,19 +78,9 @@ class Session: self.config = Config() def __contains__(self, item: time): - if self.start > self.end: - end = timedelta(days=1, hours=self.start.hour, minutes=self.start.minute, seconds=self.start.second, - microseconds=self.start.microsecond) - start = delta(self.start) - if item < self.start and item < self.end: - item = timedelta(days=1, hours=item.hour, minutes=item.minute, seconds=item.second, - microseconds=item.microsecond) - else: - item = delta(item) - else: - start = delta(self.start) - end = delta(self.end) - return start <= item < end + span = (delta(self.end) - delta(self.start)).seconds + item_span = (delta(self.end) - delta(item)).seconds + return item_span <= span def __str__(self): return f'{self.start}<-->{self.end}' @@ -94,7 +89,7 @@ class Session: return f'{self.start}<-->{self.end}' def __len__(self): - return (delta(self.start) - delta(self.end)).seconds + return int((delta(self.end) - delta(self.start)).seconds) def in_session(self) -> bool: """Check if the current time is within the session.""" @@ -110,9 +105,15 @@ class Session: """Call the action specified in on_end or custom_end.""" await self.action(action=self.on_end) + def duration(self) -> Duration: + """Get the duration of the session in seconds.""" + hours, seconds = divmod(len(self), 3600) + minutes, seconds = divmod(seconds, 60) + return Duration(hours=hours, minutes=minutes, seconds=seconds) + async def close_positions(self, *, positions: tuple[TradePosition, ...]): - results = asyncio.gather(*(self.positions_manager.close_position(pos) for pos in positions), + results = asyncio.gather(*(self.positions_manager.close_position(position=position) for position in positions), return_exceptions=True) closed = pending = 0 for result in results: @@ -169,7 +170,7 @@ class Session: """Get the seconds until the session starts from the current time in seconds.""" if self.config.mode == 'backtest': now = datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=pytz.UTC).time() - secs = delta(self.start) - delta(now) + secs = (delta(self.start) - delta(now)).seconds else: secs = (delta(self.start) - delta(datetime.now(tz=pytz.UTC).time())).seconds return secs @@ -189,6 +190,7 @@ class Sessions: check: Check if the current session has started and if not, wait until it starts. """ sessions: list[Session] + current_session: Session | None def __init__(self, *, sessions: Iterable[Session]): self.sessions = list(sessions) @@ -196,43 +198,47 @@ class Sessions: self.current_session = None self.config = Config() - def find(self, obj: time) -> Session | None: - """Find a session that contains a datetime.time object. + def find(self, *, moment: time = None) -> Session | None: + """Find a session that contains a datetime.time object, if not found return None. - Args: - obj (datetime.time): A datetime.time object. + Keyword Args: + moment (datetime.time | None): A datetime.time object. if not provided, the current time is used. Returns: Session | None: A Session object or None if not found. """ + moment = moment or datetime.now(tz=pytz.UTC).time() if self.config.mode == 'live' else ( + datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=pytz.UTC).time()) for session in self.sessions: - if obj in session: + if moment in session: return session return None - def find_next(self, obj: time) -> Session: + def find_next(self, *, moment: time = None) -> Session: """Find the next session that contains a datetime.time object. Args: - obj (datetime.time): A datetime.time object. + moment (datetime.time | None): A datetime.time object, if not provided, the current time is used. Returns: Session: A Session object. """ + moment = moment or datetime.now(tz=pytz.UTC).time() if self.config.mode == 'live' else ( + datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=pytz.UTC).time()) for session in self.sessions: - if delta(obj) < delta(session.start): + if delta(moment) < delta(session.start): return session return self.sessions[0] - def __contains__(self, item: time): - return True if self.find(item) is not None else False + def __contains__(self, moment: time): + return True if self.find(moment=moment) is not None else False async def __aenter__(self): await self.check() return self async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.current_session.close() + await self.current_session.close() if self.current_session is not None else ... async def check(self): """Check if the current session has started and if not, wait until it starts.""" @@ -242,9 +248,9 @@ class Sessions: if self.config.mode == 'backtest': now = datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=pytz.UTC).time() else: - now = datetime.now().time() + now = datetime.now(tz=pytz.UTC).time() - next_session = self.find(now) + next_session = self.find(moment=now) if next_session and self.current_session is None: self.current_session = next_session @@ -255,11 +261,12 @@ class Sessions: await self.current_session.close() self.current_session = next_session await self.current_session.begin() + return if next_session is None and self.current_session is not None: await self.current_session.close() - next_session = self.find_next(now) + next_session = self.find_next(moment=now) secs = next_session.until() + 10 logger.info(f'sleeping for {secs} seconds until next {next_session} session') sleep_func = asyncio.sleep if self.config.mode == 'live' else backtest_sleep diff --git a/src/aiomql/lib/strategies/__init__.py b/src/aiomql/lib/strategies/__init__.py deleted file mode 100644 index 175ab49..0000000 --- a/src/aiomql/lib/strategies/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .finger_trap import FingerTrap -from .tracker import Tracker -# from .finger_trap_back_test import FingerTrapTest, FingerTrapSingleTest diff --git a/src/aiomql/lib/strategies/finger_trap_back_test.py b/src/aiomql/lib/strategies/finger_trap_back_test.py deleted file mode 100644 index 654248f..0000000 --- a/src/aiomql/lib/strategies/finger_trap_back_test.py +++ /dev/null @@ -1,30 +0,0 @@ -from .finger_trap import FingerTrap -from ...contrib.backtester.strategy_tester import StrategyTester - - -class FingerTrapTest(StrategyTester, FingerTrap): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.set_up() - - async def test(self): - print(f"Backtesting {self.symbol}") - while True: - await self.event_manager.acquire() - try: - await self.event_manager.wait() - await self.watch_market() - - if not self.tracker.new: - continue - - if self.tracker.order_type is not None: - await self.trader.place_trade(order_type=self.tracker.order_type, parameters=self.parameters, - sl=self.tracker.sl) - await self.sleep(self.tracker.snooze) - except Exception as err: - print(f"{err} For {self.symbol} in {self.__class__.__name__}.trade") - await self.sleep(self.ttf.time) - - finally: - self.event_manager.release() diff --git a/src/aiomql/lib/strategy.py b/src/aiomql/lib/strategy.py new file mode 100644 index 0000000..0d854ce --- /dev/null +++ b/src/aiomql/lib/strategy.py @@ -0,0 +1,159 @@ +"""The base class for creating strategies.""" +import asyncio +from time import time +from typing import TypeVar +from abc import ABC, abstractmethod +from datetime import time as dtime +from logging import getLogger + +from ..core.meta_trader import MetaTrader +from ..core import Config +from ..core.meta_backtester import MetaBackTester +from ..core.event_manager import EventManager +from .sessions import Sessions, Session +from .symbol import Symbol as _Symbol + +Symbol = TypeVar("Symbol", bound=_Symbol) +logger = getLogger(__name__) + + +class Strategy(ABC): + """The base class for creating strategies. + + Attributes: + name (str): The name of the strategy. + symbol (Symbol): The Financial Instrument as a Symbol Object + parameters (Dict): A dictionary of parameters for the strategy. + sessions (Sessions): The sessions to use for the strategy. + running (bool): A flag to indicate if the strategy is running. + + Notes: + Define the name of a strategy as a class attribute. If not provided, the class name will be used as the name. + """ + name: str + symbol: Symbol + sessions: Sessions + mt5: MetaTrader | MetaBackTester + config: Config + running: bool + parameters = {} + event_manager = EventManager + current_session = Session + + def __init__(self, *, symbol: Symbol, params: dict = None, sessions: Sessions = None, name=''): + """Initiate the parameters dict and add name and symbol fields. + Use class name as strategy name if name is not provided + + Args: + symbol (Symbol): The Financial instrument + params (Dict): Trading strategy parameters + """ + self.parameters = self.parameters | (params or {}) + self.symbol = symbol + self.name = name or self.__class__.__name__ + self.parameters["symbol"] = symbol.name + self.parameters["name"] = self.name + self.running = True + self.sessions = sessions or Sessions(sessions=[Session(start=0, end=dtime(hour=23, minute=59, second=59))]) + self.config = Config() + self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaBackTester() + self.event_manager = EventManager() + + def __repr__(self): + return f"{self.name}({self.symbol!r})" + + def __getattr__(self, item): + if item in self.parameters: + return self.parameters[item] + raise AttributeError(f'{item} not an attribute of {self.name}') + + def __setattr__(self, key, value): + if key in self.parameters: + self.parameters[key] = value + super().__setattr__(key, value) + + async def __aenter__(self): + await self.sessions.check() + self.current_session = self.sessions.current_session + + async def __aexit__(self, exc_type, exc_val, exc_tb): + try: + await self.current_session.close() if self.current_session else ... + self.running = False + except Exception as err: + logger.error(f"Error: {err}") + + @staticmethod + async def live_sleep(*, secs: float): + """Sleep for the needed amount of seconds in between requests to the terminal. + computes the accurate amount of time needed to sleep ensuring that the next request is made at the start of + a new bar and making cooperative multitasking possible. + + Args: + secs (float): The time in seconds. Usually the timeframe you are trading on. + """ + mod = time() % secs + secs = secs - mod if mod != 0 else mod + await asyncio.sleep(secs + 0.2) + + async def sleep(self, *, secs: float): + """Sleep for the needed amount of seconds in between requests to the terminal. + computes the accurate amount of time needed to sleep ensuring that the next request is made at the start of + a new bar and making cooperative multitasking possible. + + Args: + secs (float): The time in seconds. Usually the timeframe you are trading on. + """ + if self.config.mode == 'live': + await self.live_sleep(secs=secs) + elif self.config.mode == 'backtest': + await self.backtest_sleep(secs=secs) + + async def backtest_sleep(self, *, secs: float): + _time = self.config.backtest_engine.cursor.time + mod = _time % secs + secs = secs - mod if mod != 0 else mod + + if self.event_manager.num_main_tasks == 1: + self.config.backtest_engine.fast_forward(secs) + await self.event_manager.wait() + + elif self.event_manager.num_main_tasks > 1: + _time = self.config.backtest_engine.cursor.time + secs + while _time > self.config.backtest_engine.cursor.time: + await self.event_manager.wait() + else: + await self.event_manager.wait() + + async def run_strategy(self): + """Run the strategy.""" + if self.config.mode == 'live': + await self.live_strategy() + elif self.config.mode == 'backtest': + await self.backtest_strategy() + + async def live_strategy(self): + """Run the strategy.""" + while self.running: + async with self as _: + await self.sessions.check() + await self.trade() + + async def backtest_strategy(self): + """Backtest the strategy.""" + async with self as _: + while self.running: + async with self.event_manager.condition: + await self.sessions.check() + await self.event_manager.wait() + await self.trade() + + @abstractmethod + async def trade(self): + """Place trades using this method. This is the main method of the strategy. + It will be called by the strategy runner. + """ + raise NotImplementedError("Implement this method in your subclass") + + async def test(self): + await self.trade() diff --git a/src/aiomql/symbol.py b/src/aiomql/lib/symbol.py similarity index 91% rename from src/aiomql/symbol.py rename to src/aiomql/lib/symbol.py index ab195ba..82f488e 100644 --- a/src/aiomql/symbol.py +++ b/src/aiomql/lib/symbol.py @@ -3,19 +3,19 @@ import asyncio from datetime import datetime from logging import getLogger -from .core.constants import TimeFrame, CopyTicks -from .core.models import SymbolInfo, BookInfo +from ..core.constants import TimeFrame, CopyTicks +from ..core.base import _Base +from ..core.models import SymbolInfo, BookInfo +from .._utils import round_off, backoff_decorator from .ticks import Tick from .account import Account from .candle import Candles from .ticks import Ticks -from .utils import round_off, backoff_decorator - logger = getLogger(__name__) -class Symbol(SymbolInfo): +class Symbol(_Base, SymbolInfo): """Main class for handling a financial instrument. A subclass of SymbolInfo it has attributes and methods for working with a financial instrument. @@ -36,18 +36,10 @@ class Symbol(SymbolInfo): Args: name (str): Name of the financial instrument """ + assert 'name' in kwargs, "Symbol Object Must be initialized with a name" super().__init__(**kwargs) self.account = Account() - @property - def pip(self): - """Returns the pip value of the symbol. This is ten times the point value for forex symbols. - - Returns: - float: The pip value of the symbol. - """ - return self.point * 10 - @backoff_decorator async def info_tick(self, *, name: str = "") -> Tick: """Get the current price tick of a financial instrument. @@ -152,7 +144,7 @@ class Symbol(SymbolInfo): """ return await self.mt5.market_book_release(self.name) - def check_volume(self, volume) -> tuple[bool, float]: + def check_volume(self, *, volume) -> tuple[bool, float]: """Check if the volume is within the limits of the symbol. If not, return the nearest limit. Args: @@ -167,7 +159,7 @@ class Symbol(SymbolInfo): else: return check, self.volume_min if volume <= self.volume_min else self.volume_max - def round_off_volume(self, volume: float, round_down: bool = False) -> float: + def round_off_volume(self, *, volume: float, round_down: bool = False) -> float: """Round off the volume to the nearest volume step. Args: @@ -179,7 +171,7 @@ class Symbol(SymbolInfo): """ return round_off(value=volume, step=self.volume_step, round_down=round_down) - async def check_amount(self, amount: float) -> float: + async def check_amount(self, *, amount: float) -> float: if self.currency_profit != self.account.currency: amount = await self.convert_currency(amount=amount, base=self.currency_profit, quote=self.account.currency) return amount @@ -212,27 +204,19 @@ class Symbol(SymbolInfo): Returns: float: Amount in terms of the quote currency - - Raises: - ValueError: If conversion is impossible """ try: pair = f'{base}{quote}' - if self.account.has_symbol(pair): - tick = await self.info_tick(name=pair) - if tick is not None: - return amount / tick.ask + tick = await self.info_tick(name=pair) + if tick is not None: + return amount / tick.ask pair = f'{quote}{base}' - if self.account.has_symbol(pair): - tick = await self.info_tick(name=pair) - if tick is not None: - return amount * tick.bid + tick = await self.info_tick(name=pair) + if tick is not None: + return amount * tick.bid except Exception as err: - logger.warning(f'Currency conversion failed: Unable to convert {amount} in {quote} to {base}') - raise ValueError(f'Currency Conversion Failed: {err}') - else: - logger.warning(f'Currency conversion failed: Unable to convert {amount} in {quote} to {base}') + logger.warning(f'{err}: Currency conversion failed: Unable to convert {amount} in {quote} to {base}') @backoff_decorator async def copy_rates_from(self, *, timeframe: TimeFrame, diff --git a/src/aiomql/terminal.py b/src/aiomql/lib/terminal.py similarity index 71% rename from src/aiomql/terminal.py rename to src/aiomql/lib/terminal.py index 3a08115..3497673 100644 --- a/src/aiomql/terminal.py +++ b/src/aiomql/lib/terminal.py @@ -2,20 +2,22 @@ from typing import NamedTuple from logging import getLogger -from .core.models import TerminalInfo +from ..core.models import TerminalInfo +from ..core.base import _Base logger = getLogger(__name__) +Version = NamedTuple("Version", (('version', str), ('build', int), ('release_date', str))) -class Terminal(TerminalInfo): + +class Terminal(_Base, TerminalInfo): """Terminal Class. Get information about the MetaTrader 5 terminal. The class is a subclass of the TerminalInfo class. It inherits all the attributes and methods of the TerminalInfo class and adds some useful methods. Notes: Other attributes are defined in the TerminalInfo Class """ - - Version = NamedTuple("Version", (('version', str), ('build', int), ('release_date', str))) + version: Version | None = None async def initialize(self) -> bool: """Establish a connection with the MetaTrader 5 terminal. There are three call options. Call without parameters. @@ -26,30 +28,29 @@ class Terminal(TerminalInfo): Returns: bool: True if successful else False """ - self.connected = await self.mt5.initialize(**self.config.account_info()) - + self.connected = await self.mt5.initialize() if not self.connected: err = await self.mt5.last_error() - logger.critical(f'Failed to initialize Terminal. Error Code: {err}') - raise SystemExit - return self.connected + logger.warning(f'Failed to initialize Terminal. Error Code: {err}') + info = await self.info() + await self.get_version() + return bool(self.connected and info and self.version) - async def version(self): + async def get_version(self) -> Version | None: """Get the MetaTrader 5 terminal version. This method returns the terminal version, build and release date as a tuple of three values Returns: Version: version of tuple as Version object - - Raises: - ValueError: If the terminal version cannot be obtained """ res = await self.mt5.version() if res is None: - raise ValueError('Failed to get terminal version') - return self.Version(*res) + logger.error('Failed to get terminal version') + return None + self.version = Version(*res) + return self.version - async def info(self): + async def info(self) -> TerminalInfo | None: """Get the connected MetaTrader 5 client terminal status and settings. gets terminal info in the form of a named tuple structure (namedtuple). Return None in case of an error. The info on the error can be obtained using last_error(). @@ -58,7 +59,9 @@ class Terminal(TerminalInfo): Terminal: Terminal status and settings as a terminal object. """ info = await self.mt5.terminal_info() - self.set_attributes(**info._asdict()) + if info: + self.set_attributes(**info._asdict()) + return info async def symbols_total(self) -> int: """Get the number of all financial instruments in the MetaTrader 5 terminal. diff --git a/src/aiomql/ticks.py b/src/aiomql/lib/ticks.py similarity index 87% rename from src/aiomql/ticks.py rename to src/aiomql/lib/ticks.py index 4a3a947..3a37b8f 100644 --- a/src/aiomql/ticks.py +++ b/src/aiomql/lib/ticks.py @@ -1,15 +1,14 @@ """Module for working with price ticks.""" -from typing import TypeVar, Iterable +from typing import Iterable, Self +import time from pandas import DataFrame, Series import pandas_ta as ta import mplfinance as mplt import pandas as pd -from .core.constants import TickFlag - -Self = TypeVar('Self', bound='Ticks') +from ..core.constants import TickFlag class Tick: @@ -37,11 +36,13 @@ class Tick: Index: int def __init__(self, **kwargs): - """Initialize the Tick class. Set attributes from keyword arguments. bid, ask, last, time and volume must be + """Initialize the Tick class. Set attributes from keyword arguments. bid, ask, last and volume must be present""" - if not all(key in kwargs for key in ['bid', 'ask', 'last', 'volume', 'time']): + 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.Index = kwargs.pop('Index', 0) + self.time = kwargs.pop('time', time.monotonic()) + self.time_msc = int(self.time * 1000) self.set_attributes(**kwargs) def __repr__(self): @@ -49,6 +50,30 @@ class Tick: % {"class": self.__class__.__name__, "time": self.time, "bid": self.bid, "ask": self.ask, "last": self.last, "volume": self.volume, 'Index': self.Index}) + def __eq__(self, other: Self): + return self.time == other.time + + def __lt__(self, other: Self): + return self.time < other.time + + def __hash__(self): + return hash(self.time) + + def __getitem__(self, item): + return self.__dict__[item] + + def __setitem__(self, key, value): + self.__dict__[key] = value + + def __iter__(self): + return iter(self.__dict__.items()) + + def keys(self): + return self.__dict__.keys() + + def values(self): + return self.__dict__.values() + def dict(self, exclude: set = None, include: set = None) -> dict: """ Returns a dictionary of the instance attributes. @@ -70,9 +95,6 @@ class Tick: setattr(self, key, value) -_Ticks = TypeVar('_Ticks', bound='Ticks') - - class Ticks: """Container class for price ticks. Arrange in chronological order. Supports iteration, slicing and assignment""" time: Series @@ -85,7 +107,7 @@ class Ticks: volume_real: Series Index: Series - def __init__(self, *, data: DataFrame | Iterable, flip=False): + def __init__(self, *, data: DataFrame | Iterable | Self, flip=False): """Initialize the Ticks class. Creates a DataFrame of price ticks from the data argument. Args: @@ -162,7 +184,7 @@ class Ticks: """DataFrame of price ticks arranged in chronological order.""" return self._data - def rename(self, inplace=True, **kwargs) -> _Ticks | None: + def rename(self, inplace=True, **kwargs) -> Self | None: """Rename columns of the candle class. Keyword Args: @@ -190,13 +212,13 @@ class Ticks: data.index = pd.to_datetime(data['time'], unit='s') return mplt.make_addplot(data[columns], **kwargs) - def visualize(self, *, count: int = 50, type='candle', savefig: str | dict = None, addplot: dict = None, + def visualize(self, *, count: int = 50, _type='candle', savefig: str | dict = None, addplot: dict = None, style: str = 'charles', ylabel: str = 'Price', title: str = 'Chart', **kwargs): """Visualize the candles using the mplfinance library. Args: count (int): The number of candles to visualize, counting from behind, i.e the most recent candles. Defaults to 50. - type: Type of chart, defaults to candle + _type: Type of chart, defaults to candle savefig (str|dict): The path to save the figure or a dictionary of parameters to pass to the savefig method. addplot: Additional plots to add to the chart. Defaults to None. They should match the dimension of the original data which is specified via the count parameter. @@ -206,7 +228,7 @@ class Ticks: kwargs: valid kwargs for the plot function. """ kwargs |= {key: arg for key, arg in (('savefig', savefig), ('addplot', addplot), ('style', style), - ('ylabel', ylabel), ('title', title), ('type', type)) if arg} + ('ylabel', ylabel), ('title', title), ('type', _type)) if arg} data = self._data[-count:] data.index = pd.to_datetime(data['time'], unit='s') mplt.plot(data, **kwargs) diff --git a/src/aiomql/trade_records.py b/src/aiomql/lib/trade_records.py similarity index 81% rename from src/aiomql/trade_records.py rename to src/aiomql/lib/trade_records.py index f0d0a80..78d7ab8 100644 --- a/src/aiomql/trade_records.py +++ b/src/aiomql/lib/trade_records.py @@ -7,9 +7,9 @@ import csv import logging from typing import Iterable -from .core.config import Config -from .core.meta_trader import MetaTrader -from .contrib.backtester.meta_tester import MetaTester +from ..core.config import Config +from ..core.meta_trader import MetaTrader +from ..core.meta_backtester import MetaBackTester logger = logging.getLogger(__name__) @@ -23,7 +23,7 @@ class TradeRecords: from the config """ config: Config - mt5: MetaTrader | MetaTester + mt5: MetaTrader | MetaBackTester def __init__(self, *, records_dir: Path | str = ''): """Initialize the Records class. The main method of this class is update_records which you should call to update @@ -33,7 +33,7 @@ class TradeRecords: records_dir (Path): Absolute path to directory containing record of placed trades. """ self.config = Config() - self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester() + self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaBackTester() self.records_dir = records_dir or self.config.records_dir async def get_csv_records(self): @@ -63,16 +63,15 @@ class TradeRecords: file: Trade record file in csv format """ try: - fr = open(file, mode='r', newline='') - reader: Iterable[dict] | csv.DictReader = csv.DictReader(fr) - rows = [row for row in reader] - rows = await self.update_rows(rows=rows) - fr.close() - fw = open(file, mode='w', newline='') - writer = csv.DictWriter(fw, fieldnames=reader.fieldnames, extrasaction='ignore', restval=None) - writer.writeheader() - writer.writerows(rows) - fw.close() + with open(file, mode='r', newline='') as fr: + reader: Iterable[dict] | csv.DictReader = csv.DictReader(fr) + rows = [row for row in reader] + rows = await self.update_rows(rows=rows) + + with open(file, mode='w', newline='') as fw: # type: SupportsWrite[str] + writer = csv.DictWriter(fw, fieldnames=reader.fieldnames, extrasaction='ignore', restval=None) + writer.writeheader() + writer.writerows(rows) except Exception as err: logger.error(f'Error: {err}. Unable to read and update csv trade records') @@ -82,14 +81,13 @@ class TradeRecords: file: Trade record file in csv format """ try: - fh = open(file, mode='r') - data = json.load(fh) - rows = [row for row in data] - rows = await self.update_rows(rows=rows) - fh.close() - fh = open(file, mode='w') - json.dump(rows, fh, indent=2) - fh.close() + with open(file, mode='r') as fh: # type: SupportsRead[str | bytes] + data = json.load(fh) + rows = [row for row in data] + rows = await self.update_rows(rows=rows) + + with open(file, mode='w') as fh: # type: SupportsWrite[str] + json.dump(rows, fh, indent=2) except Exception as err: logger.error(f'Error: {err}. Unable to read and update json trade records') @@ -109,7 +107,7 @@ class TradeRecords: return row deals = [deal for deal in deals if (deal.order != deal.position_id and deal.position_id == order and deal.entry == 1)] - deals.sort(key=lambda x: x.time_msc) + deals.sort(key=lambda deal: deal.time_msc) deal = deals[-1] row.update(actual_profit=deal.profit, win=deal.profit > 0, closed=True) return row diff --git a/src/aiomql/trader.py b/src/aiomql/lib/trader.py similarity index 73% rename from src/aiomql/trader.py rename to src/aiomql/lib/trader.py index 753d0fb..a8b06bc 100644 --- a/src/aiomql/trader.py +++ b/src/aiomql/lib/trader.py @@ -3,33 +3,40 @@ from abc import ABC, abstractmethod from datetime import datetime from typing import TypeVar from logging import getLogger -from zoneinfo import ZoneInfo +import pytz + +from ..core.models import OrderType, OrderSendResult, OrderCheckResult +from ..core.config import Config +from ..core.task_queue import QueueItem +from .result import Result from .order import Order from .symbol import Symbol as _Symbol from .ticks import Tick from .ram import RAM -from .core.models import OrderType, OrderSendResult -from .core.config import Config -from .result import Result -from .core.task_queue import QueueItem logger = getLogger(__name__) Symbol = TypeVar("Symbol", bound=_Symbol) class Trader(ABC): - """Base class for creating a Trader object. Handles the creation of an order and the placing of trades. + """Base class for creating and managing orders. + Handles the creation and placing of an order. + It is an abstract class and must be subclassed to implement the place_trade method. + It has a set of methods that can be used to set the order limits and stop levels for the order. Attributes: symbol (Symbol): The financial instrument. ram (RAM): RAM instance order (Order): Trade order + parameters (dict): Parameters of the trading strategy used to place the trade Class Attributes: config (Config): Config instance. """ config: Config + ram: RAM + parameters: dict def __init__(self, *, symbol: Symbol, ram: RAM = None): """Initializes the order object and RAM instance @@ -82,47 +89,55 @@ class Trader(ABC): self.symbol.digits) self.order.price = tick.bid - async def check_order(self) -> bool: + async def check_order(self) -> OrderCheckResult | None: """Check order before sending it to the broker. Returns: bool: True if order can go through else false """ check = await self.order.check() - if check.retcode != 0: - logger.warning(f"Invalid order for {self.symbol} due to {check.comment}") - return False - return True - async def send_order(self) -> OrderSendResult: + if check is None: + logger.warning(f"{self.order.mt5.error}: Order check failed") + return check + + if check.retcode != 0: + logger.warning(f"Invalid order for due to {check.comment}") + else: + logger.info(f"Order check passed for {self.symbol}") + + return check + + async def send_order(self) -> OrderSendResult | None: """Send the order to the broker.""" result = await self.order.send() - if result.retcode != 10009: - logger.warning(f"Unable to place order for {self.symbol} due to {result.comment}") + if result is None: + logger.warning(f"{self.order.mt5.error}: Failed to place order.") return result - logger.info(f"Placed Trade for {self.symbol}") + + if result.retcode != 10009: + logger.warning(f"Unable to place order for due to {result.comment}") + return result + logger.info("Order placed successfully") return result - async def record_trade(self, result: OrderSendResult, parameters: dict = None, name: str = '', exclude: set = None): + async def record_trade(self, *, result: OrderSendResult, parameters: dict = None, name: str = ''): """Record the trade in csv or json. Args: result (OrderSendResult): Result of the order send parameters: parameters of the trading strategy used to place the trade name: Name of the trading strategy - exclude: Exclude these fields from the recorded trade """ if result.retcode != 10009 or not self.config.record_trades: return - params = parameters or self.parameters - params = {k: v for k, v in params.items() if k not in (exclude or set())} + params = parameters or {} profit = result.profit or await self.order.calc_profit() params["expected_profit"] = profit - date = datetime.utcnow() - date = date.replace(tzinfo=ZoneInfo("UTC")) + date = datetime.now(tz=pytz.UTC) params["date"] = str(date.date()) params["time"] = str(date.time()) res = Result(result=result, parameters=params, name=name) - self.config.task_queue.add(item=QueueItem(res.save, must_complete=True)) + self.config.task_queue.add(item=QueueItem(res.save), must_complete=True) @abstractmethod async def place_trade(self, *args, **kwargs): diff --git a/src/aiomql/records.py b/src/aiomql/records.py deleted file mode 100644 index e758329..0000000 --- a/src/aiomql/records.py +++ /dev/null @@ -1,117 +0,0 @@ -"""This module contains the Records class, which is used to read and update trade records from csv files.""" - -import asyncio -from pathlib import Path -import csv -import logging -from typing import Iterable - -from .contrib.backtester.meta_tester import MetaTester -from .core.config import Config -from .core.meta_trader import MetaTrader - -logger = logging.getLogger(__name__) - - -class Records: - """This utility class read trade records from csv files, and update them based on their closing positions. - - Attributes: - config: Config object - records_dir(Path): Absolute path to directory containing record of placed trades, If not given takes the default - from the config - """ - config: Config - mt5: MetaTrader - - def __init__(self, records_dir: Path | str = ''): - """Initialize the Records class. The main method of this class is update_records which you should call to update - all the records specified in the records_dir. - - Keyword Args: - records_dir (Path): Absolute path to directory containing record of placed trades. - """ - self.config = Config() - self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester() - self.records_dir = records_dir or self.config.records_dir - - async def get_records(self): - """Get trade records from records_dir folder - - Yields: - files: Trade record files - """ - for file in self.records_dir.iterdir(): - if file.is_file() and file.name.endswith('.csv'): - yield file - - async def read_update(self, file: Path): - """Read and update trade records - - Args: - file: Trade record file - """ - try: - fr = open(file, mode='r', newline='') - reader: Iterable[dict] | csv.DictReader = csv.DictReader(fr) - rows = [row for row in reader] - rows = await self.update_rows(rows) - fr.close() - fw = open(file, mode='w', newline='') - writer = csv.DictWriter(fw, fieldnames=reader.fieldnames, extrasaction='ignore', restval=None) - writer.writeheader() - writer.writerows(rows) - fw.close() - except Exception as err: - logger.error(f'Error: {err}. Unable to read and update trade records') - - async def update_row(self, row: dict) -> dict: - """Update a single row of entered trade in the csv file with the actual profit. - - Args: - row: A dictionary from the dictionary writer object of the csv file. - - Returns: - dict: A dictionary with the actual profit and win status. - """ - try: - order = int(row['order']) - deals = await self.mt5.history_deals_get(position=order) - if not deals or len(deals) <= 1: - return row - deals = [deal for deal in deals if (deal.order != deal.position_id and deal.position_id == order - and deal.entry == 1)] - deals.sort(key=lambda x: x.time_msc) - deal = deals[-1] - row.update(actual_profit=deal.profit, win=deal.profit > 0, closed=True) - return row - except Exception as err: - logging.error(f'Error: {err}. Unable to update trade record') - return row - - async def update_rows(self, rows: list[dict]) -> list[dict]: - """Update the rows of entered trades in the csv file with the actual profit. - - Args: - rows: A list of dictionaries from the dictionary writer object of the csv file. - - Returns: - list[dict]: A list of dictionaries with the actual profit and win status. - """ - closed, unclosed = [], [] - for row in rows: - if (row.get('closed', 'FALSE')).title() == 'True': - closed.append(row) - else: - unclosed.append(row) - unclosed = await asyncio.gather(*[self.update_row(row) for row in unclosed]) - return closed + list(unclosed) - - async def update_records(self): - """Update trade records in the records_dir folder.""" - records = [self.read_update(record) async for record in self.get_records()] - await asyncio.gather(*records) - - async def update_record(self, file: Path | str): - """Update a single trade record file.""" - await self.read_update(file) diff --git a/src/aiomql/strategy.py b/src/aiomql/strategy.py deleted file mode 100644 index c9648a0..0000000 --- a/src/aiomql/strategy.py +++ /dev/null @@ -1,83 +0,0 @@ -"""The base class for creating strategies.""" -import asyncio -from time import time -from typing import TypeVar -from abc import ABC, abstractmethod -from datetime import time as dtime - -from .core.meta_trader import MetaTrader -from .symbol import Symbol as _Symbol -from .core import Config -from .contrib.backtester.meta_tester import MetaTester -from .sessions import Sessions, Session - -Symbol = TypeVar("Symbol", bound=_Symbol) - - -class Strategy(ABC): - """The base class for creating strategies. - - Attributes: - name (str): The name of the strategy. - symbol (Symbol): The Financial Instrument as a Symbol Object - parameters (Dict): A dictionary of parameters for the strategy. - sessions (Sessions): The sessions to use for the strategy. - - Notes: - Define the name of a strategy as a class attribute. If not provided, the class name will be used as the name. - """ - name: str - symbol: Symbol - sessions: Sessions - mt5: MetaTrader - config: Config - parameters = {} - - def __init__(self, *, symbol: Symbol, params: dict = None, sessions: Sessions = None, name=''): - """Initiate the parameters dict and add name and symbol fields. - Use class name as strategy name if name is not provided - - Args: - symbol (Symbol): The Financial instrument - params (Dict): Trading strategy parameters - """ - self.parameters = self.parameters | (params or {}) - self.symbol = symbol - self.name = name or self.__class__.__name__ - self.parameters["symbol"] = symbol.name - self.parameters["name"] = self.name - self.sessions = sessions or Sessions(sessions=[Session(start=0, end=dtime(hour=23, minute=59, second=59))]) - self.config = Config() - self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester() - - def __repr__(self): - return f"{self.name}({self.symbol!r})" - - def __getattr__(self, item): - if item in self.parameters: - return self.parameters[item] - raise AttributeError(f'{item} not an attribute of {self.name}') - - def __setattr__(self, key, value): - if key in self.__dict__.get('parameters', {}): - self.parameters[key] = value - super().__setattr__(key, value) - - @staticmethod - async def sleep(secs: float): - """Sleep for the needed amount of seconds in between requests to the terminal. - computes the accurate amount of time needed to sleep ensuring that the next request is made at the start of - a new bar and making cooperative multitasking possible. - - Args: - secs (float): The time in seconds. Usually the timeframe you are trading on. - """ - mod = time() % secs - secs = secs - mod if mod != 0 else mod - await asyncio.sleep(secs + 0.2) - - @abstractmethod - async def trade(self): - """Place trades using this method. This is the main method of the strategy. - It will be called by the strategy runner. - """ diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 67c5895..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest -from aiomql import Config -import MetaTrader5 - - -@pytest.fixture(scope='session', autouse=True) -def config(): - config = Config(filename='test.json') - return config - - -@pytest.fixture(scope='session', autouse=True) -def metatrader5(): - return MetaTrader5 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5a03298 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,93 @@ +import asyncio +import json +import shutil +from logging import getLogger +from pathlib import Path + +import pytest +from aiomql.core import Config +from aiomql.core.meta_trader import MetaTrader + +logger = getLogger(__name__) + + +async def cleanup(): + try: + shutil.rmtree(Path('tests/configs'), ignore_errors=True) + Path.unlink(Path('tests/test.json'), missing_ok=True) + shutil.rmtree(Path('tests/trade_records'), ignore_errors=True) + await close_all_positions() + await MetaTrader().shutdown() + except Exception as err: + logger.error(f"Failed to complete cleanup: {err}") + + +async def close_all_positions(): + try: + mt = MetaTrader() + positions = await mt.positions_get() + tasks = [] + for position in positions: + order_type = mt.ORDER_TYPE_BUY if position.type == mt.ORDER_TYPE_SELL else mt.ORDER_TYPE_SELL + req = {'action': mt.TRADE_ACTION_DEAL, 'symbol': position.symbol, 'volume': position.volume, + 'type': order_type, 'position': position.ticket, 'price': position.price_current} + tasks.append(mt.order_send(req)) + await asyncio.gather(*tasks) + except Exception as err: + logger.error(f"Failed to close all positions: {err}") + + +@pytest.fixture(scope='session', autouse=True) +async def config(request): + Path('tests/configs').mkdir(exist_ok=True) + with open('aiomql.json', 'r') as fh, open('tests/configs/test2.json', 'w') as fh1, open('tests/test.json', 'w') as fh2: + data = json.load(fh) + json.dump(data, fh1, indent=2) + json.dump(data, fh2, indent=2) + config = Config(filename='test.json', root='tests') + yield config + await cleanup() + + +@pytest.fixture(scope='session') +async def mt(): + mt = MetaTrader() + await mt.initialize() + await mt.login() + yield mt + await mt.shutdown() + + +@pytest.fixture(scope='function') +async def sell_order(mt): + sym = 'BTCUSD' + sym_info = await mt.symbol_info(sym) + return {'action': mt.TRADE_ACTION_DEAL, 'symbol': sym, 'volume': sym_info.volume_min, + 'type': mt.ORDER_TYPE_SELL, 'price': sym_info.bid} + + +@pytest.fixture(scope='function') +async def buy_order(mt): + sym = 'BTCUSD' + sym_info = await mt.symbol_info(sym) + dsl = (sym_info.trade_stops_level + sym_info.spread) * 2 * sym_info.point + sl = sym_info.ask - dsl + tp = sym_info.ask + dsl + return {'action': mt.TRADE_ACTION_DEAL, 'symbol': sym, 'volume': sym_info.volume_min, + 'type': mt.ORDER_TYPE_BUY, 'price': sym_info.ask, 'sl': sl, 'tp': tp} + +@pytest.fixture(scope='class') +async def make_orders(mt): + sym = 'BTCUSD' + sym_info = await mt.symbol_info(sym) + dsl = (sym_info.trade_stops_level + sym_info.spread) * 2 * sym_info.point + sl = sym_info.ask - dsl + tp = sym_info.ask + dsl + req = {'action': mt.TRADE_ACTION_DEAL, 'symbol': sym, 'volume': sym_info.volume_min, + 'type': mt.ORDER_TYPE_BUY, 'price': sym_info.ask, 'sl': sl, 'tp': tp} + await mt.order_send(req) + req['type'] = mt.ORDER_TYPE_SELL + req['price'] = sym_info.bid + req['sl'] = sym_info.bid + dsl + req['tp'] = sym_info.bid - dsl + await mt.order_send(req) diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..e46e79b --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +asyncio_default_fixture_loop_scope = session +addopts = --rootdir=tests --capture=tee-sys --last-failed +asyncio_mode = auto diff --git a/tests/test.json b/tests/test.json deleted file mode 100644 index e322524..0000000 --- a/tests/test.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "login": 31288540, - "password": "nwa0#anaEze", - "server": "Deriv-Demo", - "demo": 5463204, - "fin": 24251812, - "deriv-demo": 5463204, - "deriv-real": 31288540, - "mode": "backtest" -} diff --git a/tests/unittests/test_account.py b/tests/unittests/test_account.py new file mode 100644 index 0000000..6f27daf --- /dev/null +++ b/tests/unittests/test_account.py @@ -0,0 +1,20 @@ +import pytest +from aiomql.lib.account import Account + +class TestAccount: + + @classmethod + def setup_class(cls): + cls.account = Account() + + @pytest.fixture(scope='class', autouse=True) + async def refresh(self): + await self.account.refresh() + + async def test_connected(self): + assert self.account.connected is True + + async def test_account_info(self): + acc_info = await self.account.mt5.account_info() + assert acc_info.login == self.account.login + assert acc_info.server == self.account.server diff --git a/tests/unittests/test_base.py b/tests/unittests/test_base.py new file mode 100644 index 0000000..1349fee --- /dev/null +++ b/tests/unittests/test_base.py @@ -0,0 +1,60 @@ +import pytest +from aiomql.core.base import Base + + +class ChildClass(Base): + attr: int + attr2: str + cls_attr: int = 10 + + +class TestBaseClass: + @pytest.fixture + def child(self): + return ChildClass(attr=1, attr2="test") + + def test_repr(self, child): + repr_str = repr(child) + assert repr_str.startswith("ChildClass(") + assert "attr=1" in repr_str + assert "attr2=test" in repr_str + + def test_set_attributes(self, child): + child.set_attributes(attr3=3.14, attr2='str') + assert child.attr2 == 'str' + assert getattr(child, 'attr3', None) is None + + def test_annotations(self, child): + annotations = child.annotations + assert isinstance(annotations, dict) + + def test_get_dict(self, child): + child.set_attributes(attr2='test') + result = child.get_dict() + assert result["attr"] == 1 + assert result["attr2"] == "test" + + def test_get_dict_with_exclude(self, child): + child.set_attributes(attr2='test') + result = child.get_dict(exclude={"attr"}) + assert "attr" not in result + assert result["attr2"] == "test" + + def test_get_dict_with_include(self, child): + child.set_attributes(attr3=3.14) + result = child.get_dict(include={"attr"}) + assert result["attr"] == 1 + assert "attr2" not in result + + def test_class_vars(self, child): + class_vars = child.class_vars + assert isinstance(class_vars, dict) + assert 'cls_attr' in class_vars + assert 'attr' not in class_vars + + def test_dict_property(self, child): + child.set_attributes(attr2="test") + dict_prop = child.dict + assert dict_prop["attr"] == 1 + assert dict_prop["attr2"] == "test" + assert dict_prop["cls_attr"] == 10 diff --git a/tests/unittests/test_candles.py b/tests/unittests/test_candles.py new file mode 100644 index 0000000..ddea024 --- /dev/null +++ b/tests/unittests/test_candles.py @@ -0,0 +1,106 @@ +from datetime import datetime + +import pytest +import pytz +import pandas as pd +from aiomql.lib.candle import Candle, Candles +from aiomql.core.meta_trader import MetaTrader +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) + + def test_repr(self): + repr_str = repr(self.bearish_candle) + assert repr_str.startswith("Candle(") + assert "open=" in repr_str + assert "high=" in repr_str + assert "low=" in repr_str + assert "close=" in repr_str + + def test_set_attributes(self): + self.bearish_candle.set_attributes(ema=10) + assert self.bearish_candle.ema == 10 + + def test_compare(self): + assert self.bearish_candle > self.bullish_candle + assert self.bullish_candle != self.bearish_candle + assert self.bullish_candle < self.bearish_candle + + def test_dict(self): + self.bearish_candle.set_attributes(ema=10) + result = self.bearish_candle.dict(exclude={'time'}) + result2 = self.bearish_candle.dict(include={'close', 'high'}) + assert result['open'] == 1.3452 + assert result['ema'] == 10 + assert 'time' not in result + assert set(result2.keys()) == {'close', 'high'} + + def test_dictionary_properties(self): + self.bearish_candle['ema'] = 4 + assert self.bearish_candle['ema'] == 4 + + def test_candle_type(self): + assert self.bearish_candle.is_bearish() + assert self.bullish_candle.is_bullish() + + +class TestCandles: + @pytest.fixture(scope='class') + async def candles(self): + mt = MetaTrader() + start = datetime(day=5, month=10, year=2023) + rates = await mt.copy_rates_from("BTCUSD", mt.TIMEFRAME_H1, start, 200) + return Candles(data=rates) + + + def test_get_series(self, candles): + series = candles['open'] + assert isinstance(series, pd.Series) + assert len(series) == 200 + + def test_get_candle(self, candles): + candle = candles[10] + assert isinstance(candle, Candle) + assert candle in candles + assert candle.Index == 10 + + def test_slice(self, candles): + sliced = candles[10:15] + assert len(sliced) == 5 + assert isinstance(sliced, Candles) + + def test_setitem(self, candles): + new_series = candles.open + new_series = new_series * 2 + candles['double_open'] = new_series + assert 'double_open' in candles.data.columns + + def test_getattr(self, candles): + open_series = candles.open + assert isinstance(open_series, pd.Series) + assert open_series.equals(candles.data['open']) + + def test_iter(self, candles): + l_5 = candles[-5:] + assert all(isinstance(candle, Candle) for candle in l_5) + + def test_timeframe(self, candles): + tf = candles.timeframe + assert tf == TimeFrame.H1 + + def test_ta_and_rename(self, candles): + ema = candles.ta.ema(close='open', length=10, append=True) + assert 'EMA_10' in candles.data.columns + candles.rename(inplace=True, EMA_10='ema') + assert 'ema' in candles.data.columns + + def test_ta_lib(self, candles): + fas = candles.ta_lib.above(candles.open, candles.close) + assert isinstance(fas, pd.Series) + candles['fas'] = fas + assert 'fas' in candles.data.columns diff --git a/tests/unittests/test_config.py b/tests/unittests/test_config.py new file mode 100644 index 0000000..6b16720 --- /dev/null +++ b/tests/unittests/test_config.py @@ -0,0 +1,29 @@ +from aiomql.core.config import Config +from aiomql.contrib.backtesting import BackTestEngine + + +class TestConfig: + def test_singleton(self, config): + config2 = Config(filename='test.json') + assert config is config2 + + def test_set_attributes(self, config): + config.set_attributes(timeout=5000, record_trades=False) + assert config.timeout == 5000 + assert config.record_trades is False + + def test_backtest_engine(self, config): + engine = BackTestEngine() + config.backtest_engine = engine + assert config.backtest_engine is engine + + def test_account_info(self, config): + account_info = config.account_info() + assert isinstance(account_info, dict) + assert 'login' in account_info + assert 'password' in account_info + assert 'server' in account_info + + def test_load_config(self, config): + config.load_config(file='tests/configs/test2.json') + assert config.filename == 'test2.json' diff --git a/tests/unittests/test_history.py b/tests/unittests/test_history.py new file mode 100644 index 0000000..ab19bc6 --- /dev/null +++ b/tests/unittests/test_history.py @@ -0,0 +1,49 @@ +from datetime import datetime + +import pytest +from aiomql.lib.history import History + +class TestHistory: + @pytest.fixture(scope='class', autouse=True) + async def init(self, make_orders): + await self.history.init() + + @classmethod + def setup_class(cls): + now = datetime.now() + cls.start = now.replace(hour=0) + cls.end = now.replace(hour=23) + history = History(date_from=cls.start, date_to=cls.end) + cls.history = history + + async def test_init(self): + assert self.history.total_deals > 0 + assert self.history.total_orders > 0 + + async def test_get_deals(self): + deals = await self.history.get_deals() + assert len(deals) > 0 + + async def test_get_deals_by_ticket(self): + ticket = self.history.deals[0].order + deals = self.history.get_deals_by_ticket(ticket=ticket) + assert len(deals) > 0 + + async def test_get_deals_by_position(self): + position = self.history.deals[0].position_id + deals = self.history.get_deals_by_position(position=position) + assert len(deals) > 0 + + async def test_get_orders(self): + orders = await self.history.get_orders() + assert len(orders) > 0 + + async def test_get_orders_by_ticket(self): + ticket = self.history.orders[0].ticket + orders = self.history.get_orders_by_ticket(ticket=ticket) + assert len(orders) > 0 + + async def test_get_orders_by_position(self): + position = self.history.orders[0].position_id + orders = self.history.get_orders_by_position(position=position) + assert len(orders) > 0 diff --git a/tests/test_meta_trader.py b/tests/unittests/test_meta_trader.py similarity index 51% rename from tests/test_meta_trader.py rename to tests/unittests/test_meta_trader.py index 515f72f..9315f00 100644 --- a/tests/test_meta_trader.py +++ b/tests/unittests/test_meta_trader.py @@ -1,199 +1,207 @@ from datetime import datetime, timedelta -import pytest import pytz -import pytest_asyncio -from aiomql import MetaTrader, TimeFrame, OrderType, CopyTicks +import MetaTrader5 -from . import metatrader5 +from aiomql import MetaTrader class TestMetaTrader: - @classmethod - def setup_class(cls, metatrader5): - tz = pytz.timezone('Etc/UTC') + def setup_class(cls): cls.mt = MetaTrader() - cls.mt5 = metatrader5 - cls.symbol = "Volatility 100 Index" - now = datetime.now(tz=tz) - cls.start = now - timedelta(hours=24) - cls.end = now + timedelta(hours=2) + cls.mt5 = MetaTrader5 + cls.symbol = "BTCUSD" + now = datetime.now(tz=pytz.UTC) + cls.start = now - timedelta(hours=10) + cls.end = now + timedelta(hours=1) cls.tf = cls.mt.TIMEFRAME_H1 - @pytest.mark.asyncio + @classmethod + def teardown_class(cls): + cls.mt._shutdown() + async def test_initialize(self): res = await self.mt.initialize() assert res == True - @pytest.mark.asyncio async def test_login(self): res = await self.mt.login() assert res == True - @pytest.mark.asyncio async def test_last_error(self): res = await self.mt.last_error() assert isinstance(res, tuple) assert res[0] == 1 - assert res[1] == 'Successful' - - @pytest.mark.asyncio + assert res[1] == 'Success' + async def test_version(self): res = await self.mt.version() - res2 = self.mt5.version() + res2 = self.mt5.version() assert res is not None assert res == res2 - - @pytest.mark.asyncio + async def test_account_info(self): res = await self.mt.account_info() res2 = self.mt5.account_info() assert res is not None assert res == res2 - - @pytest.mark.asyncio + async def test_terminal_info(self): res = await self.mt.terminal_info() - res2 = await self.mt5.terminal_info() + res2 = self.mt5.terminal_info() assert res is not None assert res == res2 - @pytest.mark.asyncio async def test_symbols_total(self): res = await self.mt.symbols_total() - res2 = await self.mt5.symbols_total() + res2 = self.mt5.symbols_total() assert isinstance(res, int) - assert res - - @pytest.mark.asyncio + assert res == res2 + async def test_symbols_get(self): res = await self.mt.symbols_get() - res2 = await self.mt5.symbols_get() + res2 = self.mt5.symbols_get() assert res is not None - assert res == res2 - - @pytest.mark.asyncio + assert len(res) == len(res2) + async def test_symbol_info(self): res = await self.mt.symbol_info(self.symbol) - res2 = await self.mt5.symbol_info(self.symbol) + res2 = self.mt5.symbol_info(self.symbol) assert res is not None assert res == res2 - - @pytest.mark.asyncio + async def test_symbol_info_tick(self): res = await self.mt.symbol_info_tick(self.symbol) - res2 = await self.mt5.symbol_info_tick(self.symbol) + res2 = self.mt5.symbol_info_tick(self.symbol) assert res is not None assert res == res2 - - @pytest.mark.asyncio + async def test_symbol_select(self): res = await self.mt.symbol_select(self.symbol, True) assert res == True - - @pytest.mark.asyncio + async def test_market_book_add(self): res = await self.mt.market_book_add(self.symbol) assert res == True - - @pytest.mark.asyncio + async def test_market_book_get(self): res = await self.mt.market_book_get(self.symbol) - res2 = await self.mt5.market_book_get(self.symbol) + res2 = self.mt5.market_book_get(self.symbol) assert res is not None assert res == res2 - - @pytest.mark.asyncio + async def test_market_book_release(self): res = await self.mt.market_book_release(self.symbol) assert res == True - - @pytest.mark.asyncio + async def test_copy_rates_from(self): res = await self.mt.copy_rates_from(self.symbol, self.tf, self.start, 10) assert res is not None assert res.shape[0] == 10 - - @pytest.mark.asyncio + async def test_copy_rates_from_pos(self): res = await self.mt.copy_rates_from_pos(self.symbol, self.tf, 0, 10) assert res is not None assert res.shape[0] == 10 - - @pytest.mark.asyncio + async def test_copy_rates_range(self): - res = await self.mt.copy_rates_range(self.symbol, TimeFrame.M1, datetime.now(), datetime.now()) + res = await self.mt.copy_rates_range(self.symbol, self.tf, self.start, self.end) assert res is not None - - @pytest.mark.asyncio + assert res.shape[0] == 10 + async def test_copy_ticks_from(self): - res = await self.mt.copy_ticks_from(self.symbol, datetime.now(), 10, CopyTicks.ALL) + res = await self.mt.copy_ticks_from(self.symbol, self.start, 10, self.mt.COPY_TICKS_ALL) assert res is not None - - @pytest.mark.asyncio + assert res.shape[0] == 10 + async def test_copy_ticks_range(self): - res = await self.mt.copy_ticks_range(self.symbol, datetime.now(), datetime.now(), CopyTicks.ALL) + res = await self.mt.copy_ticks_range(self.symbol, self.start, self.end, self.mt.COPY_TICKS_ALL) + res2 = self.mt5.copy_ticks_range(self.symbol, self.start, self.end, self.mt5.COPY_TICKS_ALL) assert res is not None - - @pytest.mark.asyncio + assert res.shape[0] == res2.shape[0] + async def test_orders_total(self): res = await self.mt.orders_total() assert isinstance(res, int) - - @pytest.mark.asyncio + async def test_orders_get(self): res = await self.mt.orders_get() assert res is not None - - @pytest.mark.asyncio - async def test_order_calc_margin(self): - res = await self.mt.order_calc_margin(OrderType.BUY, self.symbol, 1.0, 1.0) + assert isinstance(res, tuple) + assert len(res) == 0 + + async def test_order_calc_margin(self, sell_order): + price = sell_order['price'] + volume = sell_order['volume'] + type_ = sell_order['type'] + res = await self.mt.order_calc_margin(type_, self.symbol, volume, price) assert isinstance(res, float) - - @pytest.mark.asyncio - async def test_order_calc_profit(self): - res = await self.mt.order_calc_profit(OrderType.BUY, self.symbol, 1.0, 1.0, 1.1) + + async def test_order_calc_profit(self, buy_order): + volume = buy_order['volume'] + price_open = buy_order['price'] + price_close = buy_order['tp'] + type_ = buy_order['type'] + res = await self.mt.order_calc_profit(type_, self.symbol, volume, price_open, price_close) assert isinstance(res, float) - - @pytest.mark.asyncio - async def test_order_check(self): - request = {"action": OrderType.BUY, "symbol": self.symbol, "volume": 1.0, "price": 1.0} - res = await self.mt.order_check(request) + + async def test_order_check(self, buy_order): + res = await self.mt.order_check(buy_order) assert res is not None - - @pytest.mark.asyncio - async def test_order_send(self): - request = {"action": OrderType.BUY, "symbol": self.symbol, "volume": 1.0, "price": 1.0} - res = await self.mt.order_send(request) + assert res.retcode == 0 + + async def test_order_send(self, sell_order): + res = await self.mt.order_send(sell_order) assert res is not None - - @pytest.mark.asyncio + assert res.retcode == 10009 + async def test_positions_total(self): res = await self.mt.positions_total() assert isinstance(res, int) - - @pytest.mark.asyncio + assert res >= 0 + async def test_positions_get(self): res = await self.mt.positions_get() assert res is not None - - @pytest.mark.asyncio + assert isinstance(res, tuple) + assert len(res) >= 0 + async def test_history_orders_total(self): - res = await self.mt.history_orders_total(datetime.now(), datetime.now()) + res = await self.mt.history_orders_total(self.start, self.end) assert isinstance(res, int) - - @pytest.mark.asyncio + assert res >= 0 + async def test_history_orders_get(self): - res = await self.mt.history_orders_get(datetime.now(), datetime.now()) + res = await self.mt.history_orders_get(self.start, self.end) assert res is not None - - @pytest.mark.asyncio + assert isinstance(res, tuple) + assert len(res) >= 0 + async def test_history_deals_total(self): - res = await self.mt.history_deals_total(datetime.now(), datetime.now()) + res = await self.mt.history_deals_total(self.start, self.end) assert isinstance(res, int) - - @pytest.mark.asyncio + assert res >= 0 + async def test_history_deals_get(self): - res = await self.mt.history_deals_get(datetime.now(), datetime.now()) + res = await self.mt.history_deals_get(self.start, self.end) assert res is not None + assert isinstance(res, tuple) + assert len(res) >= 0 + + +# sym = order_request['symbol'] +# sym_info = await self.mt.symbol_info(sym) +# dsl = (sym_info.trade_stops_level + sym_info.spread) * 2 * sym_info.point +# order_request['volume'] = sym_info.volume_min +# order_request['price'] = sym_info.ask +# order_request['tp'] = round(sym_info.ask + dsl, sym_info.digits) +# order_request['sl'] = round(sym_info.ask - dsl, sym_info.digits) + +# sym = order_request['symbol'] +# sym_info = await self.mt.symbol_info(sym) +# dsl = (sym_info.trade_stops_level + sym_info.spread) * 2 * sym_info.point +# order_request['volume'] = sym_info.volume_min +# order_request['price'] = sym_info.ask +# order_request['tp'] = round(sym_info.ask + dsl, sym_info.digits) +# order_request['sl'] = round(sym_info.ask - dsl, sym_info.digits) diff --git a/tests/unittests/test_order.py b/tests/unittests/test_order.py new file mode 100644 index 0000000..e1ac75b --- /dev/null +++ b/tests/unittests/test_order.py @@ -0,0 +1,27 @@ +from aiomql.lib.order import Order + + +class TestOrder: + async def test_check(self, sell_order): + order = Order(**sell_order) + check = await order.check() + assert check.retcode == 0 + + async def test_send(self, buy_order): + order = Order(**buy_order) + send = await order.send() + assert send.retcode == 10009 + + async def test_margin(self, buy_order): + order = Order(**buy_order) + margin = await order.calc_margin() + assert margin is not None + assert margin > 0 + assert isinstance(margin, float) + + async def test_profit(self, buy_order): + order = Order(**buy_order) + profit = await order.calc_profit() + assert profit is not None + assert profit > 0 + assert isinstance(profit, float) diff --git a/tests/unittests/test_positions.py b/tests/unittests/test_positions.py new file mode 100644 index 0000000..dcf4057 --- /dev/null +++ b/tests/unittests/test_positions.py @@ -0,0 +1,30 @@ +import pytest + +from aiomql.lib.positions import Positions + + +class TestPositions: + @pytest.fixture(scope='class', autouse=True) + async def init(self, make_orders): + await self.positions.get_positions() + + @classmethod + def setup_class(cls): + cls.positions = Positions() + + @pytest.mark.order(1) + async def test_get_positions(self): + await self.positions.get_positions() + assert len(self.positions.positions) >= 0 + + async def test_get_position_by_ticket(self): + ticket = self.positions.positions[0].ticket + position = await self.positions.get_position_by_ticket(ticket=ticket) + assert position is not None + assert position.ticket == ticket + + async def test_get_position_by_symbol(self): + symbol = self.positions.positions[0].symbol + positions = await self.positions.get_position_by_symbol(symbol=symbol) + assert len(positions) >= 0 + assert positions[0].symbol == symbol diff --git a/tests/unittests/test_ram.py b/tests/unittests/test_ram.py new file mode 100644 index 0000000..ca025f9 --- /dev/null +++ b/tests/unittests/test_ram.py @@ -0,0 +1,22 @@ +from aiomql.lib.ram import RAM + + +class TestRAM: + @classmethod + def setup_class(cls): + cls.ram = RAM(min_amount=5, max_amount=10, loss_limit=3, open_limit=5) + + async def test_get_amount(self): + res = await self.ram.get_amount() + assert self.ram.min_amount <= res <= self.ram.max_amount + + async def test_checks(self, buy_order, sell_order, mt): + for i in range(self.ram.open_limit+1): + if i % 2 == 0: + await mt.order_send(buy_order) + else: + await mt.order_send(sell_order) + res1 = await self.ram.check_losing_positions() + res2 = await self.ram.check_open_positions() + assert res2 is False + assert isinstance(res1, bool) diff --git a/tests/unittests/test_result.py b/tests/unittests/test_result.py new file mode 100644 index 0000000..aa34390 --- /dev/null +++ b/tests/unittests/test_result.py @@ -0,0 +1,44 @@ +import asyncio + +import pytest + +from aiomql.lib.result import Result +from aiomql.core.models import OrderSendResult + + +class TestResult: + + @pytest.fixture(scope='class') + def parameters(self): + return {'name': 'test_trades', 'ema': 20, 'rsi': 14} + + @pytest.fixture(scope='function') + async def order_results(self, mt, sell_order, buy_order, parameters): + res1 = await mt.order_send(sell_order) + res2 = await mt.order_send(buy_order) + res1 = Result(result=OrderSendResult(**res1._asdict()), parameters=parameters) + res2 = Result(result=OrderSendResult(**res2._asdict()), parameters=parameters) + return res1, res2 + + async def test_get_data(self, order_results): + res1, res2 = order_results + data1 = res1.get_data() + data2 = res2.get_data() + assert data1['actual_profit'] == data2['actual_profit'] == 0 + assert data1['closed'] == data2['closed'] == False + assert data1['win'] == data2['win'] == False + + + async def test_csv(self, order_results): + res1, res2 = order_results + await asyncio.gather(res1.save(), res2.save()) + assert res1.config.records_dir.exists() + record = res1.config.records_dir / f"{res1.name}.csv" + assert record.exists() + + async def test_json(self, order_results): + res1, res2 = order_results + await asyncio.gather(res1.save(trade_record_mode='json'), res2.save(trade_record_mode='json')) + assert res1.config.records_dir.exists() + record = res1.config.records_dir / f"{res1.name}.json" + assert record.exists() diff --git a/tests/unittests/test_sessions.py b/tests/unittests/test_sessions.py new file mode 100644 index 0000000..c26536d --- /dev/null +++ b/tests/unittests/test_sessions.py @@ -0,0 +1,68 @@ +from datetime import datetime, time + +import pytest +import pytz + +from aiomql.lib.sessions import Session, Sessions, delta + + +class TestSessions: + @pytest.fixture(scope='class') + def make_sessions(self, make_session): + london, all_day, over_night = make_session + return Sessions(sessions=[london, all_day, over_night]) + + @pytest.fixture(scope='class') + def make_session(self): + end = time(hour=16, minute=59, second=59, microsecond=999_999, tzinfo=pytz.UTC) + london = Session(start=8, end=end, name='London', on_end='close_all') + start, end = time(hour=0, tzinfo=pytz.UTC), time(hour=23, minute=59, second=59, tzinfo=pytz.UTC) + all_day = Session(start=start, end=end, name='AllDay', on_end='close_all') + end = time(hour=6, minute=59, second=59, microsecond=999_999, tzinfo=pytz.UTC) + over_night = Session(start=18, end=end, name='OverNight', on_end='close_all') + return london, all_day, over_night + + def test_session_attributes(self, make_session): + london, all_day, over_night = make_session + period = over_night.duration() + assert london.name == 'London' + assert london.start == time(hour=8, tzinfo=pytz.UTC) + assert london.end.hour == 16 + assert period.hours == 12 + assert period.minutes == period.seconds == 59 + + def test_session_intervals(self, make_session): + london, all_day, over_night = make_session + two_am = time(hour=2, tzinfo=pytz.UTC) + noon = time(hour=12, tzinfo=pytz.UTC) + now = datetime.now(pytz.UTC).time() + hours_till_london_starts = (delta(london.start) - delta(now)).seconds // 3600 + assert hours_till_london_starts == london.until() // 3600 + assert two_am in over_night + assert noon in london + assert two_am not in london + assert noon not in over_night + # all_day session is always open + assert all_day.in_session() + + async def test_sessions(self, make_session): + london, all_day, over_night = make_session + sessions = Sessions(sessions=[london, over_night]) + now = time(hour=21, tzinfo=pytz.UTC) + noon = time(hour=12, tzinfo=pytz.UTC) + mid_nite = time(hour=0, tzinfo=pytz.UTC) + next_sess = sessions.find_next(moment=now) + noon_sess = sessions.find(moment=noon) + no_sess = sessions.find(moment=time(hour=17, tzinfo=pytz.UTC)) + mid_nite_sess = sessions.find(moment=mid_nite) + current_sess = sessions.find(moment=now) + assert current_sess.name == 'OverNight' + assert noon_sess.name == 'London' + assert no_sess is None + assert next_sess.name == 'London' + assert mid_nite_sess.name == 'OverNight' + current = datetime.now(pytz.UTC).time() + if current.hour not in (7, 17): + await sessions.check() + assert sessions.current_session is not None + assert sessions.current_session.name in ('London', 'OverNight') diff --git a/tests/unittests/test_symbol.py b/tests/unittests/test_symbol.py new file mode 100644 index 0000000..076dd78 --- /dev/null +++ b/tests/unittests/test_symbol.py @@ -0,0 +1,53 @@ +from datetime import datetime, timedelta + +import pytest + +from aiomql.lib.symbol import Symbol +from aiomql.lib.candle import Candles +from aiomql.lib.ticks import Ticks + + +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: + await symbol.init() + return symbol + + async def test_symbol_attributes(self, btc): + assert btc.name == 'BTCUSD' + assert btc.select is True + assert btc.tick is not None + + async def test_volume(self, btc): + volume = btc.volume_min - btc.volume_step + success, volume = btc.check_volume(volume=volume) + assert success is False + volume = btc.volume_min + btc.volume_step * 2 + success, volume = btc.check_volume(volume=volume) + assert success is True + volume = btc.volume_min + btc.volume_step * 2.5 + volume = btc.round_off_volume(volume=volume, round_down=True) + assert volume == btc.volume_min + btc.volume_step * 2 + + async def test_rates(self, btc): + start = datetime(year=2023, month=10, day=5) + end = start + timedelta(hours=9) + rates_from = await btc.copy_rates_from(timeframe=btc.mt5.TIMEFRAME_H1, date_from=start, count=10) + assert isinstance(rates_from, Candles) + assert len(rates_from) == 10 + rates_from_pos = await btc.copy_rates_from_pos(timeframe=btc.mt5.TIMEFRAME_H1, count=10, start_position=0) + assert isinstance(rates_from_pos, Candles) + assert len(rates_from_pos) == 10 + rates_range = await btc.copy_rates_range(timeframe=btc.mt5.TIMEFRAME_H1, date_from=start, date_to=end) + assert isinstance(rates_range, Candles) + assert len(rates_range) == 10 + ticks_from = await btc.copy_ticks_from(date_from=start, count=10) + assert isinstance(ticks_from, Ticks) + assert len(ticks_from) == 10 + end = start + timedelta(seconds=20) + ticks_from_pos = await btc.copy_ticks_range(date_from=start, date_to=end) + assert isinstance(ticks_from_pos, Ticks) + assert len(ticks_from_pos) >= 10 diff --git a/tests/unittests/test_terminal.py b/tests/unittests/test_terminal.py new file mode 100644 index 0000000..9d83cfc --- /dev/null +++ b/tests/unittests/test_terminal.py @@ -0,0 +1,17 @@ +import pytest + +from aiomql.lib.terminal import Terminal + + +class TestTerminal: + @pytest.fixture(scope='class', autouse=True) + async def init_terminal(self): + terminal = Terminal() + init = await terminal.initialize() + return init, terminal + + async def test_terminal(self, init_terminal): + init, terminal = init_terminal + assert init is True + assert terminal.connected is True + assert terminal.version is not None diff --git a/tests/unittests/test_ticks.py b/tests/unittests/test_ticks.py new file mode 100644 index 0000000..6d6799e --- /dev/null +++ b/tests/unittests/test_ticks.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from aiomql.lib.ticks import Ticks, Tick +from pandas import Series + +class TestTicks: + async def test_tick(self, mt): + btc_tick = await mt.symbol_info_tick("BTCUSD") + btc_tick = Tick(**btc_tick._asdict()) + tick_dict = btc_tick.dict(include={'ask', 'bid', 'time', 'volume'}) + assert isinstance(btc_tick, Tick) + assert isinstance(tick_dict, dict) + assert 'ask' in tick_dict + assert 'bid' in tick_dict + assert 'volume_real' not in tick_dict + + async def test_ticks(self, mt): + start = datetime(year=2023, month=10, day=5) + ticks = await mt.copy_ticks_from("BTCUSD", start, 10, mt.COPY_TICKS_ALL) + ticks = Ticks(data=ticks) + assert isinstance(ticks, Ticks) + assert len(ticks) == 10 + assert isinstance(ticks[0], Tick) + bids = ticks['bid'] + assert len(bids) == 10 + assert isinstance(bids, Series)