From 9eb0baa85c7f4e843b325fbae5deb1e21813e75f Mon Sep 17 00:00:00 2001 From: Ichinga Samuel Date: Tue, 29 Oct 2024 12:27:22 +0100 Subject: [PATCH] v3.23 --- .gitignore | 79 +++ LICENSE.txt | 21 + README.md | 100 +++ pyproject.toml | 25 + requirements.txt | 154 +++++ setup.py | 3 + src/aiomql/__init__.py | 20 + src/aiomql/account.py | 113 ++++ src/aiomql/bot_builder.py | 157 +++++ src/aiomql/candle.py | 296 +++++++++ src/aiomql/core/__init__.py | 8 + src/aiomql/core/base.py | 120 ++++ src/aiomql/core/config.py | 161 +++++ src/aiomql/core/constants.py | 791 +++++++++++++++++++++++ src/aiomql/core/errors.py | 33 + src/aiomql/core/exceptions.py | 19 + src/aiomql/core/meta_trader.py | 349 ++++++++++ src/aiomql/core/models.py | 617 ++++++++++++++++++ src/aiomql/core/task_queue.py | 49 ++ src/aiomql/executor.py | 90 +++ src/aiomql/history.py | 229 +++++++ src/aiomql/lib/__init__.py | 3 + src/aiomql/lib/strategies/__init__.py | 2 + src/aiomql/lib/strategies/finger_trap.py | 111 ++++ src/aiomql/lib/strategies/tracker.py | 37 ++ src/aiomql/lib/symbols/__init__.py | 1 + src/aiomql/lib/symbols/forex_symbol.py | 83 +++ src/aiomql/lib/traders/__init__.py | 1 + src/aiomql/lib/traders/simple_trader.py | 47 ++ src/aiomql/order.py | 146 +++++ src/aiomql/positions.py | 126 ++++ src/aiomql/ram.py | 66 ++ src/aiomql/records.py | 115 ++++ src/aiomql/result.py | 94 +++ src/aiomql/sessions.py | 207 ++++++ src/aiomql/strategy.py | 82 +++ src/aiomql/symbol.py | 399 ++++++++++++ src/aiomql/terminal.py | 70 ++ src/aiomql/ticks.py | 212 ++++++ src/aiomql/trade_records.py | 154 +++++ src/aiomql/trader.py | 128 ++++ src/aiomql/utils.py | 37 ++ 42 files changed, 5555 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 src/aiomql/__init__.py create mode 100644 src/aiomql/account.py create mode 100644 src/aiomql/bot_builder.py create mode 100644 src/aiomql/candle.py create mode 100644 src/aiomql/core/__init__.py create mode 100644 src/aiomql/core/base.py create mode 100644 src/aiomql/core/config.py create mode 100644 src/aiomql/core/constants.py create mode 100644 src/aiomql/core/errors.py create mode 100644 src/aiomql/core/exceptions.py create mode 100644 src/aiomql/core/meta_trader.py create mode 100644 src/aiomql/core/models.py create mode 100644 src/aiomql/core/task_queue.py create mode 100644 src/aiomql/executor.py create mode 100644 src/aiomql/history.py create mode 100644 src/aiomql/lib/__init__.py create mode 100644 src/aiomql/lib/strategies/__init__.py create mode 100644 src/aiomql/lib/strategies/finger_trap.py create mode 100644 src/aiomql/lib/strategies/tracker.py create mode 100644 src/aiomql/lib/symbols/__init__.py create mode 100644 src/aiomql/lib/symbols/forex_symbol.py create mode 100644 src/aiomql/lib/traders/__init__.py create mode 100644 src/aiomql/lib/traders/simple_trader.py create mode 100644 src/aiomql/order.py create mode 100644 src/aiomql/positions.py create mode 100644 src/aiomql/ram.py create mode 100644 src/aiomql/records.py create mode 100644 src/aiomql/result.py create mode 100644 src/aiomql/sessions.py create mode 100644 src/aiomql/strategy.py create mode 100644 src/aiomql/symbol.py create mode 100644 src/aiomql/terminal.py create mode 100644 src/aiomql/ticks.py create mode 100644 src/aiomql/trade_records.py create mode 100644 src/aiomql/trader.py create mode 100644 src/aiomql/utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3b0528 --- /dev/null +++ b/.gitignore @@ -0,0 +1,79 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so +# Distribution / packaging +.Python +env/ +venv/ +.venv/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +notebooks/ +*.egg-info/ +.installed.cfg +*.egg +*.pkl + + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +#Ipython Notebook +.ipynb_checkpoints + +# Ide environment +*.idea/ + +.pypirc + +.vscode/ + +# config files +aiomql.json + +# development +terminals/ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..6a127cb --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Ichinga Samuel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4cea5f --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Aiomql - Bot Building Framework and Asynchronous MetaTrader5 Library +![GitHub](https://img.shields.io/github/license/ichinga-samuel/aiomql?style=plastic) +![GitHub issues](https://img.shields.io/github/issues/ichinga-samuel/aiomql?style=plastic) +![PyPI](https://img.shields.io/pypi/v/aiomql) + +### Installation +```bash +pip install aiomql +``` + +### Key Features +- Asynchronous Python Library For MetaTrader5 +- Asynchronous Bot Building Framework +- Build bots for trading in different financial markets using a bot factory +- Use threadpool executors to run multiple strategies on multiple instruments concurrently +- Records and keep track of trades and strategies in csv files. +- Helper classes for Bot Building. Easy to use and extend. +- Compatible with pandas-ta. +- Sample Pre-Built strategies +- Visualization of charts using matplotlib and mplfinance +- Manage Trading periods using Sessions +- Risk Management +- Run multiple bots concurrently with different accounts from the same broker or different brokers + +### As an asynchronous MetaTrader5 Libray +```python +import asyncio + +from aiomql import MetaTrader + + +async def main(): + mt5 = MetaTrader() + await mt5.initialize() + await mt5.login(123456, '*******', 'Broker-Server') + symbols = await mt5.symbols_get() + print(symbols) + +asyncio.run(main()) +``` + +### As a Bot Building FrameWork using a Sample Strategy +***The following code is a sample bot that uses the FingerTrap strategy from the library.\ +It assumes that you have a config file in the same directory as the script.\ +The config file should be named aiomql.json and should contain the login details for your account.\ +It demonstrates the use of sessions and risk management.\ +Sessions allows you to specify the trading period for a strategy. You can also set an action to be performed at the end of a session.\ +Risk Management allows you to manage the risk of a strategy. You can set the risk per trade and the risk to reward ratio.\ +The trader class handles the placing of orders and risk management. It is an attribute of the strategy class.*** + +```python +from datetime import time +import logging + +from aiomql import Bot, ForexSymbol, FingerTrap, Session, Sessions, RAM, SimpleTrader, TimeFrame + +logging.basicConfig(level=logging.INFO) + + +def build_bot(): + bot = Bot() + + # create sessions for the strategies + london = Session(name='London', start=8, end=time(hour=15, minute=30), on_end='close_all') + new_york = Session(name='New York', start=13, end=time(hour=20, minute=30)) + tokyo = Session(name='Tokyo', start=23, end=time(hour=6, minute=30)) + + # configure the parameters and the trader for a strategy + params = {'trend_candles_count': 500, 'fast_period': 8, 'slow_period': 34, 'etf': TimeFrame.M5} + gbpusd = ForexSymbol(name='GBPUSD') + st1 = FingerTrap(symbol=gbpusd, params=params, trader=SimpleTrader(symbol=gbpusd, ram=RAM(risk=0.05, risk_to_reward=2)), + sessions=Sessions(london, new_york)) + + # use the default for the other strategies + st2 = FingerTrap(symbol=ForexSymbol(name='AUDUSD'), sessions=Sessions(tokyo, new_york)) + st3 = FingerTrap(symbol=ForexSymbol(name='USDCAD'), sessions=Sessions(new_york)) + st4 = FingerTrap(symbol=ForexSymbol(name='USDJPY'), sessions=Sessions(tokyo)) + st5 = FingerTrap(symbol=ForexSymbol(name='EURGBP'), sessions=Sessions(london)) + + # sessions are not required + st6 = FingerTrap(symbol=ForexSymbol(name='EURUSD')) + + # add strategies to the bot + bot.add_strategies([st1, st2, st3, st4, st5, st6]) + bot.execute() + +# run the bot +build_bot() +``` +## API Documentation +see [API Documentation](https://github.com/Ichinga-Samuel/aiomql/tree/master/docs) for more details + +## Contributing +Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. + +## Support +Feeling generous, like the package or want to see it become a more mature package? + +Consider supporting the project by buying me a coffee.\ +[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/ichingasamuel) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8e114f9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = [ + "setuptools>=42", + "wheel" +] +build-backend = "setuptools.build_meta" + +[project] +name = "aiomql" +version = "3.23" +readme = "README.md" +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +keywords = ['MetaTrader5', 'Asynchronous', 'Algorithmic Trading', 'Trading Bot'] +dependencies = ["MetaTrader5>=5.0.37", "pandas>=1.5.0", "pandas-ta>=0.3.14b0", "matplotlib>=3.8.4", "mplfinance>=0.12.10b0"] +authors = [{name = "Ichinga Samuel", email = "ichingasamuel@gmail.com"}] +description = "Asynchronous MetaTrader5 library and Algorithmic Trading Framework" + +[project.urls] +"Homepage" = "https://github.com/Ichinga-Samuel/aiomql" +"Bug Tracker" = "https://github.com/Ichinga-Samuel/aiomql/issues" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8fa1169 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,154 @@ +anyio==4.3.0 +argon2-cffi==23.1.0 +argon2-cffi-bindings==21.2.0 +arrow==1.3.0 +asttokens==2.4.1 +async-lru==2.0.4 +attrs==23.2.0 +Babel==2.14.0 +beautifulsoup4==4.12.3 +black==23.9.1 +bleach==6.1.0 +build==1.0.3 +certifi==2023.7.22 +cffi==1.16.0 +charset-normalizer==3.3.0 +click==8.1.7 +colorama==0.4.6 +comm==0.2.2 +contourpy==1.2.1 +cycler==0.12.1 +databind.core==4.4.1 +databind.json==4.4.1 +debugpy==1.8.1 +decorator==5.1.1 +defusedxml==0.7.1 +Deprecated==1.2.14 +docspec==2.2.1 +docspec-python==2.2.1 +docstring-parser==0.11 +docutils==0.20.1 +executing==2.0.1 +fastjsonschema==2.19.1 +fonttools==4.51.0 +fqdn==1.5.1 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.0 +idna==3.4 +importlib-metadata==6.8.0 +iniconfig==2.0.0 +ipykernel==6.29.4 +ipython==8.23.0 +ipywidgets==8.1.2 +isoduration==20.11.0 +jaraco.classes==3.3.0 +jedi==0.19.1 +Jinja2==3.1.2 +json5==0.9.24 +jsonpointer==2.4 +jsonschema==4.21.1 +jsonschema-specifications==2023.12.1 +jupyter==1.0.0 +jupyter-console==6.6.3 +jupyter-events==0.10.0 +jupyter-lsp==2.2.5 +jupyter_client==8.6.1 +jupyter_core==5.7.2 +jupyter_server==2.13.0 +jupyter_server_terminals==0.5.3 +jupyterlab==4.1.6 +jupyterlab_pygments==0.3.0 +jupyterlab_server==2.26.0 +jupyterlab_widgets==3.0.10 +keyring==24.2.0 +kiwisolver==1.4.5 +markdown-it-py==3.0.0 +MarkupSafe==2.1.3 +matplotlib==3.8.4 +matplotlib-inline==0.1.6 +mdurl==0.1.2 +MetaTrader5==5.0.45 +mistune==3.0.2 +more-itertools==10.1.0 +mplfinance==0.12.10b0 +mypy-extensions==1.0.0 +nbclient==0.10.0 +nbconvert==7.16.3 +nbformat==5.10.4 +nest-asyncio==1.6.0 +nh3==0.2.14 +notebook==7.1.2 +notebook_shim==0.2.4 +nr-date==2.1.0 +nr-stream==1.1.5 +nr.util==0.8.12 +numpy==1.26.0 +overrides==7.7.0 +packaging==23.2 +pandas==2.1.1 +pandas-ta==0.3.14b0 +pandocfilters==1.5.1 +parso==0.8.4 +pathspec==0.11.2 +pillow==10.3.0 +pkginfo==1.9.6 +platformdirs==3.11.0 +pluggy==1.3.0 +prometheus_client==0.20.0 +prompt-toolkit==3.0.43 +psutil==5.9.8 +pure-eval==0.2.2 +pycparser==2.22 +pydoc-markdown==4.8.2 +Pygments==2.16.1 +pyparsing==3.1.2 +pyproject_hooks==1.0.0 +pytest==7.4.4 +python-dateutil==2.8.2 +python-json-logger==2.0.7 +python-telegram-bot==21.0.1 +pytz==2023.3.post1 +pywin32==306 +pywin32-ctypes==0.2.2 +pywinpty==2.0.13 +PyYAML==6.0.1 +pyzmq==25.1.2 +qtconsole==5.5.1 +QtPy==2.4.1 +readme-renderer==42.0 +referencing==0.34.0 +requests==2.31.0 +requests-toolbelt==1.0.0 +rfc3339-validator==0.1.4 +rfc3986==2.0.0 +rfc3986-validator==0.1.1 +rich==13.6.0 +rpds-py==0.18.0 +Send2Trash==1.8.3 +six==1.16.0 +sniffio==1.3.1 +soupsieve==2.5 +stack-data==0.6.3 +terminado==0.18.1 +tinycss2==1.2.1 +tomli==2.0.1 +tomli_w==1.0.0 +tornado==6.4 +traitlets==5.14.2 +twine==4.0.2 +typeapi==2.1.1 +types-python-dateutil==2.9.0.20240316 +typing_extensions==4.6.3 +tzdata==2023.3 +uri-template==1.3.0 +urllib3==2.0.6 +watchdog==3.0.0 +wcwidth==0.2.13 +webcolors==1.13 +webencodings==0.5.1 +websocket-client==1.7.0 +widgetsnbextension==4.0.10 +wrapt==1.15.0 +yapf==0.40.2 +zipp==3.17.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6068493 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/src/aiomql/__init__.py b/src/aiomql/__init__.py new file mode 100644 index 0000000..fb6a018 --- /dev/null +++ b/src/aiomql/__init__.py @@ -0,0 +1,20 @@ +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, find_bearish_fractal, find_bullish_fractal +from .lib import * diff --git a/src/aiomql/account.py b/src/aiomql/account.py new file mode 100644 index 0000000..93a06a9 --- /dev/null +++ b/src/aiomql/account.py @@ -0,0 +1,113 @@ +import asyncio +from logging import getLogger + +from .core.models import AccountInfo, SymbolInfo +from .core.exceptions import LoginError + +logger = getLogger(__name__) + + +class Account(AccountInfo): + """A class for managing a trading account. A singleton class. + A subclass of AccountInfo. All AccountInfo attributes are available in this class. + + Attributes: + connected (bool): Status of connection to MetaTrader 5 Terminal + symbols (set[SymbolInfo]): A set of available symbols for the financial market. + + Notes: + Other Account properties are defined in the AccountInfo class. + """ + _instance: 'Account' + connected: bool + symbols = set() + + def __new__(cls, *args, **kwargs): + if not hasattr(cls, '_instance'): + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, **kwargs): + super().__init__(**kwargs) + acc = self.config.account_info() + acc_details = {k: v for k, v in self.get_dict(include={'login', 'server', 'password'}).items() if v} + acc |= acc_details + self.config.set_attributes(**acc) + self.set_attributes(**acc) + + 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) -> 'Account': + """Connect to a trading account and return the account instance. + Async context manager for the Account class. + + Returns: + Account: An instance of the Account class + + Raises: + LoginError: If login fails + """ + res = await self.sign_in() + if not res: + raise LoginError('Login failed') + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.mt5.shutdown() + self.connected = False + + async def sign_in(self) -> bool: + """Connect to a trading account. + + Returns: + bool: True if login was successful else False + """ + acc = self.get_dict(include={'login', 'server', 'password'}) + self.connected = await self._login(acc=acc) + if self.connected: + await self.refresh() + self.symbols = await self.symbols_get() + return self.connected + await self.mt5.shutdown() + return False + + async def _login(self, *, acc: dict, tries=3): + res = False + if tries == 0: + return False + ini = await self.mt5.initialize(**acc, path=self.config.path) + if ini: + res = await self.mt5.login(**acc) + if ini and res: + return True + else: + await asyncio.sleep(5+tries) + return await self._login(acc=acc, tries=tries-1) + + def has_symbol(self, symbol: str | SymbolInfo): + """Checks to see if a symbol is available for a trading account. + + Args: + symbol (str | SymbolInfo): + + Returns: + bool: True if symbol is present otherwise False + """ + try: + return str(symbol) in {s.name for s in self.symbols} + except Exception as err: + logger.warning(f'Error: {err}; {symbol} not available in this market') + return False + + async def symbols_get(self) -> set[SymbolInfo]: + """Get all financial instruments from the MetaTrader 5 terminal available for the current account. + + Returns: + set[Symbol]: A set of available symbols. + """ + syms = await self.mt5.symbols_get() + return {SymbolInfo(name=sym.name) for sym in syms} diff --git a/src/aiomql/bot_builder.py b/src/aiomql/bot_builder.py new file mode 100644 index 0000000..5a2912f --- /dev/null +++ b/src/aiomql/bot_builder.py @@ -0,0 +1,157 @@ +import asyncio +from concurrent.futures import ProcessPoolExecutor +from typing import Type, Iterable, TypeVar, Callable, Coroutine +import logging + +from .executor import Executor +from .account import Account +from .core.config import Config +from .symbol import Symbol as _Symbol +from .strategy import Strategy as _Strategy + +logger = logging.getLogger(__name__) + +Strategy = TypeVar("Strategy", bound=_Strategy) +Symbol = TypeVar("Symbol", bound=_Symbol) + + +class Bot: + """The bot class. Create a bot instance to run your strategies. + + Attributes: + account (Account): Account Object. + executor: The default thread executor. + symbols (list[Symbols]): A set of symbols for the trading session + config (Config): Config instance + + """ + config: Config + account: Account + symbols: set + executor: Executor + + def __init__(self): + self.config = Config() + self.account = Account() + self.symbols = set() + self.executor = Executor() + + @classmethod + def run_bots(cls, funcs: dict[Callable: dict] = None, num_workers: int = None): + """Run multiple scripts or bots in parallel with different accounts. + + Args: + funcs (dict): A dictionary of functions to run with their respective keyword arguments as a dictionary + num_workers (int): Number of workers to run the functions + """ + num_workers = num_workers or len(funcs) * 2 + with ProcessPoolExecutor(max_workers=num_workers) as executor: + for bot, kwargs in funcs.items(): + executor.submit(bot, **kwargs) + + async def initialize(self): + """Prepares the bot by signing in to the trading account and initializing the symbols for the trading session. + Starts the global task queue. + + Raises: + SystemExit if sign in was not successful + """ + try: + init = await self.account.sign_in() + if not init: + logger.warning(f"Unable to sign in to MetaTrder 5 Terminal") + raise SystemExit + logger.info("Login Successful") + await self.init_symbols() + self.executor.remove_workers(symbols=self.symbols) + self.add_coroutine(self.config.task_queue.start) + self.config.bot = self + except Exception as err: + logger.error(f"{err}. Bot initialization failed") + raise SystemExit + + def add_function(self, func: Callable, **kwargs: dict): + """Add a function to the executor. + + Args: + func (Callable): A function to be executed + **kwargs (dict): Keyword arguments for the function + """ + self.executor.add_function(func, kwargs) + + def add_coroutine(self, coro: Coroutine | Callable, **kwargs): + """Add a coroutine to the executor. + + Args: + coro (Coroutine): A coroutine to be executed + **kwargs (dict): keyword arguments for the coroutine + + Returns: + + """ + self.executor.add_coroutine(coro, kwargs) + + def execute(self): + """Execute the bot.""" + asyncio.run(self.start()) + + async def start(self): + """Initialize the bot and execute it. Similar to calling `execute` method but is a coroutine.""" + await self.initialize() + await self.executor.execute() + + 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: + strategy (Strategy): A Strategy instance to run on bot + + Notes: + Make sure the symbol has been added to the market + """ + self.executor.add_worker(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] + + def add_strategy_all(self, *, strategy: Type[Strategy], params: dict | None = None): + """Use this to run a single strategy on all available instruments in the market using the default parameters + i.e. one set of parameters for all trading symbols + + Keyword Args: + strategy (Strategy): Strategy class + params (dict): A dictionary of parameters for the strategy + """ + [ + self.add_strategy(strategy(symbol=symbol, params=params)) + for symbol in self.symbols + ] + + async def init_symbols(self): + """Initialize the symbols for the current trading session. This method is called internally by the bot.""" + syms = [self.init_symbol(strategy.symbol) for strategy in self.executor.workers] + await asyncio.gather(*syms, return_exceptions=True) + + async def init_symbol(self, symbol: Symbol) -> Symbol: + """Initialize a symbol before the beginning of a trading sessions. + Removes it from the list of symbols if it was not successfully initialized or not available + for the account. + + Args: + symbol (Symbol): Symbol object to be initialized + + Returns: + Symbol: if successfully initialized + """ + if self.account.has_symbol(symbol): + init = await symbol.init() + if init: + self.symbols.add(symbol) + return symbol + logger.warning(f"Unable to initialize symbol {symbol}") + logger.warning(f"{symbol} not a available for this market") diff --git a/src/aiomql/candle.py b/src/aiomql/candle.py new file mode 100644 index 0000000..5754342 --- /dev/null +++ b/src/aiomql/candle.py @@ -0,0 +1,296 @@ +"""Candle and Candles classes for handling bars from the MetaTrader 5 terminal.""" + +from typing import Type, TypeVar, Generic, Iterable +from logging import getLogger + +from pandas import DataFrame, Series +import pandas as pd +import pandas_ta as ta +import mplfinance as mplt + +from .core.constants import TimeFrame + +logger = getLogger(__name__) + + +class Candle: + """A customized class representing rates from the MetaTrader 5 terminal analogous to Japanese + Candlesticks. You can subclass this class for added customization. + + Attributes: + time (int): Period start time. + open (int): Open price + high (float): The highest price of the period + low (float): The lowest price of the period + close (float): Close price + tick_volume (float): Tick volume + real_volume (float): Trade volume + spread (float): Spread + Index (int): Custom attribute representing the position of the candle in a sequence. + """ + time: float + open: float + high: float + low: float + close: float + real_volume: float + spread: float + tick_volume: float + Index: int + + def __init__(self, **kwargs): + """Create a Candle object from keyword arguments. This class must always be instantiated with open, high, low + and close prices. + + Keyword Args: + **kwargs: Candle attributes and values as keyword arguments. + """ + 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.Index = kwargs.pop('Index', 0) + self.set_attributes(**kwargs) + + def __repr__(self): + return ("%(class)s(Index=%(Index)s, time=%(time)s, open=%(open)s, high=%(high)s, low=%(low)s, close=%(close)s)" + % {"class": 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: "Candle"): + eq = self.open == other.open and self.high == other.high and self.low == other.low and self.close == other.close + return eq + + 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 set_attributes(self, **kwargs): + """Set keyword arguments as instance attributes + + Keyword Args: + **kwargs: Instance attributes and values as keyword arguments + """ + [setattr(self, i, j) for i, j in kwargs.items()] + + def is_bullish(self) -> bool: + """A simple check to see if the candle is bullish. + + Returns: + bool: True or False + """ + return self.close >= self.open + + def is_bearish(self) -> bool: + """A simple check to see if the candle is bearish. + + Returns: + bool: True or False + """ + return self.open > self.close + + def dict(self, exclude: set = None, include: set = None) -> dict: + """ + Returns a dictionary of the instance attributes. + + Args: + exclude: A set of attributes to exclude from the dictionary. Defaults to None. + include: A set of attributes to include in the dictionary. Defaults to None. + + Returns: dict + """ + 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} + + +_Candle = TypeVar("_Candle", bound=Candle) +_Candles = TypeVar("_Candles", bound="Candles") + + +class Candles(Generic[_Candle]): + """An iterable container class of Candle objects in chronological order. + + Attributes: + Index (Series['int']): A pandas Series of the indexes of all candles in the object. + time (Series['int']): A pandas Series of the time of all candles in the object. + open (Series[float]): A pandas Series of the opening price of all candles in the object. + high (Series[float]): A pandas Series of the high price of all candles in the object. + low (Series[float]): A pandas Series of the low price of all candles in the object. + close (Series[float]): A pandas Series of the closing price of all candles in the object. + tick_volume (Series[float]): A pandas Series of the tick volume of all candles in the object. + real_volume (Series[float]): A pandas Series of the real volume of all candles in the object. + spread (Series[float]): A pandas Series of the spread of all candles in the object. + timeframe (TimeFrame): The timeframe of the candles in the object. + Candle (Type[Candle]): The Candle class for representing the candles in the object. + + properties: + data (DataFrame): A pandas DataFrame of all candles in the object. + + Notes: + The candle class can be customized by subclassing the Candle class and passing the subclass as the candle + keyword argument, or defining it on the class body as a class attribute. + """ + Index: Series + time: Series + open: Series + high: Series + low: Series + close: Series + tick_volume: Series + real_volume: Series + spread: Series + Candle: Type[Candle] + timeframe: TimeFrame + _data: DataFrame + + def __init__(self, *, data: DataFrame | _Candles | Iterable, flip=False, candle_class: Type[_Candle] = None): + """A container class of Candle objects in chronological order. + + Args: + data (DataFrame|Candles|Iterable): A pandas dataframe, a Candles object or any suitable iterable + + Keyword Args: + flip (bool): Reverse the chronological order of the candles to the oldest first. Defaults to False. + candle_class: A subclass of Candle to use as the candle class. Defaults to Candle. + """ + if isinstance(data, DataFrame): + data = data + elif isinstance(data, type(self)): + data = DataFrame(data.data) + elif isinstance(data, Iterable): + data = DataFrame(data) + else: + raise ValueError(f"Cannot create DataFrame from object of {type(data)}") + + self._data = data.loc[::-1].reset_index(drop=True) if flip else data + self.Candle = candle_class or Candle + + def __repr__(self): + return self._data.__repr__() + + def __len__(self): + return len(self._data.index) + + def __contains__(self, item: _Candle): + return item.time == self[item.Index].time + + def __getitem__(self, index) -> _Candle | _Candles | Series: + if isinstance(index, slice): + cls = self.__class__ + data = self._data.iloc[index] + data.reset_index(drop=True, inplace=True) + return cls(data=data) + + elif isinstance(index, str): + if index == 'Index': + return Series(self._data.index) + return self._data[index] + + elif isinstance(index, int): + index = index if index >= 0 else len(self) + index + return self.Candle(**self._data.iloc[index], Index=index) + raise TypeError(f"Expected int, slice or str got {type(index)}") + + def __setitem__(self, index, value: Series): + if isinstance(value, Series): + self._data[index] = value + return + raise TypeError(f"Expected Series got {type(value)}") + + 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__}") + + def __iter__(self): + return (self.Candle(**row._asdict()) for row in self._data.itertuples()) + + @property + def timeframe(self): + tf = self.time[1] - self.time[0] + return TimeFrame.get(abs(tf)) + + @property + def ta(self): + """Access to the pandas_ta library for performing technical analysis on the underlying data attribute. + + Returns: + pandas_ta: The pandas_ta library + """ + return self._data.ta + + @property + def ta_lib(self): + """Access to the ta library for performing technical analysis. Not dependent on the underlying data attribute. + + Returns: + ta: The ta library + """ + return ta + + @property + def data(self) -> DataFrame: + """The original data passed to the class as a pandas DataFrame""" + return self._data + + def rename(self, inplace=True, **kwargs) -> _Candles: + """Rename columns of the candles class. + + Keyword Args: + inplace (bool): Rename the columns inplace or return a new instance of the class with the renamed columns + **kwargs: The new names of the columns + + Returns: + Candles: A new instance of the class with the renamed columns if inplace is False else the modified instance + """ + res = self._data.rename(columns=kwargs, inplace=inplace) + return self if inplace else self.__class__(data=res) + + def make_addplot(self, *, count: int = 50, columns: list = None, **kwargs) -> dict: + """ + Make subplots for adding to the main plot + + Args: + count (int): The numbers of candles to make the addplot for. Defaults to 50. + columns (list[str]): The columns to make the plot from. Defaults to None. + **kwargs: Valid arguments for the mplfinance make_addplot function + """ + columns = columns or [] + data = self._data[-count:] + 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, + 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 + 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. + style (str): The style of the chart. Defaults to 'charles'. + ylabel (str): The label of the y-axis. Defaults to 'Price'. + title (str): The title of the chart. Defaults to 'Chart'. + 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} + data = self._data[-count:] + data.index = pd.to_datetime(data['time'], unit='s') + mplt.plot(data, **kwargs) diff --git a/src/aiomql/core/__init__.py b/src/aiomql/core/__init__.py new file mode 100644 index 0000000..33ebb21 --- /dev/null +++ b/src/aiomql/core/__init__.py @@ -0,0 +1,8 @@ +from .meta_trader import MetaTrader +from .config import Config +from .models import * +from .constants import * +from .base import 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 new file mode 100644 index 0000000..333fc39 --- /dev/null +++ b/src/aiomql/core/base.py @@ -0,0 +1,120 @@ +from functools import cache +import enum +from logging import getLogger + +from .config import Config +from .meta_trader import MetaTrader + +logger = getLogger(__name__) + + +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 + + 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() + self.exclude = {'mt5', "config", 'exclude', 'include', 'annotations', 'class_vars', 'dict'} + self.include = set() + self.set_attributes(**kwargs) + + def __repr__(self): + kv = [(k, v) for k, v in self.__dict__.items() if not k.startswith('_') and + (type(v) in (int, float, str) or isinstance(v, enum.Enum))] + args = (', '.join('%s=%s' % (i, j) for i, j in kv[:3])) + args = args if len(kv) <= 3 else args + ' ... ' + ', '.join('%s=%s' % (i, j) for i, j in kv[-1:]) + return '%(class)s(%(args)s)' % {'class': self.__class__.__name__, 'args': args} + + def set_attributes(self, **kwargs): + """Set keyword arguments as object attributes + + Keyword Args: + **kwargs: Object attributes and values as keyword arguments + + Raises: + AttributeError: When assigning an attribute that does not belong to the class or any parent class + + Notes: + Only sets attributes that have been annotated on the class body. + """ + for i, j in kwargs.items(): + try: + setattr(self, i, self.annotations[i](j)) + except KeyError: + logger.warning(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 Exception as exe: + logger.warning(f'Did not set attribute {i} on class {self.__class__.__name__} due to {exe}') + continue + + @property + @cache + def annotations(self) -> dict: + """Class annotations from all ancestor classes and the current class. + + Returns: + dict: A dictionary of class annotations + """ + annots = {} + for base in self.__class__.__mro__[-3::-1]: + annots |= getattr(base, '__annotations__', {}) + return annots + + def get_dict(self, exclude: set = None, include: set = None) -> dict: + """Returns class attributes as a dict, with the ability to filter + + Keyword Args: + exclude: A set of attributes to be excluded + include: Specific attributes to be returned + + Returns: + dict: A dictionary of specified class attributes + + Notes: + You can only set either of include or exclude. If you set both, include will take precedence + """ + exclude, include = exclude or set(), include or set() + filter_ = include or set(self.dict.keys()).difference(exclude) + return {key: value for key, value in self.dict.items() if key in filter_} + + @property + @cache + def class_vars(self): + """Annotated class attributes + + Returns: + dict: A dictionary of available class attributes in all ancestor classes and the current class. + """ + clss = self.__class__.__mro__[-3::-1] + cls_dict = {} + for cls in clss: + cls_dict |= cls.__dict__ + return {key: value for key, value in cls_dict.items() if key in self.annotations} + + @property + def dict(self) -> dict: + """All instance and class attributes as a dictionary, except those excluded in the Meta class. + + Returns: + dict: A dictionary of instance and class attributes + """ + try: + _filter = self.exclude.difference(self.include) + return {key: value for key, value in (self.class_vars | self.__dict__).items() if + key not in _filter} + except Exception as err: + logger.warning(err) diff --git a/src/aiomql/core/config.py b/src/aiomql/core/config.py new file mode 100644 index 0000000..674c432 --- /dev/null +++ b/src/aiomql/core/config.py @@ -0,0 +1,161 @@ +import os +from pathlib import Path +from typing import Iterator, Literal, TypeVar +import json +from logging import getLogger + +from .task_queue import TaskQueue + +logger = getLogger(__name__) +Bot = TypeVar("Bot") + + +class Config: + """A class for handling configuration settings for the aiomql package. + + Attributes: + record_trades (bool): Whether to keep record of trades or not. + trade_record_mode: How to save trade, json or csv. Defaults to json + filename (str): Name of the config file + records_dir (str): Path to the directory where trade records are saved + login (int): Trading account number + password (str): Trading account password + server (str): Broker server + path (str): Path to terminal file + timeout (int): Timeout for terminal connection + _initialize (bool): First time initialization flag + state (dict): A global state dictionary for storing data across the framework + root_dir (str): The root directory of the project + Notes: + By default, the config class looks for a file named aiomql.json. + You can change this by passing the filename and/or the config_dir keyword argument(s) to the constructor + or the load_config method. + By passing reload=True to the load_config method, you can reload and search again for the config file. + """ + login: int = 0 + trade_record_mode: Literal['csv', 'json'] = 'csv' + password: str = "" + server: str = "" + path: str | Path = "" + timeout: int = 60000 + record_trades: bool = True + filename: str = "aiomql.json" + _initialize = True + state: dict = {} + root: Path + root_dir: Path + records_dir: Path + config_dir: str = '' + task_queue: TaskQueue = TaskQueue() + bot: Bot = None + _instance: 'Config' + + def __new__(cls, *args, **kwargs): + if not hasattr(cls, "_instance"): + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, **kwargs): + reload = kwargs.pop('reload', False) + self.load_config(reload=reload, **kwargs) + + def set_root(self, *, root: str | Path): + root = Path(root) if str else root + self.root = root.absolute().resolve() + self.root_dir = self.root + + def __setattr__(self, key, value): + if key == 'path': + value = str(self.root_dir / Path(value).absolute().resolve()) + super().__setattr__(key, value) + + def set_attributes(self, **kwargs): + """Set keyword arguments as object attributes + + Keyword Args: + **kwargs: Object attributes and values as keyword arguments + """ + [setattr(self, key, value) for key, value in kwargs.items()] + + @staticmethod + def walk_to_root(path: str | Path) -> Iterator[str]: + if not os.path.exists(path): + raise IOError("Starting path not found") + + if os.path.isfile(path): + path = os.path.dirname(path) + + last_dir = None + current_dir = os.path.abspath(path) + while last_dir != current_dir: + yield current_dir + parent_dir = os.path.abspath(os.path.join(current_dir, os.path.pardir)) + last_dir, current_dir = current_dir, parent_dir + + def find_config(self): + try: + path = self.root_dir / self.config_dir + for dirname in self.walk_to_root(path): + check_path = os.path.join(dirname, self.filename) + if os.path.isfile(check_path): + return check_path + return None + except Exception as _: + return + + def create_records_dir(self, *, records_dir: str | Path = 'records'): + """Create records directory if it does not exist. By default, it is relative to the root directory of the + project unless an absolute path is provided. + + Keyword Args: + records_dir (str|Path): The directory to save trade records. Default is 'records' + """ + try: + if isinstance(records_dir, str): + records_dir = self.root_dir / records_dir + elif isinstance(records_dir, Path): + records_dir = records_dir.absolute().resolve() + records_dir.mkdir(parents=True, exist_ok=True) + self.records_dir = records_dir + except Exception as err: + logger.warning(f"{err}: Unable to create records directory") + + def load_config(self, *, file: str = None, reload: bool = True, filename: str = None, + config_dir: str = '', **kwargs): + """Load configuration settings from a file. + Keyword Args: + file (str): The path to the file to load. If not provided, the file is searched for + reload (bool): Whether to reload the config object. Default is True + filename (str): The name of the file to load. If not provided, the default filename is used + config_dir (str): The name of the directory to search for the file. Default is the root directory + root_dir (str): The root directory of the project + kwargs: Additional keyword arguments + """ + if not (self._initialize or reload): + return + data = {} + self.filename = filename or self.filename + self.config_dir = config_dir or self.config_dir + root_dir = kwargs.pop('root_dir', None) + records_dir = kwargs.pop('records_dir', 'records') + if self._initialize or (root_dir is not None): + self.set_root(root=(root_dir or '.')) + self.create_records_dir(records_dir=records_dir) + + if (file := (file or self.find_config())) is None: + logger.warning("No Config File Found") + else: + fh = open(file, mode="r") + data = json.load(fh) + fh.close() + data |= kwargs + self.set_attributes(**data) + self._initialize = False + + def account_info(self) -> dict[str, int | str]: + """Returns Account login details as found in the config object if available + + Returns: + dict: A dictionary of login details + """ + return {"login": self.login, "password": self.password, "server": self.server} diff --git a/src/aiomql/core/constants.py b/src/aiomql/core/constants.py new file mode 100644 index 0000000..054d358 --- /dev/null +++ b/src/aiomql/core/constants.py @@ -0,0 +1,791 @@ +from enum import IntEnum, IntFlag + +import MetaTrader5 as mt5 + +""" +MetaTrader5 constants as IntEnum types with Python style class names and nice string representation + +Examples: + >>> from aiomql import OrderFilling + >>> fok = OrderFilling.FOK + >>> print(fok) + "ORDER_FILLING_FOK" +""" + + +class Repr: + __enum_name__ = "" + name: str + + def __repr__(self): + return f"{self.__enum_name__}_{self.name}" + + +class TradeAction(Repr, IntEnum): + """TRADE_REQUEST_ACTION Enum. + + Attributes: + DEAL (int): Delete the pending order placed previously Place a trade order for an immediate execution with the + specified parameters (market order). + PENDING (int): Delete the pending order placed previously + SLTP (int): Modify Stop Loss and Take Profit values of an opened position + MODIFY (int): Modify the parameters of the order placed previously + REMOVE (int): Delete the pending order placed previously + CLOSE_BY (int): Close a position by an opposite one + """ + __enum_name__ = "TRADE_ACTION" + DEAL = mt5.TRADE_ACTION_DEAL + PENDING = mt5.TRADE_ACTION_PENDING + SLTP = mt5.TRADE_ACTION_SLTP + MODIFY = mt5.TRADE_ACTION_MODIFY + REMOVE = mt5.TRADE_ACTION_MODIFY + CLOSE_BY = mt5.TRADE_ACTION_CLOSE_BY + + +class OrderFilling(Repr, IntEnum): + """ORDER_TYPE_FILLING Enum. + + Attributes: + FOK (int): This execution policy means that an order can be executed only in the specified volume. + If the necessary amount of a financial instrument is currently unavailable in the market, the order will + not be executed. The desired volume can be made up of several available offers. + + IOC (int): An agreement to execute a deal at the maximum volume available in the market within the volume + specified in the order. If the request cannot be filled completely, an order with the available volume will + be executed, and the remaining volume will be canceled. + + RETURN (int): This policy is used only for market (ORDER_TYPE_BUY and ORDER_TYPE_SELL), limit and stop limit + orders (ORDER_TYPE_BUY_LIMIT, ORDER_TYPE_SELL_LIMIT,ORDER_TYPE_BUY_STOP_LIMIT and + ORDER_TYPE_SELL_STOP_LIMIT) and only for the symbols with Market or Exchange execution modes. If filled + partially, a market or limit order with the remaining volume is not canceled, and is processed further. + During activation of the ORDER_TYPE_BUY_STOP_LIMIT and ORDER_TYPE_SELL_STOP_LIMIT orders, an appropriate + limit order ORDER_TYPE_BUY_LIMIT/ORDER_TYPE_SELL_LIMIT with the ORDER_FILLING_RETURN type is created. + """ + __enum_name__ = "ORDER_FILLING" + FOK = mt5.ORDER_FILLING_FOK + IOC = mt5.ORDER_FILLING_IOC + RETURN = mt5.ORDER_FILLING_RETURN + + +class OrderTime(Repr, IntEnum): + """ORDER_TIME Enum. + + Attributes: + GTC (int): Good till cancel order + DAY (int): Good till current trade day order + SPECIFIED (int): The order is active until the specified date + SPECIFIED_DAY (int): The order is active until 23:59:59 of the specified day. If this time appears to be out of + a trading session, the expiration is processed at the nearest trading time. + """ + __enum_name__ = "ORDER_TIME" + GTC = mt5.ORDER_TIME_GTC + DAY = mt5.ORDER_TIME_DAY + SPECIFIED = mt5.ORDER_TIME_SPECIFIED + SPECIFIED_DAY = mt5.ORDER_TIME_SPECIFIED_DAY + + +class OrderType(Repr, IntEnum): + """ORDER_TYPE Enum. + + Attributes: + BUY (int): Market buy order + SELL (int): Market sell order + BUY_LIMIT (int): Buy Limit pending order + SELL_LIMIT (int): Sell Limit pending order + BUY_STOP (int): Buy Stop pending order + SELL_STOP (int): Sell Stop pending order + BUY_STOP_LIMIT (int): Upon reaching the order price, Buy Limit pending order is placed at StopLimit price + SELL_STOP_LIMIT (int): Upon reaching the order price, Sell Limit pending order is placed at StopLimit price + CLOSE_BY (int): Order for closing a position by an opposite one + + Properties: + opposite (int): Gets the opposite of an order type + """ + __enum_name__ = "ORDER_TYPE" + BUY = mt5.ORDER_TYPE_BUY + SELL = mt5.ORDER_TYPE_SELL + BUY_LIMIT = mt5.ORDER_TYPE_BUY_LIMIT + SELL_LIMIT = mt5.ORDER_TYPE_SELL_LIMIT + BUY_STOP = mt5.ORDER_TYPE_BUY_STOP + SELL_STOP = mt5.ORDER_TYPE_SELL_STOP + BUY_STOP_LIMIT = mt5.ORDER_TYPE_BUY_STOP_LIMIT + SELL_STOP_LIMIT = mt5.ORDER_TYPE_SELL_STOP_LIMIT + CLOSE_BY = mt5.ORDER_TYPE_CLOSE_BY + + @property + def opposite(self): + """Gets the opposite of an order type for closing an open position + + Returns: + int: integer value of opposite order type + """ + return {0: 1, 1: 0, 2: 3, 3: 2, 4: 5, 5: 4, 6: 7, 7: 6, 8: 8}[self] + + +class BookType(Repr, IntEnum): + """BOOK_TYPE Enum. + + Attributes: + SELL (int): Sell order (Offer) + BUY (int): Buy order (Bid) + SELL_MARKET (int): Sell order by Market + BUY_MARKET (int): Buy order by Market + """ + __enum_name__ = "BOOK_TYPE" + SELL = mt5.BOOK_TYPE_SELL + BUY = mt5.BOOK_TYPE_BUY + SELL_MARKET = mt5.BOOK_TYPE_SELL_MARKET + BUY_MARKET = mt5.BOOK_TYPE_BUY_MARKET + + +class TimeFrame(Repr, IntEnum): + """TIMEFRAME Enum. + + Attributes: + M1 (int): One Minute + M2 (int): Two Minutes + M3 (int): Three Minutes + M4 (int): Four Minutes + M5 (int): Five Minutes + M6 (int): Six Minutes + M10 (int): Ten Minutes + M15 (int): Fifteen Minutes + M20 (int): Twenty Minutes + M30 (int): Thirty Minutes + H1 (int): One Hour + H2 (int): Two Hours + H3 (int): Three Hours + H4 (int): Four Hours + H6 (int): Six Hours + H8 (int): Eight Hours + D1 (int): One Day + W1 (int): One Week + MN1 (int): One Month + + Properties: + time: return the value of the timeframe object in seconds. Used as a property + + Methods: + get: get a timeframe object from a time value in seconds + """ + __enum_name__ = "TIMEFRAME" + + def __str__(self): + return self.name + + M1 = mt5.TIMEFRAME_M1 + M2 = mt5.TIMEFRAME_M2 + M3 = mt5.TIMEFRAME_M3 + M4 = mt5.TIMEFRAME_M4 + M5 = mt5.TIMEFRAME_M5 + M6 = mt5.TIMEFRAME_M6 + M10 = mt5.TIMEFRAME_M10 + M15 = mt5.TIMEFRAME_M15 + M20 = mt5.TIMEFRAME_M20 + M30 = mt5.TIMEFRAME_M30 + H1 = mt5.TIMEFRAME_H1 + H2 = mt5.TIMEFRAME_H2 + H3 = mt5.TIMEFRAME_H3 + H4 = mt5.TIMEFRAME_H4 + H6 = mt5.TIMEFRAME_H6 + H8 = mt5.TIMEFRAME_H8 + H12 = mt5.TIMEFRAME_H12 + D1 = mt5.TIMEFRAME_D1 + W1 = mt5.TIMEFRAME_W1 + MN1 = mt5.TIMEFRAME_MN1 + + @property + def time(self): + """The number of seconds in a TIMEFRAME + + Returns: + int: The number of seconds in a TIMEFRAME + + Examples: + >>> t = TimeFrame.H1 + >>> print(t.time) + 3600 + """ + times = {1: 60, 2: 120, 3: 180, 4: 240, 5: 300, 6: 360, 10: 600, 15: 900, 20: 1200, 30: 1800, 16385: 3600, + 16386: 7200, 16387: 10800, 16388: 14400, 16390: 21600, 16392: 28800, 16396: 43200, 16408: 86400, + 32769: 604800, 49153: 2592000} + return times[self] + + @classmethod + def get(cls, time: int) -> 'TimeFrame': + times = {60: 1, 120: 2, 180: 3, 240: 4, 300: 5, 360: 6, 600: 10, 900: 15, 1200: 20, 1800: 30, 3600: 16385, + 7200: 16386, 10800: 16387, 14400: 16388, 21600: 16390, 28800: 16392, 43200: 16396, 86400: 16408, + 604800: 32769, 2592000: 49153} + return TimeFrame(times[int(time)]) + + +class CopyTicks(Repr, IntEnum): + """COPY_TICKS Enum. This defines the types of ticks that can be requested using the copy_ticks_from() and + copy_ticks_range() functions. + + Attributes: + ALL (int): All ticks + INFO (int): Ticks containing Bid and/or Ask price changes + TRADE (int): Ticks containing Last and/or Volume price changes + """ + __enum_name__ = "COPY_TICKS" + ALL = mt5.COPY_TICKS_ALL + INFO = mt5.COPY_TICKS_INFO + TRADE = mt5.COPY_TICKS_TRADE + + +class PositionType(Repr, IntEnum): + """POSITION_TYPE Enum. Direction of an open position (buy or sell) + + Attributes: + BUY (int): Buy + SELL (int): Sell + """ + __enum_name__ = "POSITION_TYPE" + BUY = mt5.POSITION_TYPE_BUY + SELL = mt5.POSITION_TYPE_SELL + + +class PositionReason(Repr, IntEnum): + """POSITION_REASON Enum. The reason for opening a position is contained in the POSITION_REASON Enum + + Attributes: + CLIENT (int): The position was opened as a result of activation of an order placed from a desktop terminal + MOBILE (int): The position was opened as a result of activation of an order placed from a mobile application + WEB (int): The position was opened as a result of activation of an order placed from the web platform + EXPERT (int): The position was opened as a result of activation of an order placed from an MQL5 program, + i.e. an Expert Advisor or a script + """ + __enum_name__ = "POSITION_REASON" + CLIENT = mt5.POSITION_REASON_CLIENT + MOBILE = mt5.POSITION_REASON_MOBILE + WEB = mt5.POSITION_REASON_WEB + EXPERT = mt5.POSITION_REASON_EXPERT + + +class DealType(Repr, IntEnum): + """DEAL_TYPE enum. Each deal is characterized by a type, allowed values are enumerated in this enum + + Attributes: + BUY (int): Buy + SELL (int): Sell + BALANCE (int): Balance + CREDIT (int): Credit + CHARGE (int): Additional Charge + CORRECTION (int): Correction + BONUS (int): Bonus + COMMISSION (int): Additional Commission + COMMISSION_DAILY (int): Daily Commission + COMMISSION_MONTHLY (int): Monthly Commission + COMMISSION_AGENT_DAILY (int): Daily Agent Commission + COMMISSION_AGENT_MONTHLY (int): Monthly Agent Commission + INTEREST (int): Interest Rate + DEAL_DIVIDEND (int): Dividend Operations + DEAL_DIVIDEND_FRANKED (int): Franked (non-taxable) dividend operations + DEAL_TAX (int): Tax Charges + + BUY_CANCELED (int): Canceled buy deal. There can be a situation when a previously executed buy deal is canceled. + In this case, the type of the previously executed deal (DEAL_TYPE_BUY) is changed to DEAL_TYPE_BUY_CANCELED, + and its profit/loss is zeroized. Previously obtained profit/loss is charged/withdrawn using a separated + balance operation + + SELL_CANCELED (int): Canceled sell deal. There can be a situation when a previously executed sell deal is + canceled. In this case, the type of the previously executed deal (DEAL_TYPE_SELL) is changed to + DEAL_TYPE_SELL_CANCELED, and its profit/loss is zeroized. Previously obtained profit/loss is + charged/withdrawn using a separated balance operation. + """ + __enum_name__ = "DEAL_TYPE" + BUY = mt5.DEAL_TYPE_BUY + SELL = mt5.DEAL_TYPE_SELL + BALANCE = mt5.DEAL_TYPE_BALANCE + CREDIT = mt5.DEAL_TYPE_CREDIT + CHARGE = mt5.DEAL_TYPE_CHARGE + CORRECTION = mt5.DEAL_TYPE_CORRECTION + BONUS = mt5.DEAL_TYPE_BONUS + COMMISSION = mt5.DEAL_TYPE_COMMISSION + COMMISSION_DAILY = mt5.DEAL_TYPE_COMMISSION_DAILY + COMMISSION_MONTHLY = mt5.DEAL_TYPE_COMMISSION_MONTHLY + COMMISSION_AGENT_DAILY = mt5.DEAL_TYPE_COMMISSION_AGENT_DAILY + COMMISSION_AGENT_MONTHLY = mt5.DEAL_TYPE_COMMISSION_AGENT_MONTHLY + INTEREST = mt5.DEAL_TYPE_INTEREST + BUY_CANCELED = mt5.DEAL_TYPE_BUY_CANCELED + SELL_CANCELED = mt5.DEAL_TYPE_SELL_CANCELED + DEAL_DIVIDEND = mt5.DEAL_DIVIDEND + DEAL_DIVIDEND_FRANKED = mt5.DEAL_DIVIDEND_FRANKED + DEAL_TAX = mt5.DEAL_TAX + + def __str__(self): + if self.name in ('DEAL_DIVIDEND', 'DEAL_DIVIDEND_FRANKED', 'DEAL_TAX'): + return self.name + return super().__str__() + + +class DealEntry(Repr, IntEnum): + """DEAL_ENTRY Enum. Deals differ not only in their types set in DEAL_TYPE enum, but also in the way they change + positions. This can be a simple position opening, or accumulation of a previously opened position (market entering), + position closing by an opposite deal of a corresponding volume (market exiting), or position reversing, if the + opposite-direction deal covers the volume of the previously opened position. + + Attributes: + IN (int): Entry In + OUT (int): Entry Out + INOUT (int): Reverse + OUT_BY (int): Close a position by an opposite one + """ + __enum_name__ = "DEAL_ENTRY" + IN = mt5.DEAL_ENTRY_IN + OUT = mt5.DEAL_ENTRY_OUT + INOUT = mt5.DEAL_ENTRY_INOUT + OUT_BY = mt5.DEAL_ENTRY_OUT_BY + + +class DealReason(Repr, IntEnum): + """DEAL_REASON Enum. The reason for deal execution is contained in the DEAL_REASON property. A deal can be executed + as a result of triggering of an order placed from a mobile application or an MQL5 program, as well as a result + of the StopOut event, variation margin calculation, etc. + + Attributes: + CLIENT (int): The deal was executed as a result of activation of an order placed from a desktop terminal + MOBILE (int): The deal was executed as a result of activation of an order placed from a desktop terminal + WEB (int): The deal was executed as a result of activation of an order placed from the web platform + EXPERT (int): The deal was executed as a result of activation of an order placed from an MQL5 program, i.e. + an Expert Advisor or a script + SL (int): The deal was executed as a result of Stop Loss activation + TP (int): The deal was executed as a result of Take Profit activation + SO (int): The deal was executed as a result of the Stop Out event + ROLLOVER (int): The deal was executed due to a rollover + VMARGIN (int): The deal was executed after charging the variation margin + SPLIT (int): The deal was executed after the split (price reduction) of an instrument, which had an open + position during split announcement + """ + __enum_name__ = "DEAL_REASON" + CLIENT = mt5.DEAL_REASON_CLIENT + MOBILE = mt5.DEAL_REASON_MOBILE + WEB = mt5.DEAL_REASON_WEB + EXPERT = mt5.DEAL_REASON_EXPERT + SL = mt5.DEAL_REASON_SL + TP = mt5.DEAL_REASON_TP + SO = mt5.DEAL_REASON_SO + ROLLOVER = mt5.DEAL_REASON_ROLLOVER + VMARGIN = mt5.DEAL_REASON_VMARGIN + SPLIT = mt5.DEAL_REASON_SPLIT + + +class OrderReason(Repr, IntEnum): + """ORDER_REASON Enum. + + Attributes: + CLIENT (int): The order was placed from a desktop terminal + MOBILE (int): The order was placed from a mobile application + WEB (int): The order was placed from a web platform + EXPERT (int): The order was placed from an MQL5-program, i.e. by an Expert Advisor or a script + SL (int): The order was placed as a result of Stop Loss activation + TP (int): The order was placed as a result of Take Profit activation + SO (int): The order was placed as a result of the Stop Out event + """ + __enum_name__ = "ORDER_REASON" + CLIENT = mt5.ORDER_REASON_CLIENT + MOBILE = mt5.ORDER_REASON_MOBILE + WEB = mt5.ORDER_REASON_WEB + EXPERT = mt5.ORDER_REASON_EXPERT + SL = mt5.ORDER_REASON_SL + TP = mt5.ORDER_REASON_TP + SO = mt5.ORDER_REASON_SO + + +class SymbolChartMode(Repr, IntEnum): + """SYMBOL_CHART_MODE Enum. A symbol price chart can be based on Bid or Last prices. The price selected for symbol + charts also affects the generation and display of bars in the terminal. + Possible values of the SYMBOL_CHART_MODE property are described in this enum + + Attributes: + BID (int): Bars are based on Bid prices + LAST (int): Bars are based on last prices + """ + __enum_name__ = "SYMBOL_CHART_MODE" + BID = mt5.SYMBOL_CHART_MODE_BID + LAST = mt5.SYMBOL_CHART_MODE_LAST + + +class SymbolCalcMode(Repr, IntEnum): + """SYMBOL_CALC_MODE Enum. The SYMBOL_CALC_MODE enumeration is used for obtaining information about how the margin + requirements for a symbol are calculated. + + Attributes: + FOREX (int): Forex mode - calculation of profit and margin for Forex + FOREX_NO_LEVERAGE (int): Forex No Leverage mode – calculation of profit and margin for Forex symbols without + taking into account the leverage + FUTURES (int): Futures mode - calculation of margin and profit for futures + CFD (int): CFD mode - calculation of margin and profit for CFD + CFDINDEX (int): CFD index mode - calculation of margin and profit for CFD by indexes + CFDLEVERAGE (int): CFD Leverage mode - calculation of margin and profit for CFD at leverage trading + EXCH_STOCKS (int): Calculation of margin and profit for trading securities on a stock exchange + EXCH_FUTURES (int): Calculation of margin and profit for trading futures contracts on a stock exchange + EXCH_OPTIONS (int): value is 34 + EXCH_OPTIONS_MARGIN (int): value is 36 + EXCH_BONDS (int): Exchange Bonds mode – calculation of margin and profit for trading bonds on a stock exchange + EXCH_STOCKS_MOEX (int): Exchange MOEX Stocks mode –calculation of margin and profit for trading securities on + MOEX + EXCH_BONDS_MOEX (int): Exchange MOEX Bonds mode – calculation of margin and profit for trading bonds on MOEX + + SERV_COLLATERAL (int): Collateral mode - a symbol is used as a non-tradeable asset on a trading account. + The market value of an open position is calculated based on the volume, current market price, contract size + and liquidity ratio. The value is included into Assets, which are added to Equity. Open positions of such + symbols increase the Free Margin amount and are used as additional margin (collateral) for open positions + """ + __enum_name__ = "SYMBOL_CALC_MODE" + FOREX = mt5.SYMBOL_CALC_MODE_FOREX + FOREX_NO_LEVERAGE = mt5.SYMBOL_CALC_MODE_FOREX_NO_LEVERAGE + FUTURES = mt5.SYMBOL_CALC_MODE_FUTURES + CFD = mt5.SYMBOL_CALC_MODE_CFD + CFDINDEX = mt5.SYMBOL_CALC_MODE_CFDINDEX + CFDLEVERAGE = mt5.SYMBOL_CALC_MODE_CFDLEVERAGE + EXCH_STOCKS = mt5.SYMBOL_CALC_MODE_EXCH_STOCKS + EXCH_FUTURES = mt5.SYMBOL_CALC_MODE_EXCH_FUTURES + EXCH_OPTIONS = mt5.SYMBOL_CALC_MODE_EXCH_OPTIONS + EXCH_OPTIONS_MARGIN = mt5.SYMBOL_CALC_MODE_EXCH_OPTIONS_MARGIN + EXCH_BONDS = mt5.SYMBOL_CALC_MODE_EXCH_BONDS + EXCH_STOCKS_MOEX = mt5.SYMBOL_CALC_MODE_EXCH_STOCKS_MOEX + EXCH_BONDS_MOEX = mt5.SYMBOL_CALC_MODE_EXCH_BONDS_MOEX + SERV_COLLATERAL = mt5.SYMBOL_CALC_MODE_SERV_COLLATERAL + + +class SymbolTradeMode(Repr, IntEnum): + """SYMBOL_TRADE_MODE Enum. There are several symbol trading modes. Information about trading modes of a certain + symbol is reflected in the values this enumeration + + Attributes: + DISABLED (int): Trade is disabled for the symbol + LONGONLY (int): Allowed only long positions + SHORTONLY (int): Allowed only short positions + CLOSEONLY (int): Allowed only position close operations + FULL (int): No trade restrictions + """ + + __enum_name__ = "SYMBOL_TRADE_MODE" + DISABLED = mt5.SYMBOL_TRADE_MODE_DISABLED + LONGONLY = mt5.SYMBOL_TRADE_MODE_LONGONLY + SHORTONLY = mt5.SYMBOL_TRADE_MODE_SHORTONLY + CLOSEONLY = mt5.SYMBOL_TRADE_MODE_CLOSEONLY + FULL = mt5.SYMBOL_TRADE_MODE_FULL + + +class SymbolTradeExecution(Repr, IntEnum): + """SYMBOL_TRADE_EXECUTION Enum. The modes, or execution policies, define the rules for cases when the price has + changed or the requested volume cannot be completely fulfilled at the moment. + + Attributes: + REQUEST (int): Executing a market order at the price previously received from the broker. Prices for a certain + market order are requested from the broker before the order is sent. Upon receiving the prices, order + execution at the given price can be either confirmed or rejected. + + INSTANT (int): Executing a market order at the specified price immediately. When sending a trade request to be + executed, the platform automatically adds the current prices to the order. + - If the broker accepts the price, the order is executed. + - If the broker does not accept the requested price, a "Requote" is sent — the broker returns prices, + at which this order can be executed. + + MARKET (int): A broker makes a decision about the order execution price without any additional discussion with + the trader. + Sending the order in such a mode means advance consent to its execution at this price. + + EXCHANGE (int): Trade operations are executed at the prices of the current market offers. + """ + + __enum_name__ = "SYMBOL_TRADE_EXECUTION" + REQUEST = mt5.SYMBOL_TRADE_EXECUTION_REQUEST + INSTANT = mt5.SYMBOL_TRADE_EXECUTION_INSTANT + MARKET = mt5.SYMBOL_TRADE_EXECUTION_MARKET + EXCHANGE = mt5.SYMBOL_TRADE_EXECUTION_EXCHANGE + + +class SymbolSwapMode(Repr, IntEnum): + """SYMBOL_SWAP_MODE Enum. Methods of swap calculation at position transfer are specified in enumeration + ENUM_SYMBOL_SWAP_MODE. The method of swap calculation determines the units of measure of the SYMBOL_SWAP_LONG and + SYMBOL_SWAP_SHORT parameters. For example, if swaps are charged in the client deposit currency, then the values of + those parameters are specified as an amount of money in the client deposit currency. + + Attributes: + DISABLED (int): Swaps disabled (no swaps) + POINTS (int): Swaps are charged in points + CURRENCY_SYMBOL (int): Swaps are charged in money in base currency of the symbol + CURRENCY_MARGIN (int): Swaps are charged in money in margin currency of the symbol + CURRENCY_DEPOSIT (int): Swaps are charged in money, in client deposit currency + + INTEREST_CURRENT (int): Swaps are charged as the specified annual interest from the instrument price at + calculation of swap (standard bank year is 360 days) + + INTEREST_OPEN (int): Swaps are charged as the specified annual interest from the open price of position + (standard bank year is 360 days) + + REOPEN_CURRENT (int): Swaps are charged by reopening positions. At the end of a trading day the position is + closed. Next day it is reopened by the close price +/- specified number of points + (parameters SYMBOL_SWAP_LONG and SYMBOL_SWAP_SHORT) + + REOPEN_BID (int): Swaps are charged by reopening positions. At the end of a trading day the position is closed. + Next day it is reopened by the current Bid price +/- specified number of + points (parameters SYMBOL_SWAP_LONG and SYMBOL_SWAP_SHORT) + """ + __enum_name__ = "SYMBOL_SWAP_MODE" + DISABLED = mt5.SYMBOL_SWAP_MODE_DISABLED + POINTS = mt5.SYMBOL_SWAP_MODE_POINTS + CURRENCY_SYMBOL = mt5.SYMBOL_SWAP_MODE_CURRENCY_SYMBOL + CURRENCY_MARGIN = mt5.SYMBOL_SWAP_MODE_CURRENCY_MARGIN + CURRENCY_DEPOSIT = mt5.SYMBOL_SWAP_MODE_CURRENCY_DEPOSIT + INTEREST_CURRENT = mt5.SYMBOL_SWAP_MODE_INTEREST_CURRENT + INTEREST_OPEN = mt5.SYMBOL_SWAP_MODE_INTEREST_OPEN + REOPEN_CURRENT = mt5.SYMBOL_SWAP_MODE_REOPEN_CURRENT + REOPEN_BID = mt5.SYMBOL_SWAP_MODE_REOPEN_BID + + +class DayOfWeek(Repr, IntEnum): + """DAY_OF_WEEK Enum. + + Attributes: + SUNDAY (int): Sunday + MONDAY (int): Monday + TUESDAY (int): Tuesday + WEDNESDAY (int): Wednesday + THURSDAY (int): Thursday + FRIDAY (int): Friday + SATURDAY (int): Saturday + """ + __enum__name__ = "DAY_OF_WEEK" + SUNDAY = mt5.DAY_OF_WEEK_SUNDAY + MONDAY = mt5.DAY_OF_WEEK_MONDAY + TUESDAY = mt5.DAY_OF_WEEK_TUESDAY + WEDNESDAY = mt5.DAY_OF_WEEK_WEDNESDAY + THURSDAY = mt5.DAY_OF_WEEK_THURSDAY + FRIDAY = mt5.DAY_OF_WEEK_FRIDAY + SATURDAY = mt5.DAY_OF_WEEK_SATURDAY + + +class SymbolOrderGTCMode(Repr, IntEnum): + """SYMBOL_ORDER_GTC_MODE Enum. If the SYMBOL_EXPIRATION_MODE property is set to SYMBOL_EXPIRATION_GTC + (good till canceled), the expiration of pending orders, as well as of + Stop Loss/Take Profit orders should be additionally set using the ENUM_SYMBOL_ORDER_GTC_MODE enumeration. + + Attributes: + GTC (int): Pending orders and Stop Loss/Take Profit levels are valid for an unlimited period + until theirConstants, Enumerations and explicit cancellation + + DAILY (int): Orders are valid during one trading day. At the end of the day, all Stop Loss and + Take Profit levels, as well as pending orders are deleted. + + DAILY_NO_STOPS (int): When a trade day changes, only pending orders are deleted, + while Stop Loss and Take Profit levels are preserved + """ + __enum_name__ = "SYMBOL_ORDERS" + GTC = mt5.SYMBOL_ORDERS_GTC + DAILY = mt5.SYMBOL_ORDERS_DAILY + DAILY_NO_STOPS = mt5.SYMBOL_ORDERS_DAILY_NO_STOPS + + +class SymbolOptionRight(Repr, IntEnum): + """SYMBOL_OPTION_RIGHT Enum. An option is a contract, which gives the right, but not the obligation, + to buy or sell an underlying asset (goods, stocks, futures, etc.) at a specified price on or before a specific date. + The following enumerations describe option properties, including the option type and the right arising from it. + + Attributes: + CALL (int): A call option gives you the right to buy an asset at a specified price. + PUT (int): A put option gives you the right to sell an asset at a specified price. + """ + __enum_name__ = "SYMBOL_OPTION_RIGHT" + CALL = mt5.SYMBOL_OPTION_RIGHT_CALL + PUT = mt5.SYMBOL_OPTION_RIGHT_PUT + + +class SymbolOptionMode(Repr, IntEnum): + """SYMBOL_OPTION_MODE Enum. + + Attributes: + EUROPEAN (int): European option may only be exercised on a specified date + (expiration, execution date, delivery date) + AMERICAN (int): American option may be exercised on any trading day or before expiry. The period within which + a buyer can exercise the option is specified for it. + """ + __enum_name__ = "SYMBOL_OPTION_MODE" + EUROPEAN = mt5.SYMBOL_OPTION_MODE_EUROPEAN + AMERICAN = mt5.SYMBOL_OPTION_MODE_AMERICAN + + +class AccountTradeMode(Repr, IntEnum): + """ACCOUNT_TRADE_MODE Enum. There are several types of accounts that can be opened on a trade server. + The type of account on which an MQL5 program is running can be found out using + the ENUM_ACCOUNT_TRADE_MODE enumeration. + + Attributes: + DEMO: Demo account + CONTEST: Contest account + REAL: Real Account + """ + __enum_name__ = "ACCOUNT_TRADE_MODE" + DEMO = mt5.ACCOUNT_TRADE_MODE_DEMO + CONTEST = mt5.ACCOUNT_TRADE_MODE_CONTEST + REAL = mt5.ACCOUNT_TRADE_MODE_REAL + + +class TickFlag(Repr, IntFlag): + """TICK_FLAG Enum. TICK_FLAG defines possible flags for ticks. The Flags are used to describe ticks obtained by the + copy_ticks_from() and copy_ticks_range() functions. + + Attributes: + BID (int): Bid price changed + ASK (int): Ask price changed + LAST (int): Last price changed + VOLUME (int): Volume changed + BUY (int): last Buy price changed + SELL (int): last Sell price changed + """ + __enum_name__ = "TICK_FLAG" + BID = mt5.TICK_FLAG_BID + ASK = mt5.TICK_FLAG_ASK + LAST = mt5.TICK_FLAG_LAST + VOLUME = mt5.TICK_FLAG_VOLUME + BUY = mt5.TICK_FLAG_BUY + SELL = mt5.TICK_FLAG_SELL + + +class TradeRetcode(Repr, IntEnum): + """TRADE_RETCODE Enum. Return codes for order send/check operations + + Attributes: + REQUOTE (int): Requote + REJECT (int): Request rejected + CANCEL (int): Request canceled by trader + PLACED (int): Order placed + DONE (int): Request completed + DONE_PARTIAL (int): Only part of the request was completed + ERROR (int): Request processing error + TIMEOUT (int): Request canceled by timeout + INVALID (int): Invalid request + INVALID_VOLUME (int): Invalid volume in the request + INVALID_PRICE (int): Invalid price in the request + INVALID_STOPS (int): Invalid stops in the request + TRADE_DISABLED (int): Trade is disabled + MARKET_CLOSED (int): Market is closed + NO_MONEY (int): There is not enough money to complete the request + PRICE_CHANGED (int): Prices changed + PRICE_OFF (int): There are no quotes to process the request + INVALID_EXPIRATION (int): Invalid order expiration date in the request + ORDER_CHANGED (int): Order state changed + TOO_MANY_REQUESTS (int): Too frequent requests + NO_CHANGES (int): No changes in request + SERVER_DISABLES_AT (int): Autotrading disabled by server + CLIENT_DISABLES_AT (int): Autotrading disabled by client terminal + LOCKED (int): Request locked for processing + FROZEN (int): Order or position frozen + INVALID_FILL (int): Invalid order filling type + CONNECTION (int): No connection with the trade server + ONLY_REAL (int): Operation is allowed only for live accounts + LIMIT_ORDERS (int): The number of pending orders has reached the limit + LIMIT_VOLUME (int): The volume of orders and positions for the symbol has reached the limit + INVALID_ORDER (int): Incorrect or prohibited order type + POSITION_CLOSED (int): Position with the specified POSITION_IDENTIFIER has already been closed + INVALID_CLOSE_VOLUME (int): A close volume exceeds the current position volume + + CLOSE_ORDER_EXIST (int): A close order already exists for a specified position. This may happen when working in + the hedging system: + · when attempting to close a position with an opposite one, while close orders for the position already + exist + · when attempting to fully or partially close a position if the total volume of the already present close + orders and the newly placed one exceeds the current position volume + + LIMIT_POSITIONS (int): The number of open positions simultaneously present on an account can be limited by the + server settings.After a limit is reached, the server returns the TRADE_RETCODE_LIMIT_POSITIONS error when + attempting to place an order. The limitation operates differently depending on the position accounting type: + · Netting — number of open positions is considered. When a limit is reached, the platform does not let + placing new orders whose execution may increase the number of open positions. In fact, the platform + allows placing orders only for the symbols that already have open positions. + The current pending orders are not considered since their execution may lead to changes in the current + positions but it cannot increase their number. + + · Hedging — pending orders are considered together with open positions, since a pending order activation + always leads to opening a new position. When a limit is reached, the platform does not allow placing + both new market orders for opening positions and pending orders. + + REJECT_CANCEL (int): The pending order activation request is rejected, the order is canceled. + LONG_ONLY (int): The request is rejected, because the "Only long positions are allowed" rule is set for the + symbol (POSITION_TYPE_BUY) + SHORT_ONLY (int): The request is rejected, because the "Only short positions are allowed" rule is set for the + symbol (POSITION_TYPE_SELL) + CLOSE_ONLY (int): The request is rejected, because the "Only position closing is allowed" rule is set for the + symbol + FIFO_CLOSE (int): The request is rejected, because "Position closing is allowed only by FIFO rule" flag is set + for the trading account (ACCOUNT_FIFO_CLOSE=true) + """ + __enum_name__ = "TRADE_RETCODE" + REQUOTE = mt5.TRADE_RETCODE_REQUOTE + REJECT = mt5.TRADE_RETCODE_REJECT + CANCEL = mt5.TRADE_RETCODE_CANCEL + PLACED = mt5.TRADE_RETCODE_PLACED + DONE = mt5.TRADE_RETCODE_DONE + DONE_PARTIAL = mt5.TRADE_RETCODE_DONE_PARTIAL + ERROR = mt5.TRADE_RETCODE_ERROR + TIMEOUT = mt5.TRADE_RETCODE_TIMEOUT + INVALID = mt5.TRADE_RETCODE_INVALID + INVALID_VOLUME = mt5.TRADE_RETCODE_INVALID_VOLUME + INVALID_PRICE = mt5.TRADE_RETCODE_INVALID_PRICE + INVALID_STOPS = mt5.TRADE_RETCODE_INVALID_STOPS + TRADE_DISABLED = mt5.TRADE_RETCODE_TRADE_DISABLED + MARKET_CLOSED = mt5.TRADE_RETCODE_MARKET_CLOSED + NO_MONEY = mt5.TRADE_RETCODE_NO_MONEY + PRICE_CHANGED = mt5.TRADE_RETCODE_PRICE_CHANGED + PRICE_OFF = mt5.TRADE_RETCODE_PRICE_OFF + INVALID_EXPIRATION = mt5.TRADE_RETCODE_INVALID_EXPIRATION + ORDER_CHANGED = mt5.TRADE_RETCODE_ORDER_CHANGED + TOO_MANY_REQUESTS = mt5.TRADE_RETCODE_TOO_MANY_REQUESTS + NO_CHANGES = mt5.TRADE_RETCODE_NO_CHANGES + SERVER_DISABLES_AT = mt5.TRADE_RETCODE_SERVER_DISABLES_AT + CLIENT_DISABLES_AT = mt5.TRADE_RETCODE_CLIENT_DISABLES_AT + LOCKED = mt5.TRADE_RETCODE_LOCKED + FROZEN = mt5.TRADE_RETCODE_FROZEN + INVALID_FILL = mt5.TRADE_RETCODE_INVALID_FILL + CONNECTION = mt5.TRADE_RETCODE_CONNECTION + ONLY_REAL = mt5.TRADE_RETCODE_ONLY_REAL + LIMIT_ORDERS = mt5.TRADE_RETCODE_LIMIT_ORDERS + LIMIT_VOLUME = mt5.TRADE_RETCODE_LIMIT_VOLUME + INVALID_ORDER = mt5.TRADE_RETCODE_INVALID_ORDER + POSITION_CLOSED = mt5.TRADE_RETCODE_POSITION_CLOSED + INVALID_CLOSE_VOLUME = mt5.TRADE_RETCODE_INVALID_CLOSE_VOLUME + CLOSE_ORDER_EXIST = mt5.TRADE_RETCODE_CLOSE_ORDER_EXIST + LIMIT_POSITIONS = mt5.TRADE_RETCODE_LIMIT_POSITIONS + REJECT_CANCEL = mt5.TRADE_RETCODE_REJECT_CANCEL + LONG_ONLY = mt5.TRADE_RETCODE_LONG_ONLY + SHORT_ONLY = mt5.TRADE_RETCODE_SHORT_ONLY + CLOSE_ONLY = mt5.TRADE_RETCODE_CLOSE_ONLY + FIFO_CLOSE = mt5.TRADE_RETCODE_FIFO_CLOSE + + +class AccountStopOutMode(Repr, IntEnum): + """ACCOUNT_STOPOUT_MODE Enum. + + Attributes: + PERCENT (int): Account stop out mode in percents + MONEY (int): Account stop out mode in money + """ + + __enum_name__ = "ACCOUNT_STOPOUT_MODE" + PERCENT = mt5.ACCOUNT_STOPOUT_MODE_PERCENT + MONEY = mt5.ACCOUNT_STOPOUT_MODE_MONEY + + +class AccountMarginMode(Repr, IntEnum): + """ACCOUNT_MARGIN_MODE Enum. + + Attributes: + RETAIL_NETTING (int): Used for the OTC markets to interpret positions in the "netting" + mode (only one position can exist for one symbol). The margin is calculated based on the symbol + type (SYMBOL_TRADE_CALC_MODE). + + EXCHANGE (int): Used for the exchange markets. Margin is calculated based on the discounts specified in + symbol settings. Discounts are set by the broker, but not less than the values set by the exchange. + + RETAIL_HEDGING (int): Used for the exchange markets where individual positions are possible + (hedging, multiple positions can exist for one symbol). The margin is calculated based on the symbol + type (SYMBOL_TRADE_CALC_MODE) taking into account the hedged margin (SYMBOL_MARGIN_HEDGED). + """ + __enum_name__ = "ACCOUNT_MARGIN_MODE" + RETAIL_NETTING = mt5.ACCOUNT_MARGIN_MODE_RETAIL_NETTING + EXCHANGE = mt5.ACCOUNT_MARGIN_MODE_EXCHANGE + RETAIL_HEDGING = mt5.ACCOUNT_MARGIN_MODE_RETAIL_HEDGING diff --git a/src/aiomql/core/errors.py b/src/aiomql/core/errors.py new file mode 100644 index 0000000..dd17ecd --- /dev/null +++ b/src/aiomql/core/errors.py @@ -0,0 +1,33 @@ +class Error: + """Error class for handling errors from MetaTrader 5.""" + descriptions = { + # common errors + 1: 'Successful', + -1: 'generic fail', + -2: 'invalid arguments/parameters', + -3: 'no memory condition', + -4: 'no history', + -5: 'invalid version', + -6: 'authorization failed', + -7: 'unsupported method', + -8: 'auto-trading disabled', + # internal errors + -10000: 'internal IPC general error', + -10001: 'internal IPC send failed', + -10002: 'internal IPC recv failed', + -10003: 'internal IPC initialization fail', + -10004: 'internal IPC no ipc', + -10005: 'internal timeout', + } + + conn_errors = (-10000, -10001, -10002, -10003, -10004, -10005) + + def __init__(self, code: int, description: str = ''): + self.code = code + self.description = description or self.descriptions.get(code, 'Unknown Error') + + def is_connection_error(self): + return self.code in self.conn_errors + + def __repr__(self): + return f"{self.code}: {self.description}" diff --git a/src/aiomql/core/exceptions.py b/src/aiomql/core/exceptions.py new file mode 100644 index 0000000..cffbd2a --- /dev/null +++ b/src/aiomql/core/exceptions.py @@ -0,0 +1,19 @@ +"""Exceptions for the aiomql package.""" + +__all__ = ['LoginError', 'VolumeError', 'SymbolError', 'OrderError'] + +class LoginError(Exception): + """Raised when an error occurs when logging in.""" + pass + +class VolumeError(Exception): + """Raised when a volume is not valid or out of range for a symbol.""" + pass + + +class SymbolError(Exception): + """Raised when a symbol is not provided where required or not available in the Market Watch.""" + + +class OrderError(Exception): + """Raised when an error occurs when working with the order class.""" diff --git a/src/aiomql/core/meta_trader.py b/src/aiomql/core/meta_trader.py new file mode 100644 index 0000000..76088d3 --- /dev/null +++ b/src/aiomql/core/meta_trader.py @@ -0,0 +1,349 @@ +from datetime import datetime +import asyncio +from logging import getLogger +from typing import Callable + +import MetaTrader5 +from MetaTrader5 import BookInfo, SymbolInfo, AccountInfo, Tick, TerminalInfo, TradeOrder, TradeDeal, \ + TradePosition, OrderSendResult, OrderCheckResult + +from .constants import TimeFrame, CopyTicks, OrderType +from .errors import Error +from .config import Config + +logger = getLogger() + + +class BaseMeta(type): + def __new__(mcs, cls_name, bases, cls_dict): + defaults = MetaTrader5.__dict__ + defaults = {f'_{key}': value for key, value in defaults.items() if not key.startswith('_')} + cls_dict |= defaults + return super().__new__(mcs, cls_name, bases, cls_dict) + + +class MetaTrader(metaclass=BaseMeta): + _account_info: Callable + _copy_rates_from: Callable + _copy_rates_from_pos: Callable + _copy_rates_range: Callable + _copy_ticks_from: Callable + _copy_ticks_range: Callable + _history_deals_get: Callable + _history_deals_total: Callable + _history_orders_get: Callable + _history_orders_total: Callable + _initialize: Callable + _last_error: Callable + _login: Callable + _market_book_add: Callable + _market_book_get: Callable + _market_book_release: Callable + _order_calc_margin: Callable + _order_calc_profit: Callable + _order_check: Callable + _order_send: Callable + _orders_get: Callable + _orders_total: Callable + _positions_get: Callable + _positions_total: Callable + _shutdown: Callable + _symbol_info: Callable + _symbol_info_tick: Callable + _symbol_select: Callable + _symbols_get: Callable + _symbols_total: Callable + _terminal_info: Callable + _version: Callable + error: Error + config: Config + + def __init__(self): + self.config = Config() + + async def __aenter__(self) -> 'MetaTrader': + """ + Async context manager entry point. + Initializes the connection to the MetaTrader terminal. + + Returns: + MetaTrader: An instance of the MetaTrader class. + """ + await self.initialize(**Config().account_info()) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """ + Async context manager exit point. Closes the connection to the MetaTrader terminal. + """ + await self.shutdown() + + async def login(self, login: int, password: str, server: str, timeout: int = 60000) -> bool: + """ + Connects to the MetaTrader terminal using the specified login, password and server. + + Args: + login (int): The trading account number. + password (str): The trading account password. + server (str): The trading server name. + timeout (int): The timeout for the connection in seconds. + + Returns: + bool: True if successful, False otherwise. + """ + 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 = "", + timeout: int | None = None, portable=False) -> bool: + """ + Initializes the connection to the MetaTrader terminal. All parameters are optional. + + Keyword Args: + path (str): The path to the MetaTrader terminal executable. + login (int): The trading account number. + password (str): The trading account password. + server (str): The trading server name. + timeout (int): The timeout for the connection in seconds. + portable (bool): If True, the terminal will be launched in portable mode. + + Returns: + bool: True if successful, False otherwise. + """ + args = (str(path),) if path else () + kwargs = {key: value for key, value in (('login', login), ('password', password), ('server', server), + ('timeout', timeout), ('portable', portable)) if value} + return await asyncio.to_thread(self._initialize, *args, **kwargs) + + async def shutdown(self) -> None: + """ + Closes the connection to the MetaTrader terminal. + + Returns: + None: None + """ + return await asyncio.to_thread(self._shutdown) + + async def last_error(self) -> tuple[int, str]: + try: + return await asyncio.to_thread(self._last_error) + except Exception as err: + logger.warning(f'Error in obtaining last error.') + return -1, str(err) + + async def version(self) -> tuple[int, int, str] | None: + """""" + res = await asyncio.to_thread(self._version) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining version information.{self.error.description}') + return res + + async def account_info(self) -> AccountInfo | None: + """""" + res = await asyncio.to_thread(self._account_info) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining account information.{self.error.description}') + return res + + async def terminal_info(self) -> TerminalInfo | None: + res = await asyncio.to_thread(self._terminal_info) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining terminal information.{self.error.description}') + return res + return res + + async def symbols_total(self) -> int: + return await asyncio.to_thread(self._symbols_total) + + async def symbols_get(self, group: str = "") -> tuple[SymbolInfo] | None: + kwargs = {'group': group} if group else {} + res = await asyncio.to_thread(self._symbols_get, **kwargs) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining symbols.{self.error.description}') + return res + return res + + async def symbol_info(self, symbol: str) -> SymbolInfo | None: + res = await asyncio.to_thread(self._symbol_info, symbol) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining information for {symbol}.{self.error.description}') + return res + return res + + async def symbol_info_tick(self, symbol: str) -> Tick | None: + res = await asyncio.to_thread(self._symbol_info_tick, symbol) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining tick for {symbol}.{self.error.description}') + return res + return res + + async def symbol_select(self, symbol: str, enable: bool) -> bool: + return await asyncio.to_thread(self._symbol_select, symbol, enable) + + async def market_book_add(self, symbol: str) -> bool: + return await asyncio.to_thread(self._market_book_add, symbol) + + async def market_book_get(self, symbol: str) -> tuple[BookInfo] | None: + res = await asyncio.to_thread(self._market_book_get, symbol) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining market depth content for {symbol}.{self.error.description}') + return res + return res + + async def market_book_release(self, symbol: str) -> bool: + return await asyncio.to_thread(self._market_book_release, symbol) + + async def copy_rates_from(self, symbol: str, timeframe: TimeFrame, date_from: datetime | float, count: int): + res = await asyncio.to_thread(self._copy_rates_from, symbol, timeframe, date_from, count) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining rates for {symbol}.{self.error.description}') + return res + return res + + async def copy_rates_from_pos(self, symbol: str, timeframe: TimeFrame, start_pos: int, count: int): + res = await asyncio.to_thread(self._copy_rates_from_pos, symbol, timeframe, start_pos, count) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining rates for {symbol}.{self.error.description}') + return res + return res + + async def copy_rates_range(self, symbol: str, timeframe: TimeFrame, date_from: datetime | float, + date_to: datetime | float): + res = await asyncio.to_thread(self._copy_rates_range, symbol, timeframe, date_from, date_to) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining rates for {symbol}.{self.error.description}') + return res + return res + + async def copy_ticks_from(self, symbol: str, date_from: datetime | float, count: int, flags: CopyTicks): + res = await asyncio.to_thread(self._copy_ticks_from, symbol, date_from, count, flags) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining ticks for {symbol}.{self.error.description}') + return res + return res + + async def copy_ticks_range(self, symbol: str, date_from: datetime | float, date_to: datetime | float, + flags: CopyTicks): + res = await asyncio.to_thread(self._copy_ticks_range, symbol, date_from, date_to, flags) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining ticks for {symbol}.{self.error.description}') + return res + return res + + async def orders_total(self) -> int: + return await asyncio.to_thread(self._orders_total) + + async def orders_get(self, group: str = "", ticket: int = 0, symbol: str = "") -> tuple[TradeOrder] | None: + """Get active orders with the ability to filter by symbol or ticket. There are three call options. + Call without parameters. Return active orders on all symbols + + Keyword Args: + symbol (str): Symbol name. Optional named parameter. If a symbol is specified, the ticket parameter is ignored. + + group (str): The filter for arranging a group of necessary symbols. Optional named parameter. If the group is specified, the function + returns only active orders meeting a specified criteria for a symbol name. + + ticket (int): Order ticket (ORDER_TICKET). Optional named parameter. + + Returns: + tuple[TradeOrder]: A list of active trade orders as TradeOrder objects + """ + kwargs = {key: value for key, value in (('group', group), ('ticket', ticket), ('symbol', symbol)) if value} + res = await asyncio.to_thread(self._orders_get, **kwargs) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining orders.{self.error.description}') + return res + return res + + async def order_calc_margin(self, action: OrderType, symbol: str, volume: float, price: float) -> float | None: + res = await asyncio.to_thread(self._order_calc_margin, action, symbol, volume, price) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in calculating margin.{self.error.description}') + return res + return res + + async def order_calc_profit(self, action: OrderType, symbol: str, volume: float, price_open: float, + price_close: float) -> float | None: + res = await asyncio.to_thread(self._order_calc_profit, action, symbol, volume, price_open, price_close) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in calculating profit.{self.error.description}') + return res + return res + + async def order_check(self, request: dict) -> OrderCheckResult: + return await asyncio.to_thread(self._order_check, request) + + async def order_send(self, request: dict) -> OrderSendResult: + return await asyncio.to_thread(self._order_send, request) + + async def positions_total(self) -> int: + return await asyncio.to_thread(self._positions_total) + + async def positions_get(self, group: str = "", ticket: int = None, symbol: str = "") -> tuple[TradePosition] | None: + kwargs = {key: value for key, value in (('group', group), ('ticket', ticket), ('symbol', symbol)) if value} + res = await asyncio.to_thread(self._positions_get, **kwargs) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in obtaining open positions.{self.error.description}') + return res + return res + + async def history_orders_total(self, date_from: datetime | float, date_to: datetime | float) -> int: + return await asyncio.to_thread(self._history_orders_total, date_from, date_to) + + async def history_orders_get(self, date_from: datetime | float = None, date_to: datetime | float = None, + group: str = '', ticket: int = None, position: int = None) -> tuple[TradeOrder] | None: + kwargs = {key: value for key, value in (('group', group), ('ticket', ticket), ('position', position)) if value} + args = tuple(arg for arg in (date_from, date_to) if arg) + res = await asyncio.to_thread(self._history_orders_get, *args, **kwargs) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in getting orders.{self.error.description}') + return res + return res + + async def history_deals_total(self, date_from: datetime | float, date_to: datetime | float) -> int: + return await asyncio.to_thread(self._history_deals_total, date_from, date_to) + + async def history_deals_get(self, date_from: datetime | float = None, date_to: datetime | float = None, + group: str = '', ticket: int = None, position: int = None) -> tuple[TradeDeal] | None: + kwargs = {key: value for key, value in (('group', group), ('ticket', ticket), ('position', position)) if value} + args = tuple(arg for arg in (date_from, date_to) if arg) + res = await asyncio.to_thread(self._history_deals_get, *args, **kwargs) + if res is None: + err = await self.last_error() + self.error = Error(*err) + logger.warning(f'Error in getting deals.{self.error}') + return res + return res diff --git a/src/aiomql/core/models.py b/src/aiomql/core/models.py new file mode 100644 index 0000000..e75b7ec --- /dev/null +++ b/src/aiomql/core/models.py @@ -0,0 +1,617 @@ +import MetaTrader5 as mt5 + +from .constants import BookType, TradeAction, OrderType, OrderTime, OrderFilling, PositionReason, DealType, DealEntry, \ + DealReason, SymbolChartMode, SymbolTradeMode, SymbolCalcMode, SymbolOptionMode, SymbolOrderGTCMode, \ + SymbolOptionRight, \ + SymbolTradeExecution, SymbolSwapMode, DayOfWeek, AccountTradeMode, AccountStopOutMode, AccountMarginMode, \ + OrderReason + +from .base import Base + +""" +This module contains data models used in this library. +They are used as base classes to other classes having the same properties but with more methods. +""" + + +class AccountInfo(Base): + """Account Information Class. + + Attributes: + login: int + password: str + server: str + trade_mode: AccountTradeMode + balance: float + leverage: float + profit: float + point: float + amount: float + equity: float + credit: float + margin: float + margin_level: float + margin_free: float + margin_mode: AccountMarginMode + margin_so_mode: AccountStopoutMode + margin_so_call: float + margin_so_so: float + margin_initial: float + margin_maintenance: float + fifo_close: bool + limit_orders: float + currency: str = "USD" + trade_allowed: bool = True + trade_expert: bool = True + currency_digits: int + assets: float + liabilities: float + commission_blocked: float + name: str + company: str + """ + login: int = 0 + password: str = '' + server: str = '' + trade_mode: AccountTradeMode + balance: float + leverage: float + profit: float + point: float + amount: float = 0 + equity: float + credit: float + margin: float + margin_level: float + margin_free: float + margin_mode: AccountMarginMode + margin_so_mode: AccountStopOutMode + margin_so_call: float + margin_so_so: float + margin_initial: float + margin_maintenance: float + fifo_close: bool + limit_orders: float + currency: str = "USD" + trade_allowed: bool = True + trade_expert: bool = True + currency_digits: int + assets: float + liabilities: float + commission_blocked: float + name: str + company: str + + +class TerminalInfo(Base): + """Terminal information class. Holds information about the terminal. + + Attributes: + community_account: bool + community_connection: bool + connected: bool + dlls_allowed: bool + trade_allowed: bool + tradeapi_disabled: bool + email_enabled: bool + ftp_enabled: bool + notifications_enabled: bool + mqid: bool + build: int + maxbars: int + codepage: int + ping_last: int + community_balance: float + retransmission: float + company: str + name: str + language: str + path: str + data_path: str + commondata_path: str + """ + community_account: bool + community_connection: bool + connected: bool + dlls_allowed: bool + trade_allowed: bool + tradeapi_disabled: bool + email_enabled: bool + ftp_enabled: bool + notifications_enabled: bool + mqid: bool + build: int + maxbars: int + codepage: int + ping_last: int + community_balance: float + retransmission: float + company: str + name: str + language: str + path: str + data_path: str + commondata_path: str + + +class SymbolInfo(Base): + """Symbol Information Class. Symbols are financial instruments available for trading in the MetaTrader 5 terminal. + + Attributes: + name: str + custom: bool + chart_mode: SymbolChartMode + select: bool + visible: bool + session_deals: int + session_buy_orders: int + session_sell_orders: int + volume: float + volumehigh: float + volumelow: float + time: int + digits: int + spread: float + spread_float: bool + ticks_bookdepth: int + trade_calc_mode: SymbolCalcMode + trade_mode: SymbolTradeMode + start_time: int + expiration_time: int + trade_stops_level: int + trade_freeze_level: int + trade_exemode: SymbolTradeExecution + swap_mode: SymbolSwapMode + swap_rollover3days: DayOfWeek + margin_hedged_use_leg: bool + expiration_mode: int + filling_mode: int + order_mode: int + order_gtc_mode: SymbolOrderGTCMode + option_mode: SymbolOptionMode + option_right: SymbolOptionRight + bid: float + bidhigh: float + bidlow: float + ask: float + askhigh: float + asklow: float + last: float + lasthigh: float + lastlow: float + volume_real: float + volumehigh_real: float + volumelow_real: float + option_strike: float + point: float + trade_tick_value: float + trade_tick_value_profit: float + trade_tick_value_loss: float + trade_tick_size: float + trade_contract_size: float + trade_accrued_interest: float + trade_face_value: float + trade_liquidity_rate: float + volume_min: float + volume_max: float + volume_step: float + volume_limit: float + swap_long: float + swap_short: float + margin_initial: float + margin_maintenance: float + session_volume: float + session_turnover: float + session_interest: float + session_buy_orders_volume: float + session_sell_orders_volume: float + session_open: float + session_close: float + session_aw: float + session_price_settlement: float + session_price_limit_min: float + session_price_limit_max: float + margin_hedged: float + price_change: float + price_volatility: float + price_theoretical: float + price_greeks_delta: float + price_greeks_theta: float + price_greeks_gamma: float + price_greeks_vega: float + price_greeks_rho: float + price_greeks_omega: float + price_sensitivity: float + basis: str + category: str + currency_base: str + currency_profit: str + currency_margin: Any + bank: str + description: str + exchange: str + formula: Any + isin: Any + name: str + page: str + path: str + """ + custom: bool + chart_mode: SymbolChartMode + select: bool + visible: bool + session_deals: int + session_buy_orders: int + session_sell_orders: int + volume: float + volumehigh: float + volumelow: float + time: int + digits: int + spread: float + spread_float: bool + ticks_bookdepth: int + trade_calc_mode: SymbolCalcMode + trade_mode: SymbolTradeMode + start_time: int + expiration_time: int + trade_stops_level: int + trade_freeze_level: int + trade_exemode: SymbolTradeExecution + swap_mode: SymbolSwapMode + swap_rollover3days: DayOfWeek + margin_hedged_use_leg: bool + expiration_mode: int + filling_mode: int + order_mode: int + order_gtc_mode: SymbolOrderGTCMode + option_mode: SymbolOptionMode + option_right: SymbolOptionRight + bid: float + bidhigh: float + bidlow: float + ask: float + askhigh: float + asklow: float + last: float + lasthigh: float + lastlow: float + volume_real: float + volumehigh_real: float + volumelow_real: float + option_strike: float + point: float + trade_tick_value: float + trade_tick_value_profit: float + trade_tick_value_loss: float + trade_tick_size: float + trade_contract_size: float + trade_accrued_interest: float + trade_face_value: float + trade_liquidity_rate: float + volume_min: float + volume_max: float + volume_step: float + volume_limit: float + swap_long: float + swap_short: float + margin_initial: float + margin_maintenance: float + session_volume: float + session_turnover: float + session_interest: float + session_buy_orders_volume: float + session_sell_orders_volume: float + session_open: float + session_close: float + session_aw: float + session_price_settlement: float + session_price_limit_min: float + session_price_limit_max: float + margin_hedged: float + price_change: float + price_volatility: float + price_theoretical: float + price_greeks_delta: float + price_greeks_theta: float + price_greeks_gamma: float + price_greeks_vega: float + price_greeks_rho: float + price_greeks_omega: float + price_sensitivity: float + basis: str + category: str + currency_base: str + currency_profit: str + currency_margin: str + bank: str + description: str + exchange: str + formula: str + isin: str + name: str + page: str + path: str + + def __init__(self, **kwargs): + if 'name' not in kwargs: + raise AttributeError('Symbol Object Must be initialized with a name') + self.name = kwargs.pop('name') + super().__init__(**kwargs) + + def __repr__(self): + return '%(class)s(name=%(name)s)' % {'class': self.__class__.__name__, 'name': self.name} + + def __str__(self): + return self.name + + def __eq__(self, other: "SymbolInfo"): + return self.name == other.name + + def __hash__(self): + return hash(self.name) + + +class BookInfo(Base): + """Book Information Class. + + Attributes: + type: BookType + price: float + volume: float + volume_dbl: float + """ + type: BookType + price: float + volume: float + volume_dbl: float + + +class TradeOrder(Base): + """Trade Order Class. + + Attributes: + ticket: int + time_setup: int + time_setup_msc: int + time_expiration: int + time_done: int + time_done_msc: int + type: OrderType + type_time: OrderTime + type_filling: OrderFilling + state: int + magic: int + position_id: int + position_by_id: int + reason: OrderReason + volume_current: float + volume_initial: float + price_open: float + sl: float + tp: float + price_current: float + price_stoplimit: float + symbol: str + comment: str + external_id: str + """ + ticket: int + time_setup: int + time_setup_msc: int + time_expiration: int + time_done: int + time_done_msc: int + type: OrderType + type_time: OrderTime + type_filling: OrderFilling + state: int + magic: int + position_id: int + position_by_id: int + reason: OrderReason + volume_current: float + volume_initial: float + price_open: float + sl: float + tp: float + price_current: float + price_stoplimit: float + symbol: str + comment: str + external_id: str + + +class TradeRequest(Base): + """Trade Request Class. + + Attributes: + action: TradeAction + type: OrderType + order: int + symbol: str + volume: float + sl: float + tp: float + price: float + deviation: float + stop_limit: float + type_time: OrderTime + type_filling: OrderFilling + expiration: int + position: int + position_by: int + comment: str + magic: int + deviation: int + comment: str + """ + action: TradeAction + type: OrderType + order: int + symbol: str + volume: float + sl: float + tp: float + price: float + deviation: float + stop_limit: float + type_time: OrderTime + type_filling: OrderFilling + expiration: int + position: int + position_by: int + comment: str + magic: int + deviation: int + comment: str + + +class OrderCheckResult(Base): + """Order Check Result + + Attributes: + retcode: int + balance: float + equity: float + profit: float + margin: float + margin_free: float + margin_level: float + comment: str + request: TradeRequest + """ + retcode: int + balance: float + equity: float + profit: float + margin: float + margin_free: float + margin_level: float + comment: str + request: mt5.TradeRequest + + +class OrderSendResult(Base): + """Order Send Result + + Attributes: + retcode: int + deal: int + order: int + volume: float + price: float + bid: float + ask: float + profit: float + loss: float + comment: str + request: TradeRequest + request_id: int + retcode_external: int + """ + retcode: int + deal: int + order: int + volume: float + price: float + bid: float + ask: float + comment: str + request: mt5.TradeRequest + request_id: int + retcode_external: int + profit: float = None + loss: float = None + + +class TradePosition(Base): + """Trade Position + + Attributes: + ticket: int + time: int + time_msc: int + time_update: int + time_update_msc: int + type: OrderType + magic: float + identifier: int + reason: PositionReason + volume: float + price_open: float + sl: float + tp: float + price_current: float + swap: float + profit: float + symbol: str + comment: str + external_id: str + """ + ticket: int + time: int + time_msc: int + time_update: int + time_update_msc: int + type: OrderType + magic: float + identifier: int + reason: PositionReason + volume: float + price_open: float + sl: float + tp: float + price_current: float + swap: float + profit: float + symbol: str + comment: str + external_id: str + + +class TradeDeal(Base): + """Trade Deal + + Attributes: + ticket: int + order: int + time: int + time_msc: int + type: DealType + entry: DealEntry + magic: int + position_id: int + reason: DealReason + volume: float + price: float + commission: float + swap: float + profit: float + fee: float + sl: float + tp: float + symbol: str + comment: str + external_id: str + """ + ticket: int + order: int + time: int + time_msc: int + type: DealType + entry: DealEntry + magic: int + position_id: int + reason: DealReason + volume: float + price: float + commission: float + swap: float + profit: float + fee: float + sl: float + tp: float + symbol: str + comment: str + external_id: str diff --git a/src/aiomql/core/task_queue.py b/src/aiomql/core/task_queue.py new file mode 100644 index 0000000..06043d8 --- /dev/null +++ b/src/aiomql/core/task_queue.py @@ -0,0 +1,49 @@ +import asyncio +from typing import Coroutine, Callable, Awaitable +from logging import getLogger + +logger = getLogger(__name__) + + +class QueueItem: + def __init__(self, task: Callable | Awaitable | Coroutine, *args, **kwargs): + self.task = task + self.args = args + self.kwargs = kwargs + + async def run(self): + try: + if asyncio.iscoroutinefunction(self.task): + return await self.task(*self.args, **self.kwargs) + else: + return self.task(*self.args, **self.kwargs) + except Exception as err: + logger.error(f"Error in running {getattr(self.task, '__name__', str(self.task))}" + f" with {str(self.args)}, {self.kwargs}: {err}") + + +class TaskQueue: + def __init__(self): + self.queue = asyncio.Queue() + + def add(self, item: QueueItem): + try: + self.queue.put_nowait(item) + except asyncio.QueueFull: + return + + async def worker(self): + while True: + try: + item: QueueItem = self.queue.get_nowait() + await item.run() + self.queue.task_done() + except asyncio.QueueEmpty: + return + + def add_task(self, item: Callable | Awaitable | Coroutine, *args, **kwargs): + self.add(QueueItem(item, *args, **kwargs)) + asyncio.create_task(self.worker()) + + async def start(self): + await self.queue.join() diff --git a/src/aiomql/executor.py b/src/aiomql/executor.py new file mode 100644 index 0000000..0afc60f --- /dev/null +++ b/src/aiomql/executor.py @@ -0,0 +1,90 @@ +import asyncio +from concurrent.futures import ThreadPoolExecutor +from typing import Sequence, Coroutine, Callable +from logging import getLogger + +from .strategy import Strategy + +logger = getLogger(__name__) + + +class Executor: + """Executor class for running multiple strategies on multiple symbols concurrently. + + Attributes: + executor (ThreadPoolExecutor): The executor object. + workers (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 + """ + + def __init__(self): + self.executor = ThreadPoolExecutor + self.workers: list[type(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_coroutine(self, coro: Coroutine, kwargs: dict): + self.coroutines[coro] = kwargs + + def add_workers(self, strategies: Sequence[type(Strategy)]): + """Add multiple strategies at once + + Args: + strategies (Sequence[Strategy]): A sequence of strategies. + """ + self.workers.extend(strategies) + + def remove_workers(self, *, symbols: set): + """Removes any worker running on a symbol not successfully initialized.""" + self.workers = [worker for worker in self.workers if worker.symbol in symbols] + + def add_worker(self, strategy: type(Strategy)): + """Add a strategy instance to the list of workers + + Args: + strategy (Strategy): A strategy object + """ + self.workers.append(strategy) + + @staticmethod + def trade(strategy: type(Strategy)): + """Wraps the coroutine trade method of each strategy with 'asyncio.run'. + + Args: + strategy (Strategy): A strategy object + """ + asyncio.run(strategy.trade()) + + def run(self, func, kwargs: dict): + """ + Run a coroutine function + + Args: + func: The coroutine. A variadic function. + kwargs: A dictionary of keyword arguments for the function + """ + try: + asyncio.run(func(**kwargs)) + except Exception as err: + logger.error(f'Error: {err}. Unable to run function') + + async def execute(self, workers: int = 5): + """Run the strategies with a threadpool executor. + + Args: + workers: Number of workers to use in executor pool. Defaults to 5. + + 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 = max(workers, workers_) + 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()] diff --git a/src/aiomql/history.py b/src/aiomql/history.py new file mode 100644 index 0000000..7bad2a3 --- /dev/null +++ b/src/aiomql/history.py @@ -0,0 +1,229 @@ +import asyncio +from datetime import datetime +from logging import getLogger + +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 + +logger = getLogger(__name__) + + +class History: + """The history class handles completed trade deals and trade orders in the trading history of an account. + + Attributes: + deals (list[TradeDeal]): Iterable of trade deals + orders (list[TradeOrder]): Iterable of trade orders + total_deals: Total number of deals + total_orders (int): Total number orders + group (str): Filter for selecting history by symbols. + ticket (int): Filter for selecting history by ticket number + position (int): Filter for selecting history deals by position + mt5 (MetaTrader): MetaTrader instance + config (Config): Config instance + """ + mt5: MetaTrader + config: Config + + def __init__(self, *, date_from: datetime | int = None, date_to: datetime | int = None, + group: str = "", ticket: int = None, position: int = None): + """ + Args: + date_from (datetime, float): Date the orders are requested from. Set by the 'datetime' object or as a + number of seconds elapsed since 1970.01.01. Defaults to twenty-four hours from the current time in 'utc' + + date_to (datetime, float): Date up to which the orders are requested. Set by the 'datetime' object or as a + number of seconds elapsed since 1970.01.01. Defaults to the current time in "utc" + + group (str): Filter for selecting history by symbols. + ticket (int): Filter for selecting history by ticket number + position (int): Filter for selecting history deals by position + """ + self.config = Config() + self.mt5 = MetaTrader() + self.date_from = date_from + self.date_to = date_to + self.group = group + self.ticket = ticket + self.position = position + self.deals: list[TradeDeal] = [] + self.orders: list[TradeOrder] = [] + self.total_deals: int = 0 + self.total_orders: int = 0 + + async def init(self, deals=True, orders=True): + """Get history deals and orders + + Keyword Args: + deals (bool): If true get history deals during initial request to terminal + orders (bool): If true get history orders during initial request to terminal + """ + self.deals = await self.get_deals() if deals else tuple() + self.orders = await self.get_orders() if orders else tuple() + self.total_deals = len(self.deals) + self.total_orders = len(self.orders) + + async def get_deals(self, *, date_from: datetime | int = None, date_to: datetime | int = None, group: str = '', + retries: int = 3) -> tuple[TradeDeal, ...]: + """Get deals from trading history using the parameters set in the constructor. + + Returns: + tuple[TradeDeal]: A list of trade deals + """ + if retries < 1: + logger.warning(f'Failed to get deals: {self.mt5.error}') + return tuple() + + date_from, date_to, group = date_from or self.date_from, date_to or self.date_to, group or self.group + deals = await self.mt5.history_deals_get(date_from=date_from, date_to=date_to, group=group) + + if deals is not None: + return tuple(TradeDeal(**deal._asdict()) for deal in deals) + + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.get_deals(date_from=date_from, date_to=date_to, group=group, retries=retries-1) + + logger.warning(f'Failed to get deals: {self.mt5.error}') + return tuple() + + async def get_deals_ticket(self, *, ticket: int = None) -> tuple[TradeDeal, ...]: + """Call specifying the order ticket. Return all deals having the specified order ticket in the DEAL_ORDER + property. + + Args: + ticket (int): The order ticket + + Returns: + tuple[TradeDeal]: A tuple of all deals with the order ticket + """ + ticket = ticket or self.ticket + assert ticket is not None, 'ticket not provided' + deals = await self.mt5.history_deals_get(ticket=ticket) + return tuple(sorted([TradeDeal(**deal._asdict()) for deal in deals or []], key=lambda x: x.time_msc)) + + async def get_deals_position(self, *, position: int = None) -> tuple[TradeDeal, ...]: + """ + Get all deals with the specified position ticket in the DEAL_POSITION_ID property + Args: + position (int): The position ticket + + Returns: + tuple[TradeDeal]: A tuple of all deals with the position ticket + """ + position = position or self.position + assert position is not None, 'position not provided' + deals = await self.mt5.history_deals_get(position=position) + return tuple(sorted([TradeDeal(**deal._asdict()) for deal in deals or []], key=lambda x: x.time_msc)) + + async def deals_total(self, *, date_from: int | datetime = None, date_to: int | datetime = None) -> int: + """Get total number of deals within the specified period in the constructor. + Args: + date_from (int|datetime): Date the orders are requested from. Set by the 'datetime' object or as a number of + seconds elapsed since 1970.01.01. + date_to (int|datetime): Date up to which the orders are requested. Set by the 'datetime' object or as a + number of seconds elapsed since 1970.01.01. + Returns: + int: Total number of Deals + """ + date_from, date_to = date_from or self.date_from, date_to or self.date_to + assert date_from is not None and date_to is not None, 'date_from and/or date_to not provided' + total_deals = await self.mt5.history_deals_total(date_from, date_to) + return total_deals + + async def get_orders(self, *, date_from: datetime | int = None, date_to: datetime | int = None, group: str = '', + retries: int = 3) -> tuple[TradeOrder, ...]: + """Get orders from trading history using the parameters set in the constructor or the method arguments. + + Returns: + list[TradeOrder]: A list of trade orders + """ + if retries < 1: + logger.warning(f'Failed to get orders: {self.mt5.error}') + return tuple() + + date_from, date_to, group = date_from or self.date_from, date_to or self.date_to, group or self.group + orders = await self.mt5.history_orders_get(date_from=date_from, date_to=date_to, group=group) + if orders is not None: + return tuple(TradeOrder(**order._asdict()) for order in orders) + + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.get_orders(date_from=date_from, date_to=date_to, group=group, retries=retries - 1) + + logger.warning(f'Failed to get orders: {self.mt5.error}') + return tuple() + + async def get_order_ticket(self, ticket: int | None = None) -> TradeOrder | None: + ticket = ticket or self.ticket + assert isinstance(ticket, int), 'ticket not provided' + orders = await self.mt5.history_orders_get(ticket=ticket) + if orders and (order := orders[0]).ticket == ticket: + return TradeOrder(**order._asdict()) + return None + + async def get_orders_position(self, position: int = None) -> tuple[TradeOrder, ...]: + """ + Call specifying the position ticket. Return all orders with a position ticket specified in the + ORDER_POSITION_ID property + + Args: + position: The position ticket + + Returns: + tuple[TradeOrder]: A tuple of all orders with the position ticket + """ + position = position or self.position + assert isinstance(position, int), 'position not provided' + orders = await self.mt5.history_orders_get(position=position) + return tuple(sorted([TradeOrder(**order._asdict()) for order in orders or []], key=lambda x: x.time_done_msc)) + + async def orders_total(self, date_from: int | datetime = None, date_to: int | datetime = None) -> int: + """Get total number of orders within the specified period in the constructor. + + Returns: + int: Total number of orders + """ + date_from, date_to = date_from or self.date_from, date_to or self.date_to + assert date_from is not None and date_to is not None, 'date_from and/or date_to not provided' + total_orders = await self.mt5.history_orders_total(date_from, date_to) + return total_orders + + async def track_order(self, *, position: int = None, end_time: datetime = None) -> DataFrame: + """ + Track an order from the time it was opened to the time it was closed or any given time. + The tracking is done by getting the ticks + for the order symbol from the time the order was opened to the time it was closed. The profit for each tick is + calculated using the order type, symbol, initial volume, open price and the bid or ask price of the tick + depending on the order type. + Args: + end_time (datetime): The time to stop tracking the order. If not provided, the tracking will continue until + the order is closed. + position (int): The position ticket + end_time (int): The time to stop tracking the order in seconds. If not provided, the tracking will continue + until the order is closed. + Returns: + DataFrame: A pandas DataFrame of the ticks and profit for the order. + """ + orders = await self.get_orders_position(position=position) + deals = await self.get_deals_position(position=position) + open_order = orders[0] + open_deal = deals[0] + close_deal = deals[-1] + time_done = datetime.timestamp(end_time) if end_time is not None else close_deal.time + time_done_msc = int(time_done * 1000) + open_order.set_attributes(time_done_msc=time_done_msc, time_done=time_done, price_open=open_deal.price) + ticks = await self.mt5.copy_ticks_range(open_order.symbol, open_order.time_setup, open_order.time_done, + CopyTicks.ALL) + data = pd.DataFrame(ticks) + profit = lambda x: self.mt5._order_calc_profit(open_order.type, open_order.symbol, open_order.volume_initial, + open_order.price_open, + x.ask if open_order.type == OrderType.BUY else x.bid) + data['profits'] = data.apply(profit, axis=1) + data['time'] = pd.to_datetime(data['time'], unit='s') + data.set_index('time', inplace=True) + return data diff --git a/src/aiomql/lib/__init__.py b/src/aiomql/lib/__init__.py new file mode 100644 index 0000000..130b456 --- /dev/null +++ b/src/aiomql/lib/__init__.py @@ -0,0 +1,3 @@ +from .strategies import * +from .traders import * +from .symbols import * diff --git a/src/aiomql/lib/strategies/__init__.py b/src/aiomql/lib/strategies/__init__.py new file mode 100644 index 0000000..625296d --- /dev/null +++ b/src/aiomql/lib/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/lib/strategies/finger_trap.py new file mode 100644 index 0000000..0445628 --- /dev/null +++ b/src/aiomql/lib/strategies/finger_trap.py @@ -0,0 +1,111 @@ +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 ...utils import find_bearish_fractal, find_bullish_fractal + +logger = logging.getLogger(__name__) + + +class FingerTrap(Strategy): + ttf: TimeFrame + etf: TimeFrame + fast_ema: int + slow_ema: int + entry_ema: int + parameters: dict + ecc: int + tcc: int + trader: Trader + tracker: Tracker + parameters = {"fast_ema": 8, "slow_ema": 20, "etf": TimeFrame.M5, + "ttf": TimeFrame.H1, "entry_ema": 5, "tcc": 672, "ecc": 3360} + + def __init__(self, *, symbol: Symbol, params: dict | None = None, trader: Trader = None, sessions: Sessions = None, + name: str = 'FingerTrap'): + super().__init__(symbol=symbol, params=params, sessions=sessions, name=name) + self.trader = trader or SimpleTrader(symbol=self.symbol) + self.tracker: Tracker = Tracker(snooze=self.ttf.time) + + async def check_trend(self): + try: + candles: Candles = await self.symbol.copy_rates_from_pos(timeframe=self.ttf, count=self.tcc) + if not ((current := candles[-1].time) >= self.tracker.trend_time): + self.tracker.update(new=False, order_type=None) + return + self.tracker.update(new=True, trend_time=current) + candles.ta.ema(length=self.slow_ema, append=True, fillna=0) + candles.ta.ema(length=self.fast_ema, append=True, fillna=0) + candles.rename(inplace=True, **{f"EMA_{self.fast_ema}": "fast", f"EMA_{self.slow_ema}": "slow"}) + + fas = candles.ta_lib.above(candles.fast, candles.slow) + fbs = candles.ta_lib.below(candles.fast, candles.slow) + caf = candles.ta_lib.above(candles.close, candles.fast) + cbf = candles.ta_lib.below(candles.close, candles.fast) + current = candles[-2] + if fas.iloc[-1] and caf.iloc[-1] and current.is_bullish(): + self.tracker.update(trend="bullish") + elif fbs.iloc[-1] and cbf.iloc[-1] and current.is_bearish(): + self.tracker.update(trend="bearish") + else: + self.tracker.update(trend="ranging", snooze=self.ttf.time, order_type=None) + except Exception as err: + logger.error(f"{err} for {self.symbol} in {self.__class__.__name__}.check_trend") + self.tracker.update(snooze=self.ttf.time, order_type=None) + + async def confirm_trend(self): + try: + candles = await self.symbol.copy_rates_from_pos(timeframe=self.etf, count=self.ecc) + if not ((current := candles[-1].time) >= self.tracker.entry_time): + self.tracker.update(new=False, order_type=None) + return + self.tracker.update(new=True, entry_time=current) + candles.ta.ema(length=self.entry_ema, append=True) + candles.rename(**{f"EMA_{self.entry_ema}": "ema"}) + candles['cae'] = candles.ta_lib.cross(candles.close, candles.ema) + candles['cbe'] = candles.ta_lib.cross(candles.close, candles.ema, above=False) + current = candles[-1] + if self.tracker.bullish and current.cae: + sl = find_bullish_fractal(candles).low + self.tracker.update(snooze=self.ttf.time, order_type=OrderType.BUY, sl=sl) + elif self.tracker.bearish and current.cbe: + sl = find_bearish_fractal(candles).high + self.tracker.update(snooze=self.ttf.time, order_type=OrderType.SELL, sl=sl) + else: + self.tracker.update(snooze=self.etf.time, order_type=None) + except Exception as err: + logger.error(f"{err} for {self.symbol} in {self.__class__.__name__}.confirm_trend") + self.tracker.update(snooze=self.etf.time, order_type=None) + + async def watch_market(self): + await self.check_trend() + if not self.tracker.ranging: + await self.confirm_trend() + + async def trade(self): + logger.info(f"Trading {self.symbol}") + async with self.sessions as sess: + await self.sleep(self.ttf.time) + while True: + await sess.check() + try: + await self.watch_market() + if not self.tracker.new: + await asyncio.sleep(2) + continue + if self.tracker.order_type is None: + await self.sleep(self.tracker.snooze) + continue + 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: + logger.error(f"{err} For {self.symbol} in {self.__class__.__name__}.trade") + await self.sleep(self.ttf.time) diff --git a/src/aiomql/lib/strategies/tracker.py b/src/aiomql/lib/strategies/tracker.py new file mode 100644 index 0000000..5531a66 --- /dev/null +++ b/src/aiomql/lib/strategies/tracker.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from typing import Literal + +from ...core.constants import OrderType + + +@dataclass +class Tracker: + """Keeps track of a strategy's data and state""" + trend: Literal["ranging", "bullish", "bearish"] = "ranging" + bullish: bool = False + bearish: bool = False + ranging: bool = True + snooze: float = 0 + trend_time: float = 0 + entry_time: float = 0 + new: bool = True + order_type: OrderType = None + sl: float = 0 + tp: float = 0 + + def update(self, **kwargs): + fields = self.__dict__ + for key in kwargs: + if key in fields: + setattr(self, key, kwargs[key]) + if 'trend' in kwargs: + match self.trend: + case "ranging": + self.ranging = True + self.bullish = self.bearish = False + case "bullish": + self.ranging = self.bearish = False + self.bullish = True + case "bearish": + self.ranging = self.bullish = False + self.bearish = True \ No newline at end of file diff --git a/src/aiomql/lib/symbols/__init__.py b/src/aiomql/lib/symbols/__init__.py new file mode 100644 index 0000000..423959b --- /dev/null +++ b/src/aiomql/lib/symbols/__init__.py @@ -0,0 +1 @@ +from .forex_symbol import ForexSymbol diff --git a/src/aiomql/lib/symbols/forex_symbol.py b/src/aiomql/lib/symbols/forex_symbol.py new file mode 100644 index 0000000..cfb0fcf --- /dev/null +++ b/src/aiomql/lib/symbols/forex_symbol.py @@ -0,0 +1,83 @@ +from ...symbol import Symbol +from ...core.exceptions import VolumeError + + +class ForexSymbol(Symbol): + """Subclass of Symbol for Forex Symbols. Handles the conversion of currency and the computation of stop loss, + take profit and volume. + """ + 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: + amount (float): Amount to trade + volume (float): Volume to trade + """ + points = amount / (volume * self.point * self.trade_contract_size) + return points + + async def compute_volume_points(self, *, amount: float, points: float, use_limits=False, round_down: bool = False, + adjust: float = False) -> tuple[float, float]: + """Compute the volume and points required for a trade. Given the amount and the number of points. + + Args: + amount (float): Amount to trade + points (float): Number of points + round_down: round down the computed volume to the nearest step default True + adjust: Adjust the points if the computed volume is outside the range of permitted volumes + use_limits: Adjust the computed volume to the nearest permitted volume if the computed volume is outside + """ + amount = await self.check_amount(amount) + volume = amount / (self.point * points * self.trade_contract_size) + volume = self.round_off_volume(volume, round_down=round_down) + if (chk_vol := self.check_volume(volume))[0]: + if adjust: + points = self.compute_points(amount=amount, volume=volume) + return volume, points + if use_limits: + vol = chk_vol[1] + if adjust: + points = self.compute_points(amount=amount, volume=vol) + return vol, points + raise VolumeError(f"Incorrect Volume. Computed Volume outside the range of permitted volumes") + + async def compute_volume_sl(self, *, amount: float, price: float, sl: float, use_limits=False, adjust: bool = False, + round_down: bool = False) -> tuple[float, float]: + amount = await self.check_amount(amount) + volume = amount / ((price - sl) * self.trade_contract_size) + volume = self.round_off_volume(volume, round_down=round_down) + sign = volume / abs(volume) if volume else 1 + if (chk_vol := self.check_volume(abs(volume)))[0]: + if adjust: + sl = price - (amount / (volume * self.trade_contract_size)) + return abs(volume), sl + return abs(volume), sl + if use_limits: + vol = chk_vol[1] * sign + if adjust: + sl = price - (amount / (vol * self.trade_contract_size)) + return abs(vol), sl + raise VolumeError(f"Incorrect Volume. Computed Volume outside the range of permitted volumes") + + async def compute_volume(self, *, amount: float, points, use_limits=False, round_down=True) -> float: + """Compute volume given an amount to risk and target points. Round the computed volume to the nearest step. + + Args: + amount (float): Amount to risk. Given in terms of the account currency. + points (float): Target points. + use_limits (bool): If True, the computed volume checked against the maximum and minimum volume. + round_down: round down the computed volume to the nearest step default True + + Returns: + float: volume + + Raises: + VolumeError: If the computed volume is less than the minimum volume or greater than the maximum volume. + """ + amount = await self.check_amount(amount) + volume = amount / (self.point * points * self.trade_contract_size) + volume = self.round_off_volume(volume, round_down=round_down) + if self.check_volume(volume)[0]: + return volume + if use_limits: + return self.check_volume(volume)[1] + raise VolumeError(f"Incorrect Volume. Computed Volume outside the range of permitted volumes") diff --git a/src/aiomql/lib/traders/__init__.py b/src/aiomql/lib/traders/__init__.py new file mode 100644 index 0000000..07af2a2 --- /dev/null +++ b/src/aiomql/lib/traders/__init__.py @@ -0,0 +1 @@ +from .simple_trader import SimpleTrader diff --git a/src/aiomql/lib/traders/simple_trader.py b/src/aiomql/lib/traders/simple_trader.py new file mode 100644 index 0000000..860f95e --- /dev/null +++ b/src/aiomql/lib/traders/simple_trader.py @@ -0,0 +1,47 @@ +from logging import getLogger + +from ..symbols import ForexSymbol +from ...ram import RAM +from ...core.models import OrderType +from ...trader import Trader + +logger = getLogger(__name__) + + +class SimpleTrader(Trader): + """A simple trader class""" + def __init__(self, *, symbol: ForexSymbol, ram: RAM = None): + """Initializes the order object and RAM instance + + Args: + symbol (Symbol): Financial instrument + ram (RAM): Risk Assessment and Management instance + """ + ram = ram or RAM(risk_to_reward=2) + super().__init__(symbol=symbol, ram=ram) + + async def create_order(self, *, order_type: OrderType, sl: float): + amount = await self.ram.get_amount() + await self.symbol.info() + tick = await self.symbol.info_tick() + min_points = self.symbol.trade_stops_level + (self.symbol.spread * 1.5) + points = (tick.ask - sl) / self.symbol.point if order_type == OrderType.BUY else\ + (abs(tick.bid - sl) / self.symbol.point) + points = max(points, min_points) + self.order.type = order_type + volume, points = await self.symbol.compute_volume_points(amount=amount, points=points) + self.order.volume = volume + self.order.comment = self.parameters.get('name', self.__class__.__name__) + tick = await self.symbol.info_tick() + self.set_trade_stop_levels(points=points, tick=tick) + + async def place_trade(self, order_type: OrderType, sl: float, parameters: dict = None): + """Places a trade based on the order_type.""" + try: + self.parameters |= parameters or {} + await self.create_order(order_type=order_type, sl=sl) + if not await self.check_order(): + return + await self.send_order() + except Exception as err: + logger.error(f"{err} in {self.__class__.__name__}.place_trade for {self.symbol.name}") diff --git a/src/aiomql/order.py b/src/aiomql/order.py new file mode 100644 index 0000000..395e806 --- /dev/null +++ b/src/aiomql/order.py @@ -0,0 +1,146 @@ +import asyncio +from logging import getLogger + +from .core.models import TradeRequest, OrderSendResult, OrderCheckResult, TradeOrder +from .core.constants import TradeAction, OrderTime, OrderFilling +from .core.exceptions import OrderError +logger = getLogger(__name__) + + +class Order(TradeRequest): + """Trade order related functions and properties. Subclass of TradeRequest.""" + + def __init__(self, **kwargs): + """Initialize the order object with keyword arguments, symbol must be provided. + Provide default values for action, type_time and type_filling if not provided. + + Args: + **kwargs: Keyword arguments must match the attributes of TradeRequest as well as the attributes of + Order class as specified in the annotations in the class definition. + + Default Values: + action (TradeAction.DEAL): Trade action + type_time (OrderTime.DAY): Order time + type_filling (OrderFilling.FOK): Order filling + """ + if 'symbol' in kwargs: + kwargs['symbol'] = str(kwargs['symbol']) + self.action = kwargs.pop('action', TradeAction.DEAL) + self.type_time = kwargs.pop('type_time', OrderTime.DAY) + self.type_filling = kwargs.pop('type_filling', OrderFilling.FOK) + super().__init__(**kwargs) + + async def orders_total(self): + """Get the number of active orders. + + Returns: + (int): total number of active orders + """ + return await self.mt5.orders_total() + + async def get_order(self, *, ticket: int, retries: int = 3) -> TradeOrder | None: + """ + Get the order by ticket number. + Args: + ticket (int): Order ticket number + retries (int): Number of retries + Returns: + """ + if retries < 1: + return None + orders = await self.mt5.orders_get(ticket=ticket) + if orders and (order := orders[0]).ticket == ticket: + return TradeOrder(**order._asdict()) + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.get_order(ticket=ticket, retries=retries-1) + return None + + async def get_orders(self, *, ticket: int = 0, symbol: str = '', group: str = '', retries=3)\ + -> tuple[TradeOrder, ...]: + """Get the list of active orders for the current symbol. + Keyword Args: + ticket (int): Order ticket number + symbol (str): Symbol name + group (str): Group name + Returns: + tuple[TradeOrder]: A Tuple of active trade orders as TradeOrder objects + """ + if retries < 1: + return tuple() + symbol = getattr(self, 'symbol', symbol) + orders = await self.mt5.orders_get(symbol=symbol, ticket=ticket, group=group) + if orders is not None: + orders = (TradeOrder(**order._asdict()) for order in orders) + return tuple(orders) + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.get_orders(ticket=ticket, symbol=symbol, group=group, retries=retries-1) + return tuple() + + async def check(self, **kwargs) -> OrderCheckResult: + """Check funds sufficiency for performing a required trading operation and the possibility of executing it. + + Returns: + OrderCheckResult: An OrderCheckResult object + + Raises: + OrderError: If not successful + """ + req = self.dict | kwargs + res = await self.mt5.order_check(req) + if res is None: + raise OrderError(f'Failed to check order due to {self.mt5.error.description}') + return OrderCheckResult(**res._asdict()) + + async def send(self) -> OrderSendResult: + """Send a request to perform a trading operation from the terminal to the trade server. + + Returns: + OrderSendResult: An OrderSendResult object + + Raises: + OrderError: If not successful + """ + res = await self.mt5.order_send(self.dict) + if res is None: + raise OrderError(f'Failed to send order {self.symbol} due to {self.mt5.error.description}') + res = OrderSendResult(**res._asdict()) + try: + profit = await self.calc_profit() + loss = await self.calc_profit(tp=self.sl) + res.loss = loss + res.profit = profit + except Exception as exe: + logger.error(f'Failed to calculate profit and loss for this order due to {exe}') + return res + + async def calc_margin(self) -> float: + """Return the required margin in the account currency to perform a specified trading operation. + + Returns: + float: Returns float value if successful + + Raises: + OrderError: If not successful + """ + res = await self.mt5.order_calc_margin(self.type, self.symbol, self.volume, self.price) + if res is None: + raise OrderError(f'Failed to calculate margin for {self.symbol} due to {self.mt5.error.description}') + return res + + async def calc_profit(self, **kwargs) -> float: + """Return profit in the account currency for a specified trading operation. + + Returns: + float: Returns float value if successful + + Raises: + OrderError: If not successful + """ + args = self.get_dict(include={'tp', 'price', 'symbol', 'volume', 'type'}) + args |= kwargs + res = await self.mt5.order_calc_profit(args['type'], args['symbol'], args['volume'], args['price'], args['tp']) + if res is None: + raise OrderError(f'Failed to calculate profit for {self.symbol} due to {self.mt5.error.description}') + return res diff --git a/src/aiomql/positions.py b/src/aiomql/positions.py new file mode 100644 index 0000000..09060d0 --- /dev/null +++ b/src/aiomql/positions.py @@ -0,0 +1,126 @@ +"""Handle Open positions.""" +import asyncio +from logging import getLogger + +from .core import MetaTrader, TradePosition, TradeAction, OrderType +from .order import Order + +logger = getLogger(__name__) + + +class Positions: + """Get Open Positions. + + Attributes: + symbol (str): Financial instrument name. + group (str): The filter for arranging a group of necessary symbols. Optional named parameter. + If the group is specified, the function returns only positions meeting a specified criteria for a symbol. + ticket (int): Position ticket. + mt5 (MetaTrader): MetaTrader instance. + """ + mt5: MetaTrader + + def __init__(self, *, symbol: str = "", group: str = "", ticket: int = 0): + """Get Open Positions. + + Keyword Args: + symbol (str): Financial instrument name. + group (str): The filter for arranging a group of necessary symbols. Optional named parameter. If the group + is specified, the function returns only positions meeting a specified criteria for a symbol name. + ticket (int): Position ticket + + """ + self.mt5 = MetaTrader() + self.symbol = symbol + self.group = group + self.ticket = ticket + + async def positions_total(self) -> int: + """Get the number of open positions. + + Returns: + int: Return total number of open positions + """ + return await self.mt5.positions_total() + + async def positions_get(self, symbol: str = '', group: str = '', ticket: int = 0, retries=3) -> list[TradePosition]: + """Get open positions with the ability to filter by symbol or ticket. + + Keyword Args: + symbol (str): Financial instrument name. + group (str): The filter for arranging a group of necessary symbols. Optional named parameter. If the group + is specified, the function returns only positions meeting a specified criteria for a symbol name. + ticket (int): Position ticket + + Returns: + list[TradePosition]: A list of open trade positions + """ + if retries < 1: + logger.warning(f'Failed to get positions for {symbol or self.symbol}. {self.mt5.error}') + return [] + positions = await self.mt5.positions_get(group=group or self.group, symbol=symbol or self.symbol, + ticket=ticket or self.ticket) + if positions is not None: + return [TradePosition(**pos._asdict()) for pos in positions] + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.positions_get(symbol, group, ticket, retries - 1) + logger.warning(f'Failed to get positions for {symbol or self.symbol}. {self.mt5.error}') + return [] + + async def position_get(self, *, ticket: int) -> TradePosition | None: + """Get an open position by ticket. + Args: + ticket (int): Position ticket. + + Returns: + TradePosition: Return an open position + """ + positions = await self.positions_get(ticket=ticket) + position = positions[0] if positions else None + if position is None or position.ticket != ticket: + logger.warning(f'Failed to get position for ticket {ticket}. {self.mt5.error}') + return None + return position + + async def close(self, *, ticket: int, symbol: str, price: float, volume: float, order_type: OrderType): + """Close an open position for the trading account using the ticket and other parameters. + Args: + ticket (int): Position ticket. + symbol (str): Financial instrument name. + price (float): Closing price. + volume (float): Volume to close. + order_type (OrderType): Order type. + """ + order = Order(action=TradeAction.DEAL, price=price, position=ticket, symbol=symbol, volume=volume, + type=order_type.opposite) + return await order.send() + + async def close_by(self, pos: TradePosition): + """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) + return await order.send() + + async def close_position(self, *, 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) + return await order.send() + + async def close_all(self, symbol: str = '', group: str = '') -> int: + """Close all open positions for the trading account. Specify a symbol or group to filter positions. + + Keyword Args: + symbol (str): Financial instrument name. + group (str): The filter for specifying a group of symbols. + + Returns: + int: Return number of positions closed. + """ + symbol = symbol or self.symbol + group = group or self.group + positions = [pos for pos in await self.positions_get(symbol=symbol, group=group)] + orders = [self.close_position(position=pos) for pos in positions] + results = await asyncio.gather(*[order for order in orders], return_exceptions=True) + return len([res for res in results if (res and res.retcode) == 10009]) diff --git a/src/aiomql/ram.py b/src/aiomql/ram.py new file mode 100644 index 0000000..354a496 --- /dev/null +++ b/src/aiomql/ram.py @@ -0,0 +1,66 @@ +"""Risk Assessment and Management""" +from .account import Account +from .positions import Positions + + +class RAM: + account: Account + risk_to_reward: float + risk: float + points: float + pips: float + min_amount: float = 0 + max_amount: float = 0 + risk_level: float = 50 + loss_limit: int = 3 + open_limit: int = 6 + + def __init__(self, *, risk_to_reward: float = 1, risk: float = 0.01, **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% + 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()] + + async def get_amount(self) -> float: + """Calculate the amount to risk per trade as a percentage of equity. + + Returns: + float: Amount to risk per trade + """ + await self.account.refresh() + amount = self.account.margin_free * self.risk + if self.min_amount and self.max_amount: + return max(self.min_amount, min(self.max_amount, amount)) + return amount + + async def check_losing_positions(self, *, symbol: str = '') -> bool: + """Check if the number of losing positions is greater than or equal the loss limit. + + Args: + symbol (str): Symbol to check. Defaults to ''. + """ + positions = await Positions().positions_get(symbol=symbol) + loosing = [trade for trade in positions if trade.profit <= 0] + return len(loosing) >= self.loss_limit + + async def check_open_positions(self, *, symbol: str = '') -> bool: + """Check if the number of open positions is greater than or equal the loss limit. + + Args: + symbol (str): Symbol to check. Defaults to ''. + """ + positions = await Positions().positions_get(symbol=symbol) + return len(positions) >= self.open_limit + + async def check_risk_level(self) -> bool: + """Check the risk level.""" + await self.account.refresh() + risk_level = (1 - (self.account.margin_free / self.account.equity)) * 100 + return risk_level >= self.risk_level diff --git a/src/aiomql/records.py b/src/aiomql/records.py new file mode 100644 index 0000000..c9ec5a1 --- /dev/null +++ b/src/aiomql/records.py @@ -0,0 +1,115 @@ +"""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 .core import Config, 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() + 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/result.py b/src/aiomql/result.py new file mode 100644 index 0000000..148ab5b --- /dev/null +++ b/src/aiomql/result.py @@ -0,0 +1,94 @@ +import csv +import json +from logging import getLogger +from typing import Iterable, Literal + +from .core import Config +from .core.models import OrderSendResult + +logger = getLogger(__name__) + + +class Result: + """A base class for handling trade results and strategy parameters for record keeping and reference purpose. + The data property must be implemented in the subclass + + Attributes: + config (Config): The configuration object + name: Any desired name for the result file object + """ + config: Config + + def __init__(self, result: OrderSendResult, parameters: dict = None, name: str = ''): + """ + Prepare result data + Args: + result: + parameters: + name: + """ + self.config = Config() + self.parameters = parameters or {} + self.result = result + self.name = name or parameters.get('name', 'Trades') + + def get_data(self) -> dict: + res = self.result.get_dict(exclude={'retcode', 'comment', 'retcode_external', 'request_id', 'request'}) + return self.parameters | res | {'actual_profit': 0, 'closed': False, 'win': False} + + async def save(self, *, trade_record_mode: Literal['csv', 'json'] = None): + """Record trade results as a csv or json file + Args: + trade_record_mode (Literal['csv'|'json']): Mode of saving trade records + """ + trade_record_mode = trade_record_mode or self.config.trade_record_mode + if trade_record_mode == 'csv': + await self.to_csv() + else: + await self.to_json() + + async def to_csv(self): + """Record trade results and associated parameters as a csv file + """ + try: + data = self.get_data() + file = self.config.records_dir / f"{self.name}.csv" + file.touch(exist_ok=True) if not file.exists() else ... + reader: Iterable[dict] = csv.DictReader(file.open('r', newline='')) + rows: list[dict] = [] + headers = set() + [(rows.append(row), headers.update(row.keys())) for row in reader] + rows.append(data) + headers.update(data.keys()) + writer = csv.DictWriter(file.open('w', newline=''), fieldnames=headers, restval=None, + extrasaction='ignore') + writer.writeheader() + writer.writerows(rows) + except Exception as err: + logger.error(f'Unable to save to csv: {err}') + + @staticmethod + def serialize(value) -> str: + """Serialize the trade records and strategy parameters + """ + try: + return str(value) + except (ValueError, TypeError) as _: + return "" + + async def to_json(self): + """Save trades and strategy parameters in a json file + """ + try: + file = self.config.records_dir / f"{self.name}.json" + data = self.get_data() + exists = file.touch(exist_ok=True) if not file.exists() else True + if not exists: + json.dump([], file.open('w')) + with file.open('r') as fh: + rows = json.load(fh) + rows.append(data) + with file.open('w') as fh: + json.dump(rows, fh, indent=2, skipkeys=True, default=self.serialize) + except Exception as err: + logger.error(f"Unable to save as json file: {err}") diff --git a/src/aiomql/sessions.py b/src/aiomql/sessions.py new file mode 100644 index 0000000..77e5c9a --- /dev/null +++ b/src/aiomql/sessions.py @@ -0,0 +1,207 @@ +import asyncio +from datetime import time, timedelta, datetime +from asyncio import sleep, iscoroutinefunction +from typing import Literal, Callable +from logging import getLogger + +from .positions import Positions + +logger = getLogger(__name__) + + +def delta(obj: time) -> timedelta: + """Get the timedelta of a datetime.time object. + + Args: + obj (datetime.time): A datetime.time object. + """ + return timedelta(hours=obj.hour, minutes=obj.minute, seconds=obj.second, microseconds=obj.microsecond) + + +class Session: + """A session is a time period between two datetime.time objects specified in utc. + + Attributes: + start (datetime.time): The start time of the session. + end (datetime.time): The end time of the session. + on_start (str): The action to take when the session starts. Default is None. + on_end (str): The action to take when the session ends. Default is None. + custom_start (Callable): A custom function to call when the session starts. Default is None. + custom_end (Callable): A custom function to call when the session ends. Default is None. + name (str): A name for the session. Default is a combination of start and en + """ + def __init__(self, *, start: int | time, end: int | time, + on_start: Literal['close_all', 'close_win', 'close_loss', 'custom_start'] = None, + on_end: Literal['close_all', 'close_win', 'close_loss', 'custom_end'] = None, + custom_start: Callable = None, custom_end: Callable = None, name: str = ''): + """Create a session. + + Keyword Args: + start (int | datetime.time): The start time of the session in UTC. + end (int | datetime.time): The end time of the session in UTC. + on_start (Literal['close_all', 'close_win', 'close_loss', 'custom_start']): The action to take when the + session starts. Default is None. + on_end (Literal['close_all', 'close_win', 'close_loss', 'custom_end']): The action to take when the session + ends. Default is None. + custom_start (Callable): A custom function to call when the session starts. Default is None. + custom_end (Callable): A custom function to call when the session ends. Default is None. + name (str): A name for the session. Default is a combination of start and end. + """ + self.start = start if isinstance(start, time) else time(hour=start) + self.end = end if isinstance(end, time) else time(hour=end) + self.on_start = on_start + self.on_end = on_end + self.custom_start = custom_start + self.custom_end = custom_end + self.name = name or f'{self.start} - {self.end}' + + def __contains__(self, item: time): + if self.start > self.end: + m1 = time(hour=23, minute=59, second=59, microsecond=9999) + m2 = time(hour=0) + return self.start <= item <= m1 or m2 <= item < self.end + return self.start <= item < self.end + + def __str__(self): + return f'{self.start}-->{self.name}-->{self.end}' if self.name else f'{self.start}-->{self.end}' + + def __repr__(self): + return f'{self.start}-->{self.end}' + + def __len__(self): + return (delta(self.start) - delta(self.end)).seconds + + async def begin(self): + """Call the action specified in on_start or custom_start.""" + await self.action(self.on_start) + + async def close(self): + """Call the action specified in on_end or custom_end.""" + await self.action(self.on_end) + + async def action(self, action): + """Used by begin and close to call the action specified. + + Args: + action (Literal['close_all', 'close_win', 'close_loss', 'custom_start', 'custom_end']): The action to take. + """ + try: + position = Positions() + positions = await position.positions_get() + + match action: + case 'close_all': + await asyncio.gather(*(position.close(price=pos.price_current, ticket=pos.ticket, + order_type=pos.type, volume=pos.volume, + symbol=pos.symbol) for pos in positions), + return_exceptions=True) + + case 'close_win': + await asyncio.gather( + *(position.close(price=pos.price_current, ticket=pos.ticket, order_type=pos.type, + volume=pos.volume, symbol=pos.symbol) for pos in positions if pos.profit > 0), + return_exceptions=True) + + case 'close_loss': + await asyncio.gather( + *(position.close(price=pos.price_current, ticket=pos.ticket, order_type=pos.type, + volume=pos.volume, symbol=pos.symbol) for pos in positions if + pos.profit < 0), return_exceptions=True) + + case 'custom_end': + if iscoroutinefunction(self.custom_end): + await self.custom_end() + self.custom_end() + + case 'custom_start': + if iscoroutinefunction(self.custom_start): + await self.custom_start() + self.custom_start() + + case _: + pass + except Exception as exe: + logger.warning(f'Failed to call action {action} due to {exe}') + + def until(self): + """Get the seconds until the session starts from the current time in seconds.""" + return (delta(self.start) - delta(datetime.utcnow().time())).seconds + + +class Sessions: + """Sessions allow you to run code at specific times of the day. It is a collection of Session objects. + Sessions are sorted by start time. The sessions object is an asynchronous context manager. + + Attributes: + sessions (list[Session]): A list of Session objects. + current_session (Session): The current session. + + Methods: + find: Find a session that contains a datetime.time object. + find_next: Find the next session that contains a datetime.time object. + check: Check if the current session has started and if not, wait until it starts. + """ + def __init__(self, *sessions: Session): + self.sessions = list(sessions) + self.sessions.sort(key=lambda x: (x.start, x.end)) + self.current_session = None + + def find(self, obj: time) -> Session | None: + """Find a session that contains a datetime.time object. + + Args: + obj (datetime.time): A datetime.time object. + + Returns: + Session | None: A Session object or None if not found. + """ + for session in self.sessions: + if obj in session: + return session + return None + + def find_next(self, obj: time) -> Session: + """Find the next session that contains a datetime.time object. + + Args: + obj (datetime.time): A datetime.time object. + + Returns: + Session: A Session object. + """ + for session in self.sessions: + if obj < session.start: + return session + return self.sessions[0] + + def __contains__(self, item: time): + return True if self.find(item) 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() + + async def check(self): + """Check if the current session has started and if not, wait until it starts.""" + now = datetime.utcnow().time() + current_session = self.find(now) + if current_session: + if self.current_session: + if self.current_session == current_session: + return + await self.current_session.close() + + self.current_session = current_session + await self.current_session.begin() + return + + await self.current_session.close() if self.current_session else ... + current_session = self.find_next(now) + secs = current_session.until() + 10 + logger.info(f'sleeping for {secs} seconds until next {current_session} session') + await sleep(secs) + self.current_session = current_session + await self.current_session.begin() diff --git a/src/aiomql/strategy.py b/src/aiomql/strategy.py new file mode 100644 index 0000000..29da098 --- /dev/null +++ b/src/aiomql/strategy.py @@ -0,0 +1,82 @@ +"""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 .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(Session(start=0, end=dtime(hour=23, minute=59, second=59))) + self.config = Config() + self.mt5 = MetaTrader() + + 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. + """ \ No newline at end of file diff --git a/src/aiomql/symbol.py b/src/aiomql/symbol.py new file mode 100644 index 0000000..e1fff22 --- /dev/null +++ b/src/aiomql/symbol.py @@ -0,0 +1,399 @@ +"""Symbol class for handling a financial instrument.""" +import asyncio +from datetime import datetime +from logging import getLogger + +from .core.constants import TimeFrame, CopyTicks +from .core.models import SymbolInfo, BookInfo +from .ticks import Tick +from .account import Account +from .candle import Candles +from .ticks import Ticks +from .utils import round_off + + +logger = getLogger(__name__) + + +class Symbol(SymbolInfo): + """Main class for handling a financial instrument. A subclass of SymbolInfo it has attributes and methods + for working with a financial instrument. + + Attributes: + tick (Tick): Price tick object for instrument + account: An instance of the current trading account + + Notes: + Full properties are on the SymbolInfo Object. + Make sure Symbol is always initialized with a name argument + """ + tick: Tick + account: Account + + def __init__(self, **kwargs): + """Initialize the Symbol object with the name of the financial instrument. + + Args: + name (str): Name of the financial instrument + """ + 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 + + async def info_tick(self, *, name: str = "", retries=3) -> Tick: + """Get the current price tick of a financial instrument. + + Args: + name: if name is supplied get price tick of that financial instrument + + Returns: + Tick: Return a Tick Object + + Raises: + ValueError: If request was unsuccessful and None was returned + """ + if retries < 1: + raise ValueError(f'Could not get tick for {name or self.name}. {self.mt5.error}') + tick = await self.mt5.symbol_info_tick(name or self.name) + if tick is not None: + tick = Tick(**tick._asdict()) + setattr(self, 'tick', tick) if not name else ... + return tick + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.info_tick(name=name, retries=retries - 1) + raise ValueError(f'Could not get tick for {name or self.name}. {self.mt5.error}') + + async def symbol_select(self, *, enable: bool = True) -> bool: + """Select a symbol in the MarketWatch window or remove a symbol from the window. + Update the select property + + Args: + enable (bool): Switch. Optional unnamed parameter. If 'false', a symbol should be removed from + the MarketWatch window. + + Returns: + bool: True if successful, otherwise – False. + """ + self.select = await self.mt5.symbol_select(self.name, enable) + return self.select + + async def info(self, retries=3) -> SymbolInfo: + """Get data on the specified financial instrument and update the symbol object properties + + Returns: + (SymbolInfo): SymbolInfo if successful + + Raises: + ValueError: If request was unsuccessful and None was returned + """ + if retries < 1: + raise ValueError(f'Could not get info for {self.name}. {self.mt5.error}') + info = await self.mt5.symbol_info(self.name) + if info: + info = info._asdict() + info['swap_rollover3days'] = info.get('swap_rollover3days', 0) % 7 + self.set_attributes(**info) + return SymbolInfo(**info) + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.info(retries=retries - 1) + raise ValueError(f'Could not get info for {self.name}. {self.mt5.error}') + + async def init(self) -> bool: + """Initialized the symbol by pulling properties from the terminal + + Returns: + bool: Returns True if symbol info was successful initialized + """ + try: + if await self.symbol_select(): + await self.book_add() + await self.info() + await self.info_tick() + return True + logger.warning(f'Unable to initialized symbol {self}') + return False + except Exception as err: + self.select = False + logger.warning(err) + return False + + async def book_add(self) -> bool: + """Subscribes the MetaTrader 5 terminal to the Market Depth change events for a specified symbol. + If the symbol is not in the list of instruments for the market, This method will return False + + Returns: + bool: True if successful, otherwise – False. + """ + return await self.mt5.market_book_add(self.name) + + async def book_get(self, retries=3) -> tuple[BookInfo, ...]: + """Returns a tuple of BookInfo featuring Market Depth entries for the specified symbol. + + Returns: + tuple[BookInfo]: Returns the Market Depth contents as a tuples of BookInfo Objects + + Raises: + ValueError: If request was unsuccessful and None was returned + """ + if retries < 1: + raise ValueError(f'Could not get book info for {self.name}. {self.mt5.error}') + infos = await self.mt5.market_book_get(self.name) + if infos is not None: + book_infos = (BookInfo(**info._asdict()) for info in infos) + return tuple(book_infos) + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.book_get(retries=retries - 1) + raise ValueError(f'Could not get book info for {self.name}. {self.mt5.error}') + + async def book_release(self) -> bool: + """Cancels subscription of the MetaTrader 5 terminal to the Market Depth change events for a specified symbol. + + Returns: + bool: True if successful, otherwise – False. + """ + return await self.mt5.market_book_release(self.name) + + 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: + volume (float): Volume to check + + Returns: tuple[bool, float]: Returns a tuple of a boolean and a float. The boolean indicates if the volume is + within the limits of the symbol. The float is the volume to use if the volume is not within the limits of the + symbol. + """ + if check := self.volume_min <= volume <= self.volume_max: + return check, volume + 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: + """Round off the volume to the nearest volume step. + + Args: + volume (float): Volume to round off + round_down (bool): If True, round down. If False, round up. Optional unnamed parameter. Defaults to True. + + Returns: + float: Rounded off volume + """ + return round_off(value=volume, step=self.volume_step, round_down=round_down) + + 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 + + async def compute_volume(self, *args, **kwargs) -> float: + """Computes the volume required for a trade usually based on the amount and any other keyword arguments. + This is a dummy method that returns the minimum volume of the symbol. It is meant to be overridden by a subclass + that implements the computation of volume. + + Keyword Args: + use_limits (bool): round up or round down the computed volume to the nearest volume limit i.e. volume_min + or volume_max + + Returns: + float: Returns the volume of the trade + """ + return self.volume_min + + async def convert_currency(self, *, amount: float, base: str, quote: str) -> float: + """Convert from one currency to the other. Alias for currency_conversion""" + return await self.currency_conversion(amount=amount, base=base, quote=quote) + + async def currency_conversion(self, *, amount: float, base: str, quote: str) -> float: + """Convert from one currency to the other. + + Args: + amount: amount to convert given in terms of the quote currency + base: The base currency of the pair + quote: The quote currency of the pair + + 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 + + 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 + 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}') + + async def copy_rates_from(self, *, timeframe: TimeFrame, + date_from: datetime | int, count: int = 500, retries=3) -> Candles: + """ + Get bars from the MetaTrader 5 terminal starting from the specified date. + + Args: timeframe (TimeFrame): Timeframe the bars are requested for. Set by a value from the TimeFrame + enumeration. Required unnamed parameter. + + date_from (datetime | int): Date of opening of the first bar from the requested sample. Set by the + 'datetime' object or as a number of seconds elapsed since 1970.01.01. Required unnamed parameter. + + count (int): Number of bars to receive. Required unnamed parameter. + + Returns: + Candles: Returns a Candles object as a collection of rates ordered chronologically + + Raises: + ValueError: If request was unsuccessful and None was returned + """ + if retries < 1: + raise ValueError(f'Could not get rates for {self.name}. {self.mt5.error}') + rates = await self.mt5.copy_rates_from(self.name, timeframe, date_from, count) + if rates is not None: + return Candles(data=rates) + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.copy_rates_from(timeframe=timeframe, date_from=date_from, + count=count, retries=retries - 1) + raise ValueError(f'Could not get rates for {self.name}. {self.mt5.error}') + + async def copy_rates_from_pos(self, *, timeframe: TimeFrame, count: int = 500, + start_position: int = 0, retries=3) -> Candles: + """Get bars from the MetaTrader 5 terminal starting from the specified index. + + Args: + timeframe (TimeFrame): TimeFrame value from TimeFrame Enum. Required keyword only parameter + + count (int): Number of bars to return. Keyword argument defaults to 500 + + start_position (int): Initial index of the bar the data are requested from. The numbering of bars goes from + present to past. Thus, the zero bar means the current one. Keyword argument defaults to 0. + + Returns: + Candles: Returns a Candles object as a collection of rates ordered chronologically. + + Raises: + ValueError: If request was unsuccessful and None was returned + """ + if retries < 1: + raise ValueError(f'Could not get rates for {self.name}. {self.mt5.error}') + rates = await self.mt5.copy_rates_from_pos(self.name, timeframe, start_position, count) + if rates is not None: + return Candles(data=rates) + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.copy_rates_from_pos(timeframe=timeframe, count=count, + start_position=start_position, retries=retries - 1) + raise ValueError(f'Could not get rates for {self.name}. {self.mt5.error}') + + async def copy_rates_range(self, *, timeframe: TimeFrame, date_from: datetime | int, + date_to: datetime | int, retries=3) -> Candles: + """Get bars in the specified date range from the MetaTrader 5 terminal. + + Args: + timeframe (TimeFrame): Timeframe for the bars using the TimeFrame enumeration. Required unnamed parameter. + + date_from (datetime | int): Date the bars are requested from. Set by the 'datetime' object or as a number + of seconds elapsed since 1970.01.01. Bars with the open time >= date_from are returned. Required unnamed + parameter. + + date_to (datetime | int): Date, up to which the bars are requested. Set by the 'datetime' object or as a + number of seconds elapsed since 1970.01.01. Bars with the open time <= date_to are returned. + Required unnamed parameter. + + Returns: + Candles: Returns a Candles object as a collection of rates ordered chronologically. + + Raises: + ValueError: If request was unsuccessful and None was returned + """ + if retries < 1: + raise ValueError(f'Could not get rates for {self.name}. {self.mt5.error}') + + rates = await self.mt5.copy_rates_range(symbol=self.name, timeframe=timeframe, date_from=date_from, + date_to=date_to) + if rates is not None: + return Candles(data=rates) + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.copy_rates_range(timeframe=timeframe, date_from=date_from, + date_to=date_to, retries=retries - 1) + raise ValueError(f'Could not get rates for {self.name}. {self.mt5.error}') + + async def copy_ticks_from(self, *, date_from: datetime | int, count: int = 100, + flags: CopyTicks = CopyTicks.ALL, retries=3) -> Ticks: + """ + Get ticks from the MetaTrader 5 terminal starting from the specified date. + + Args: date_from (datetime | int): Date the ticks are requested from. Set by the 'datetime' object or as a + number of seconds elapsed since 1970.01.01. + + count (int): Number of requested ticks. Defaults to 100 + + flags (CopyTicks): A flag to define the type of the requested ticks from CopyTicks enum. INFO is the default + + Returns: + Candles: Returns a Candles object as a collection of ticks ordered chronologically. + + Raises: + ValueError: If request was unsuccessful and None was returned + """ + if retries < 1: + raise ValueError(f'Could not get ticks for {self.name}. {self.mt5.error}') + + ticks = await self.mt5.copy_ticks_from(self.name, date_from, count, flags) + if ticks is not None: + return Ticks(data=ticks) + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.copy_ticks_from(date_from=date_from, count=count, flags=flags, retries=retries - 1) + raise ValueError(f'Could not get ticks for {self.name}. {self.mt5.error}') + + async def copy_ticks_range(self, *, date_from: datetime | int, date_to: datetime | int, + flags: CopyTicks = CopyTicks.ALL, retries=3) -> Ticks: + """Get ticks for the specified date range from the MetaTrader 5 terminal. + + Args: + date_from: Date the bars are requested from. Set by the 'datetime' object or as a number of seconds elapsed + since 1970.01.01. Bars with the open time >= date_from are returned. Required unnamed parameter. + + date_to: Date, up to which the bars are requested. Set by the 'datetime' object or as a number of + seconds elapsed since 1970.01.01. Bars with the open time <= date_to are returned. + Required unnamed parameter. + + flags (CopyTicks): + + Returns: + Candles: Returns a Candles object as a collection of ticks ordered chronologically. + + Raises: + ValueError: If request was unsuccessful and None was returned. + """ + if retries < 1: + raise ValueError(f'Could not get ticks for {self.name}. {self.mt5.error}') + ticks = await self.mt5.copy_ticks_range(self.name, date_from, date_to, flags) + if ticks is not None: + return Ticks(data=ticks) + if self.mt5.error.is_connection_error(): + await asyncio.sleep(retries) + return await self.copy_ticks_range(date_from=date_from, date_to=date_to, flags=flags, retries=retries - 1) + raise ValueError(f'Could not get ticks for {self.name}. {self.mt5.error}') diff --git a/src/aiomql/terminal.py b/src/aiomql/terminal.py new file mode 100644 index 0000000..8d84709 --- /dev/null +++ b/src/aiomql/terminal.py @@ -0,0 +1,70 @@ +"""Terminal related functions and properties""" + +from typing import NamedTuple +from logging import getLogger +from .core.models import TerminalInfo + +logger = getLogger(__name__) + + +class Terminal(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))) + + async def initialize(self) -> bool: + """Establish a connection with the MetaTrader 5 terminal. There are three call options. Call without parameters. + The terminal for connection is found automatically. Call specifying the path to the MetaTrader 5 terminal we + want to connect to. word path as a keyword argument Call specifying the trading account path and parameters + i.e. login, password, server, as keyword arguments, path can be omitted. + + Returns: + bool: True if successful else False + """ + self.connected = await self.mt5.initialize(**self.config.account_info()) + + 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 + + async def version(self): + """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) + + async def info(self): + """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(). + + Returns: + Terminal: Terminal status and settings as a terminal object. + """ + info = await self.mt5.terminal_info() + self.set_attributes(**info._asdict()) + return self + + async def symbols_total(self) -> int: + """Get the number of all financial instruments in the MetaTrader 5 terminal. + + Returns: + int: Total number of available symbols + """ + return await self.mt5.symbols_total() diff --git a/src/aiomql/ticks.py b/src/aiomql/ticks.py new file mode 100644 index 0000000..fe62229 --- /dev/null +++ b/src/aiomql/ticks.py @@ -0,0 +1,212 @@ +"""Module for working with price ticks.""" + +from typing import TypeVar, Iterable + +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') + + +class Tick: + """Price Tick of a Financial Instrument. + + Attributes: + time (int): Time of the last prices update for the symbol + bid (float): Current Bid price + ask (float): Current Ask price + last (float): Price of the last deal (Last) + volume (float): Volume for the current Last price + time_msc (int): Time of the last prices update for the symbol in milliseconds + flags (TickFlag): Tick flags + volume_real (float): Volume for the current Last price + Index (int): Custom attribute representing the position of the tick in a sequence. + """ + time: float + bid: float + ask: float + last: float + volume: float + time_msc: float + flags: float + volume_real: float + Index: int + + def __init__(self, **kwargs): + """Initialize the Tick class. Set attributes from keyword arguments. bid, ask, last, time and volume must be + present""" + if not all(key in kwargs for key in ['bid', 'ask', 'last', 'volume', 'time']): + raise ValueError("bid, ask, last and volume, time must be present in the keyword arguments") + self.Index = kwargs.pop('Index', 0) + self.set_attributes(**kwargs) + + def __repr__(self): + return ("%(class)s(Index=%(Index)s, time=%(time)s, bid=%(bid)s, ask=%(ask)s, last=%(last)s, volume=%(volume)s)" + % {"class": self.__class__.__name__, "time": self.time, "bid": self.bid, + "ask": self.ask, "last": self.last, "volume": self.volume, 'Index': self.Index}) + + def dict(self, exclude: set = None, include: set = None) -> dict: + """ + Returns a dictionary of the instance attributes. + + Args: + exclude: A set of attributes to exclude from the dictionary. Defaults to None. + include: A set of attributes to include in the dictionary. Defaults to None. + + Returns: dict + """ + 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} + + def set_attributes(self, **kwargs): + """Set attributes from keyword arguments""" + for key, value in kwargs.items(): + 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 + bid: Series + ask: Series + last: Series + volume: Series + time_msc: Series + flags: Series + volume_real: Series + Index: Series + + def __init__(self, *, data: DataFrame | Iterable, flip=False): + """Initialize the Ticks class. Creates a DataFrame of price ticks from the data argument. + + Args: + data (DataFrame | Iterable): Dataframe of price ticks or any iterable object that can be converted to a + pandas DataFrame + flip (bool): If flip is True reverse data chronological order. + """ + if isinstance(data, DataFrame): + data = data + elif isinstance(data, type(self)): + data = DataFrame(data.data) + elif isinstance(data, Iterable): + data = DataFrame(data) + else: + raise ValueError(f'Cannot create DataFrame from object of {type(data)}') + self._data = data.iloc[::-1] if flip else data + + def __repr__(self): + return self._data.__repr__() + + def __len__(self): + return self._data.shape[0] + + def __contains__(self, item: Tick) -> bool: + return item.time_msc == self[item.Index].time_msc + + def __getattr__(self, item): + if item in list(self._data.columns.values): + return self._data[item] + raise AttributeError(f'Attribute {item} not defined on class {self.__class__.__name__}') + + def __getitem__(self, index) -> Tick | Self: + if isinstance(index, slice): + cls = self.__class__ + data = self._data.iloc[index] + data.reset_index(drop=True, inplace=True) + return cls(data=data) + + if isinstance(index, str): + return self._data[index] + + item = self._data.iloc[index] + return Tick(**item, Index=index) + + def __setitem__(self, index, value: Series): + if isinstance(value, Series): + self._data[index] = value + return + raise TypeError(f'Expected Series got {type(value)}') + + def __iter__(self): + return (Tick(**row._asdict()) for row in self._data.itertuples()) + + @property + def ta(self): + """Access to the pandas_ta library for performing technical analysis on the underlying data attribute. + + Returns: + pandas_ta: The pandas_ta library + """ + return self._data.ta + + @property + def ta_lib(self): + """Access to the ta library for performing technical analysis. Not dependent on the underlying data attribute. + + Returns: + ta: The ta library + """ + return ta + + @property + def data(self) -> DataFrame: + """DataFrame of price ticks arranged in chronological order.""" + return self._data + + def rename(self, inplace=True, **kwargs) -> _Ticks | None: + """Rename columns of the candle class. + + Keyword Args: + inplace (bool): Rename the columns inplace or return a new instance of the class with the renamed columns + **kwargs: The new names of the columns + + Returns: + Ticks: A new instance of the class with the renamed columns if inplace is False. + None: If inplace is True + """ + res = self._data.rename(columns=kwargs, inplace=inplace) + return res if inplace else self.__class__(data=res) + + def make_addplot(self, *, count: int = 50, columns: list = None, **kwargs) -> dict: + """ + Make subplots for adding to the main plot + + Args: + count (int): The numbers of candles to make the addplot for. Defaults to 50. + columns (list[str]): The columns to make the plot from. Defaults to None. + **kwargs: Valid arguments for the mplfinance make_addplot function + """ + columns = columns or [] + data = self._data[-count:] + 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, + 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 + 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. + style (str): The style of the chart. Defaults to 'charles'. + ylabel (str): The label of the y-axis. Defaults to 'Price'. + title (str): The title of the chart. Defaults to 'Chart'. + 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} + 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/trade_records.py new file mode 100644 index 0000000..55fd395 --- /dev/null +++ b/src/aiomql/trade_records.py @@ -0,0 +1,154 @@ +"""This module contains the Records class, which is used to read and update trade records from csv files.""" + +import asyncio +import json +from pathlib import Path +import csv +import logging +from typing import Iterable + +from .core import Config, MetaTrader + +logger = logging.getLogger(__name__) + + +class TradeRecords: + """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() + self.records_dir = records_dir or self.config.records_dir + + async def get_csv_records(self): + """Get trade records saved as csv 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 get_json_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('.json'): + yield file + + async def read_update_csv(self, *, file: Path): + """Read and update csv trade records + + Args: + 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() + except Exception as err: + logger.error(f'Error: {err}. Unable to read and update csv trade records') + + async def read_update_json(self, *, file: Path): + """Read and update json trade records + Args: + 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() + except Exception as err: + logger.error(f'Error: {err}. Unable to read and update json trade records') + + async def update_row(self, *, row: dict) -> dict: + """Update a single row of entered trade in the csv or json 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 or json file with the actual profit. + + Args: + rows: A list of dictionaries. + + Returns: + list[dict]: A list of dictionaries with the actual profit and win status. + """ + closed, unclosed = [], [] + for row in rows: + closed_ = row.get('closed', False) + closed_ = closed_.title() == 'True' if isinstance(closed_, str) else closed_ + if closed_: + closed.append(row) + else: + unclosed.append(row) + unclosed = await asyncio.gather(*[self.update_row(row=row) for row in unclosed]) + return closed + list(unclosed) + + async def update_csv_records(self): + """Update csv trade records in the records_dir folder.""" + records = [self.read_update_csv(file=record) async for record in self.get_csv_records()] + await asyncio.gather(*records) + + async def update_json_records(self): + """Update json trade records in the records_dir folder.""" + records = [self.read_update_json(file=record) async for record in self.get_json_records()] + await asyncio.gather(*records) + + async def update_csv_record(self, *, file: Path | str): + """Update a single trade record csv file.""" + await self.read_update_csv(file=file) + + async def update_json_record(self, *, file: Path | str): + """Update a single json trade record file""" + await self.read_update_json(file=file) diff --git a/src/aiomql/trader.py b/src/aiomql/trader.py new file mode 100644 index 0000000..00183d8 --- /dev/null +++ b/src/aiomql/trader.py @@ -0,0 +1,128 @@ +"""Trader class module. Handles the creation of an order and the placing of trades""" +from abc import ABC, abstractmethod +from datetime import datetime +from typing import TypeVar +from logging import getLogger +from zoneinfo import ZoneInfo + +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 + +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. + + Attributes: + symbol (Symbol): The financial instrument. + ram (RAM): RAM instance + order (Order): Trade order + + Class Attributes: + config (Config): Config instance. + """ + config: Config + + def __init__(self, *, symbol: Symbol, ram: RAM = None): + """Initializes the order object and RAM instance + + Args: + symbol (Symbol): Financial instrument + ram (RAM): Risk Assessment and Management instance + """ + self.config = Config() + self.symbol = symbol + self.order = Order(symbol=symbol.name) + self.ram = ram or RAM() + self.parameters = {} + + def set_order_limits(self, *, pips: float, tick: Tick): + """Sets the stop loss and take profit for the order. This method uses pips as defined for forex instruments. + + Args: + pips: Target pips + tick: Tick object + """ + pips = pips * self.symbol.pip + sl, tp = pips, pips * self.ram.risk_to_reward + if self.order.type == OrderType.BUY: + self.order.sl, self.order.tp = round(tick.ask - sl, self.symbol.digits), round(tick.ask + tp, + self.symbol.digits) + self.order.price = tick.ask + elif self.order.type == OrderType.SELL: + self.order.sl, self.order.tp = round(tick.bid + sl, self.symbol.digits), round(tick.bid - tp, + self.symbol.digits) + self.order.price = tick.bid + else: + raise ValueError(f"Invalid order type: {self.order.type}") + + def set_trade_stop_levels(self, *, points: float, tick: Tick): + """Set the stop loss and take profit levels of the order based on the points and price tick. + + Args: + points: Target points + tick: Tick object + """ + points = points * self.symbol.point + sl, tp = points, points * self.ram.risk_to_reward + if self.order.type == OrderType.BUY: + self.order.sl, self.order.tp = round(tick.ask - sl, self.symbol.digits), round(tick.ask + tp, + self.symbol.digits) + self.order.price = tick.ask + else: + self.order.sl, self.order.tp = round(tick.bid + sl, self.symbol.digits), round(tick.bid - tp, + self.symbol.digits) + self.order.price = tick.bid + + async def check_order(self) -> bool: + """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: + """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}") + return result + logger.info(f"Placed Trade for {self.symbol}") + return result + + async def record_trade(self, result: OrderSendResult, parameters: dict = None, name: str = '', exclude: set = None): + """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())} + profit = result.profit or await self.order.calc_profit() + params["expected_profit"] = profit + date = datetime.utcnow() + date = date.replace(tzinfo=ZoneInfo("UTC")) + params["date"] = str(date.date()) + params["time"] = str(date.time()) + res = Result(result=result, parameters=params, name=name) + self.config.task_queue.add_task(res.save) + + @abstractmethod + async def place_trade(self, *args, **kwargs): + """Places a trade based on the order_type.""" diff --git a/src/aiomql/utils.py b/src/aiomql/utils.py new file mode 100644 index 0000000..4c4aa09 --- /dev/null +++ b/src/aiomql/utils.py @@ -0,0 +1,37 @@ +"""Utility functions for aiomql.""" + +import decimal +from .candle import Candles, Candle + + +def dict_to_string(data: dict, multi=False) -> str: + """Convert a dict to a string. Useful for logging. + + Args: + data (dict): The dict to convert. + multi (bool, optional): If True, each key-value pair will be on a new line. Defaults to False. + + Returns: + str: The string representation of the dict. + """ + sep = '\n' if multi else ', ' + return f"{sep}".join(f"{key}: {value}" for key, value in data.items()) + + +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: + ctx.rounding = decimal.ROUND_DOWN if round_down else decimal.ROUND_UP + return float(decimal.Decimal(str(value)).quantize(decimal.Decimal(str(step)))) + + +def find_bearish_fractal(candles: Candles) -> Candle | None: + for i in range(len(candles) - 3, 1, -1): + if candles[i].high > max(candles[i - 1].high, candles[i + 1].high, candles[i - 2].high, candles[i + 2].high): + return candles[i] + + +def find_bullish_fractal(candles: Candles) -> Candle | None: + for i in range(len(candles) - 3, 1, -1): + if candles[i].low < min(candles[i - 1].low, candles[i + 1].low, candles[i - 2].low, candles[i + 2].low): + return candles[i]