diff --git a/.gitignore b/.gitignore index 429bef3..981a792 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ lib64/ parts/ sdist/ var/ +notebooks/ *.egg-info/ .installed.cfg *.egg @@ -72,4 +73,4 @@ target/ # config files config.json aiomql.json -config/ \ No newline at end of file +config/ diff --git a/Untitled.ipynb b/Untitled.ipynb new file mode 100644 index 0000000..5f6f68b --- /dev/null +++ b/Untitled.ipynb @@ -0,0 +1,520 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "f4500c8d-0e58-4d3f-8dd3-06a4896f397f", + "metadata": {}, + "outputs": [], + "source": [ + "import shelve\n", + "import pickle\n", + "import zlib\n", + "import lzma\n", + "import pytz\n", + "from datetime import datetime, timedelta\n", + "from aiomql import MetaTrader, MetaTester, TimeFrame, TestData, AccountInfo, TimeFrame, CopyTicks, Account, GetData\n", + "# from MetaTrader5 import SymbolInfo\n", + "import pandas as pd" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9307e3df-a5f3-42cf-9d3d-e65e5254c0e5", + "metadata": {}, + "outputs": [], + "source": [ + "now = datetime.now()\n", + "st = now.replace(hour=0, minute=0, second=0, day=1, month=1, year=2023)\n", + "et = now.replace(hour=9, minute=0, second=0)\n", + "diff = et - st\n", + "secs = int(diff.total_seconds())\n", + "# st = now.replace(hour=0, day=16)\n", + "# et = now.replace(hour=9)\n", + "# start = now.replace(day=now.day-3, tzinfo=tz)\n", + "# end = now.replace(day=now.day-1, tzinfo=tz)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63050d62-443a-4d30-b2a6-4ec6d4f00362", + "metadata": {}, + "outputs": [], + "source": [ + "symbols = {'Volatility 10 Index', 'Volatility 100 (1s) Index', 'Volatility 25 Index'}\n", + "timeframes = {TimeFrame.M5, TimeFrame.H1, TimeFrame.M1, TimeFrame.H4, TimeFrame.M30, TimeFrame.M15}\n", + "gd = GetData(st, et, timeframes, symbols, name='data')\n", + "await gd.fail()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49d86767-d5cc-40a8-894e-c8f5b920f8cf", + "metadata": {}, + "outputs": [], + "source": [ + "symbols = {'Volatility 10 Index', 'Volatility 100 (1s) Index', 'Volatility 25 Index'}\n", + "timeframes = {TimeFrame.M5, TimeFrame.H1, TimeFrame.M1, TimeFrame.H4, TimeFrame.M30, TimeFrame.M15}\n", + "async with MetaTester(st, et, timeframes, symbols, name='data') as mt:\n", + " # res = await mt.copy_ticks_range('Volatility 100 Index', st, et, CopyTicks.ALL)\n", + " # print(res)\n", + " await mt.get_and_save_data()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c4f8c64b-dcba-40cf-8342-53bb4b3fa6e6", + "metadata": {}, + "outputs": [], + "source": [ + "df = pd.DataFrame(res)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "970126ba-a143-4b45-a82e-8cf6b885763c", + "metadata": {}, + "outputs": [], + "source": [ + "# df.set_index(list(range(secs)))\n", + "df.drop_duplicates(subset=['time'], keep='last', )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bf6d4786-4ee1-4bd4-81e2-fa43bdc527a4", + "metadata": {}, + "outputs": [], + "source": [ + "df = df.set_index('time', drop=False, verify_integrity=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e788c43f-42d3-4c01-9bd9-5224e2175c1f", + "metadata": {}, + "outputs": [], + "source": [ + "bg = int(st.timestamp())\n", + "en = int(secs) + bg\n", + "index = range(bg, en)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fd9cffe6-c550-4f54-be8a-5e55b23b81a9", + "metadata": {}, + "outputs": [], + "source": [ + "bf = len(df.index)\n", + "df = df.reindex(index=index, method='nearest')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c0b60fb9-0c27-4435-900d-898ea6979e11", + "metadata": {}, + "outputs": [], + "source": [ + "last = df.iloc[-1].time\n", + "print(datetime.fromtimestamp(last, tz=tz), et)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "60afd149-9600-4f0e-b3ff-1e8ad10247f3", + "metadata": {}, + "outputs": [], + "source": [ + "acc = AccountInfo(balance=500)\n", + "data = MetaTester(start, end).load_data('data')\n", + "td = TestData(acc, data)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "be5704d5-4bff-4ec1-8cbe-2e96bfaf4f11", + "metadata": {}, + "outputs": [], + "source": [ + "ticks = data['ticks']['Volatility 10 Index']" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12b3d5cb-0d31-4b70-b583-1f2becb74a0f", + "metadata": {}, + "outputs": [], + "source": [ + "ticks[-1]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ee0c4ccb-0e7c-434e-a655-fe89b09e606c", + "metadata": {}, + "outputs": [], + "source": [ + "y = TimeFrame.M5\n", + "y.time" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7a28ce72-0409-4231-92f7-f4e61fc0be94", + "metadata": {}, + "outputs": [], + "source": [ + "end.timestamp()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4aeeb64b-d3d2-4053-9808-2539e2755d09", + "metadata": {}, + "outputs": [], + "source": [ + "len(ticks)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29647ef4-01ad-46a0-afb2-f43a4988a529", + "metadata": {}, + "outputs": [], + "source": [ + "type(ticks[0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bc593a4b-53d6-4203-a986-33b5267b9244", + "metadata": {}, + "outputs": [], + "source": [ + "ticks.reshape(8, 86160)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dc6440a3-4333-4363-b462-edee4facdd0f", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a246c6d-d35c-4878-a128-52ed1abb0108", + "metadata": {}, + "outputs": [], + "source": [ + "res = np.reshape(ticks, (-1, 8))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e9de365-0f13-4d88-8302-f5e3418d489b", + "metadata": {}, + "outputs": [], + "source": [ + "ticks.shape = (86160, 8)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "451b8e09-d17c-4662-a25d-39f00ff01788", + "metadata": {}, + "outputs": [], + "source": [ + "res = np.hstack(ticks)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "395a1b7b-309d-4e38-b579-1b6b4854b19d", + "metadata": {}, + "outputs": [], + "source": [ + "v = np.array((*ticks[0]))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1bb16ff4-d92e-4474-a235-d8be87d094d6", + "metadata": {}, + "outputs": [], + "source": [ + "r = next(iter(ticks))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f2241c9c-68eb-4371-b3e3-202ebe25f7cd", + "metadata": {}, + "outputs": [], + "source": [ + "v.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ee0d3713-8578-46e2-969c-aebc295fae98", + "metadata": {}, + "outputs": [], + "source": [ + "ar = list(range(4))\n", + "t = np.array(ar)\n", + "t.shape = (1, 4)\n", + "t" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8502f7da-793e-4bfe-b469-bc109f051507", + "metadata": {}, + "outputs": [], + "source": [ + "mt.config.login" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4d818ddd-f78c-4a50-b4a7-dc266321091c", + "metadata": {}, + "outputs": [], + "source": [ + "acc = Account()\n", + "await acc.sign_in()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4c4c32d9-9c45-4ee7-afee-e04d1f5dca4a", + "metadata": {}, + "outputs": [], + "source": [ + "mt5 = MetaTrader()\n", + "sym = await mt5.symbol_info('Volatility 100 (1s) Index')\n", + "print(sym._asdict)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e47bd638-42fa-40d9-b573-474a39884dbc", + "metadata": {}, + "outputs": [], + "source": [ + "n = datetime.now()\n", + "start = n.replace(hour=0, day=1, year=2020, month=1)\n", + "end = n.replace(hour=15)\n", + "res = await mt.copy_rates_range('Volatility 25 Index', TimeFrame.M1, start, end)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4bc7b28-6dac-4863-b257-64d2c5c62575", + "metadata": {}, + "outputs": [], + "source": [ + "res = pd.DataFrame(res)\n", + "len(res.index)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "90c17d01-4ed0-468d-85c4-64988338b8a9", + "metadata": {}, + "outputs": [], + "source": [ + "print(start)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9a9141a1-7049-48e5-a96f-0b637b817d40", + "metadata": {}, + "outputs": [], + "source": [ + "data = shelve.open('./data/01-08-24_17-08-24', writeback=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e2d4d4d7-5bfa-43e3-a455-012cf754a106", + "metadata": {}, + "outputs": [], + "source": [ + "data = dict(data)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95931276-fb8c-4a97-b440-330efc5e4d93", + "metadata": {}, + "outputs": [], + "source": [ + "data = pickle.dumps(data)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57c620ad-3fa5-41b9-a372-9a090e18ff85", + "metadata": {}, + "outputs": [], + "source": [ + "data = zlib.compress(data, level=9)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2d85bcdb-a86a-43f0-9b93-7d8c0ce7cf9e", + "metadata": {}, + "outputs": [], + "source": [ + "_data = lzma.compress(data)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eec34d71-7455-4064-9367-52156407335e", + "metadata": {}, + "outputs": [], + "source": [ + "fh = lzma.open('./data/ldata.xz', 'w')\n", + "fh.write(_data)\n", + "fh.close()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6f38b857-ed2d-4819-ba7f-4f70dc2fb3f9", + "metadata": {}, + "outputs": [], + "source": [ + "rb = lzma.open('./data/ldata.xz')\n", + "rbb = rb.read()\n", + "rb.close()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28220ba7-46af-409b-826d-2979ceb19f65", + "metadata": {}, + "outputs": [], + "source": [ + "rbd = lzma.decompress(rbb)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3275fd6-70ff-4596-b4ba-9d9e4b6e52fe", + "metadata": {}, + "outputs": [], + "source": [ + "rdata = pickle.loads(rbd)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5bbcbc06-f353-4255-9792-89263793ba0f", + "metadata": {}, + "outputs": [], + "source": [ + "data.keys()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f9830b77-5e73-45c9-bc6a-40c19c3816c5", + "metadata": {}, + "outputs": [], + "source": [ + "fh = open('./data/pdata', 'wb')\n", + "pickle.dump(rdata, fh)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "064b8628-1331-4fa3-abf3-66f88c75da33", + "metadata": {}, + "outputs": [], + "source": [ + "fh.close()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fe60fc41-8844-4e00-b254-bb95d28528f2", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/order.md b/docs/order.md index da8e4fe..5091081 100644 --- a/docs/order.md +++ b/docs/order.md @@ -130,7 +130,4 @@ Return profit in the account currency for a specified trading operation. | Type | Description | |---------|-----------------------------------| | `float` | Returns float value if successful | -#### Raises: -| Exception | Description | -|--------------|-------------------| -| `OrderError` | If not successful | +| `None` | If not successful | diff --git a/docs/symbol.md b/docs/symbol.md index 89469b1..426029d 100644 --- a/docs/symbol.md +++ b/docs/symbol.md @@ -165,7 +165,7 @@ This is a dummy method that returns the minimum volume of the symbol. It is mean async def currency_conversion(*, amount: float, base: str, quote: str) -> float ``` -Convert from one currency to the other. +Convert from one currency to the other. Returns the amount in terms of the base currency. #### Parameters | Name | Type | Description | Default | |----------|---------|--------------------------------------------------------|---------| diff --git a/ifo.bak b/ifo.bak new file mode 100644 index 0000000..e69de29 diff --git a/ifo.dat b/ifo.dat new file mode 100644 index 0000000..e69de29 diff --git a/ifo.dir b/ifo.dir new file mode 100644 index 0000000..e69de29 diff --git a/info.dat b/info.dat new file mode 100644 index 0000000..e69de29 diff --git a/info.dir b/info.dir new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index a8a3220..8e114f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "aiomql" -version = "3.21" +version = "3.23" readme = "README.md" requires-python = ">=3.11" classifiers = [ diff --git a/src/aiomql/account.py b/src/aiomql/account.py index fdcf22a..93a06a9 100644 --- a/src/aiomql/account.py +++ b/src/aiomql/account.py @@ -29,9 +29,11 @@ class Account(AccountInfo): def __init__(self, **kwargs): super().__init__(**kwargs) - if not self.login: - acc = self.config.account_info() - self.set_attributes(**acc) + 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""" diff --git a/src/aiomql/candle.py b/src/aiomql/candle.py index 21cc18d..5754342 100644 --- a/src/aiomql/candle.py +++ b/src/aiomql/candle.py @@ -60,7 +60,8 @@ class Candle: return str(self.dict()) def __eq__(self, other: "Candle"): - return self.time == other.time + 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) @@ -218,10 +219,6 @@ class Candles(Generic[_Candle]): def __iter__(self): return (self.Candle(**row._asdict()) for row in self._data.itertuples()) - def __add__(self, other: _Candles | _Candle): - other = other.data if isinstance(other, type(self)) else other.dict() - return self.__class__(data=self._data.append(other.data, ignore_index=True)) - @property def timeframe(self): tf = self.time[1] - self.time[0] diff --git a/src/aiomql/core/config.py b/src/aiomql/core/config.py index 8fa6c2c..674c432 100644 --- a/src/aiomql/core/config.py +++ b/src/aiomql/core/config.py @@ -69,6 +69,14 @@ class Config: 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): @@ -141,7 +149,7 @@ class Config: data = json.load(fh) fh.close() data |= kwargs - [setattr(self, key, value) for key, value in data.items()] + self.set_attributes(**data) self._initialize = False def account_info(self) -> dict[str, int | str]: diff --git a/src/aiomql/core/meta_trader.py b/src/aiomql/core/meta_trader.py index b42f315..94242dd 100644 --- a/src/aiomql/core/meta_trader.py +++ b/src/aiomql/core/meta_trader.py @@ -4,7 +4,6 @@ from logging import getLogger from typing import Callable import MetaTrader5 - from MetaTrader5 import BookInfo, SymbolInfo, AccountInfo, Tick, TerminalInfo, TradeOrder, TradeDeal, \ TradePosition, OrderSendResult, OrderCheckResult @@ -56,12 +55,11 @@ class MetaTrader(metaclass=BaseMeta): _symbols_total: Callable _terminal_info: Callable _version: Callable - error: Error config: Config def __init__(self): self.config = Config() - self.error = Error(1, 'Successful') + self.error: Error = Error(-4, description='no history') async def __aenter__(self) -> 'MetaTrader': """ @@ -130,7 +128,7 @@ class MetaTrader(metaclass=BaseMeta): return await asyncio.to_thread(self._last_error) except Exception as err: logger.warning(f'Error in obtaining last error.') - return 0, str(err) + return -1, str(err) async def version(self) -> tuple[int, int, str] | None: """""" @@ -346,6 +344,6 @@ class MetaTrader(metaclass=BaseMeta): if res is None: err = await self.last_error() self.error = Error(*err) - logger.warning(f'Error in getting deals.{self.error.description}') + 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 index f7e8781..e75b7ec 100644 --- a/src/aiomql/core/models.py +++ b/src/aiomql/core/models.py @@ -503,11 +503,12 @@ class OrderSendResult(Base): price: float bid: float ask: float + profit: float + loss: float comment: str request: TradeRequest request_id: int retcode_external: int - profit: float """ retcode: int deal: int @@ -520,7 +521,8 @@ class OrderSendResult(Base): request: mt5.TradeRequest request_id: int retcode_external: int - profit: float + profit: float = None + loss: float = None class TradePosition(Base): diff --git a/src/aiomql/history.py b/src/aiomql/history.py index a0a633a..7bad2a3 100644 --- a/src/aiomql/history.py +++ b/src/aiomql/history.py @@ -23,7 +23,6 @@ class History: 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 - initialized (bool): check if initial request has been sent to the terminal to get history. mt5 (MetaTrader): MetaTrader instance config (Config): Config instance """ @@ -55,24 +54,18 @@ class History: self.orders: list[TradeOrder] = [] self.total_deals: int = 0 self.total_orders: int = 0 - self.initialized = False - async def init(self, deals=True, orders=True) -> bool: + 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 - - Returns: - bool: True if all requests were successful else False """ - tasks = [] - tasks.append(self.get_deals()) if deals else ... - tasks.append(self.get_orders()) if orders else ... - res = await asyncio.gather(*tasks) - self.initialized = all(res) - return self.initialized + 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, ...]: @@ -89,9 +82,7 @@ class History: deals = await self.mt5.history_deals_get(date_from=date_from, date_to=date_to, group=group) if deals is not None: - self.deals = tuple(TradeDeal(**deal._asdict()) for deal in deals) - self.total_deals = len(self.deals) - return self.deals + return tuple(TradeDeal(**deal._asdict()) for deal in deals) if self.mt5.error.is_connection_error(): await asyncio.sleep(retries) @@ -113,7 +104,7 @@ class History: 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], key=lambda x: x.time_msc)) + 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, ...]: """ @@ -127,7 +118,7 @@ class History: 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], key=lambda x: x.time_msc)) + 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. @@ -167,13 +158,13 @@ class History: logger.warning(f'Failed to get orders: {self.mt5.error}') return tuple() - async def get_order_ticket(self, ticket: int | None = None) -> TradeOrder: + 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) - order = orders[0] - assert order.ticket == ticket - return TradeOrder(**order._asdict()) + 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, ...]: """ @@ -189,7 +180,7 @@ class History: 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], key=lambda x: x.time_done_msc)) + 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. diff --git a/src/aiomql/lib/__init__.py b/src/aiomql/lib/__init__.py index 130b456..5fd08d6 100644 --- a/src/aiomql/lib/__init__.py +++ b/src/aiomql/lib/__init__.py @@ -1,3 +1,4 @@ from .strategies import * from .traders import * from .symbols import * +from .backtester import * diff --git a/src/aiomql/lib/backtester/__init__.py b/src/aiomql/lib/backtester/__init__.py new file mode 100644 index 0000000..cc065ef --- /dev/null +++ b/src/aiomql/lib/backtester/__init__.py @@ -0,0 +1,3 @@ +from .meta_tester import MetaTester +from .test_data import TestData +from .get_data import GetData diff --git a/src/aiomql/lib/backtester/get_data.py b/src/aiomql/lib/backtester/get_data.py new file mode 100644 index 0000000..0e1a7f4 --- /dev/null +++ b/src/aiomql/lib/backtester/get_data.py @@ -0,0 +1,134 @@ +import pickle +import random +from datetime import datetime +from logging import getLogger +import asyncio + +import pytz +from MetaTrader5 import Tick, SymbolInfo +import pandas as pd + +from ...core.meta_trader import MetaTrader +from ...core.config import Config +from ...core.errors import Error +from ...core.constants import TimeFrame, CopyTicks, OrderType +from ...core.models import (AccountInfo, SymbolInfo, BookInfo, TradeOrder, OrderCheckResult, OrderSendResult, + TradePosition, TradeDeal) +from ...utils import backoff_decorator + +logger = getLogger(__name__) + + +class GetData(MetaTrader): + + def __init__(self, start: datetime, end: datetime, timeframes: set[TimeFrame], symbols: set[str], + interval: int = 60, name: str = '', tz: str = 'Etc/UTC'): + """""" + super().__init__() + self.tz = pytz.timezone(tz) + self.start = start.replace(tzinfo=self.tz) + self.end = end.replace(tzinfo=self.tz) + self.interval = interval + self.symbols = symbols + self.timeframes = timeframes + self.counter = 0 + self.name = name or f"{start:%d-%m-%y}_{end:%d-%m-%y}" + diff = int((self.end - self.start).total_seconds()) + self.span = range(start := int(self.start.timestamp()), diff + start) + self.mt5 = MetaTrader() + + async def get_test_data(self) -> dict: + """""" + data = {} + rates, ticks, prices = await asyncio.gather(self.get_symbols_rates(), self.get_symbols_ticks(), + self.get_symbols_prices()) + + data['rates'] = rates + data['ticks'] = ticks + data['prices'] = prices + data['symbols'] = await self.get_symbols_info() + data['account'] = self.get_account_info() + + return data + + async def get_and_save_data(self) -> None: + """""" + data = await self.get_test_data() + fh = open(f'{self.config.root}/data/{self.name}', 'wb') + pickle.dump(data, fh) + fh.close() + + def load_data(self, name: str = '') -> dict: + """""" + name = name or self.name + file = open(f'{self.config.root}/data/{name}', 'rb') + data = pickle.load(file) + file.close() + return data + + async def get_symbols_info(self): + """""" + tasks = [self.get_symbol_info(symbol) for symbol in self.symbols] + res = await asyncio.gather(*tasks) + return {symbol: info for symbol, info in res} + + async def get_symbols_ticks(self): + """""" + tasks = [self.get_symbol_ticks(symbol) for symbol in self.symbols] + res = await asyncio.gather(*tasks) + return {symbol: ticks for symbol, ticks in res} + + async def get_symbols_prices(self): + """""" + tasks = [self.get_symbol_prices(symbol) for symbol in self.symbols] + res = await asyncio.gather(*tasks) + return {symbol: prices for symbol, prices in res} + + async def get_symbols_rates(self): + """""" + tasks = [self.get_symbol_rates(symbol, timeframe) for symbol in self.symbols for timeframe in self.timeframes] + res = await asyncio.gather(*tasks) + data = {} + for symbol, timeframe, rates in res: + data.setdefault(symbol, {}).setdefault(timeframe.name, rates) + return data + + @backoff_decorator(max_retries=5) + async def get_account_info(self) -> AccountInfo | None: + """""" + res = await self.mt5.account_info() + return res._asdict() + + @backoff_decorator(max_retries=5) + async def get_symbol_info(self, symbol: str): + """""" + res = await self.mt5.symbol_info(symbol) + return symbol, res._asdict() + + @backoff_decorator(max_retries=5) + async def get_symbol_ticks(self, symbol: str): + """""" + res = await self.mt5.copy_ticks_range(symbol, self.start, self.end, CopyTicks.ALL) + res = pd.DataFrame(res) + res.drop_duplicates(subset=['time'], keep='last', inplace=True) + res.set_index('time', inplace=True, drop=False) + return symbol, res + + @backoff_decorator(max_retries=5) + async def get_symbol_prices(self, symbol: str): + """""" + res = await self.mt5.copy_ticks_range(symbol, self.start, self.end, CopyTicks.ALL) + res = pd.DataFrame(res) + res.drop_duplicates(subset=['time'], keep='last', inplace=True) + res.set_index('time', inplace=True, drop=False) + res = res.reindex(self.span, method='nearest') + return symbol, res + + @backoff_decorator(max_retries=5) + async def get_symbol_rates(self, symbol: str, timeframe: TimeFrame): + """""" + res = await self.mt5.copy_rates_range(symbol, timeframe, self.start, self.end) + res = pd.DataFrame(res) + res.drop_duplicates(subset=['time'], keep='last', inplace=True) + res.set_index('time', inplace=True, drop=False) + return symbol, timeframe, res diff --git a/src/aiomql/lib/backtester/meta_data.py b/src/aiomql/lib/backtester/meta_data.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/aiomql/lib/backtester/meta_data.py @@ -0,0 +1 @@ + diff --git a/src/aiomql/lib/backtester/meta_tester.py b/src/aiomql/lib/backtester/meta_tester.py new file mode 100644 index 0000000..b7a95c0 --- /dev/null +++ b/src/aiomql/lib/backtester/meta_tester.py @@ -0,0 +1,317 @@ +import pickle +from datetime import datetime +from logging import getLogger +import asyncio + +import pytz +from MetaTrader5 import Tick, SymbolInfo +import pandas as pd + +from ...core.meta_trader import MetaTrader +from ...core.config import Config +from ...core.errors import Error +from ...core.constants import TimeFrame, CopyTicks, OrderType +from ...core.models import (AccountInfo, SymbolInfo, BookInfo, TradeOrder, OrderCheckResult, OrderSendResult, + TradePosition, TradeDeal) + +logger = getLogger(__name__) + + +class MetaTester(MetaTrader): + + def __init__(self, start: datetime, end: datetime, timeframes: set[TimeFrame], symbols: set[str], + interval: int = 60, name: str = '', tz: str = 'Etc/UTC'): + """""" + super().__init__() + self.tz = pytz.timezone(tz) + self.start = start.replace(tzinfo=self.tz) + self.end = end.replace(tzinfo=self.tz) + self.interval = interval + self.symbols = symbols + self.timeframes = timeframes + self.counter = 0 + self.name = name or f"{start:%d-%m-%y}_{end:%d-%m-%y}" + diff = int((self.end - self.start).total_seconds()) + self.span = range(start := int(self.start.timestamp()), diff + start) + + def __iter__(self): + yield + + async def get_test_data(self) -> dict: + """""" + data = {} + rates, ticks, prices = await asyncio.gather(self.get_symbols_rates(), self.get_symbols_ticks(), + self.get_symbols_prices()) + + data['rates'] = rates + data['ticks'] = ticks + data['prices'] = prices + data['symbols'] = await self.get_symbols_info() + data['account'] = self.get_account_info() + + return data + + async def get_and_save_data(self) -> None: + """""" + data = await self.get_test_data() + fh = open(f'{self.config.root}/data/{self.name}', 'wb') + data_file = pickle.dump(data, fh) + # data_file.update(data) + # data_file.sync() + # data_file.close() + fh.close() + + def load_data(self, name: str = '') -> dict: + """""" + name = name or self.name + data_file = shelve.open(f'{self.config.root}/data/{name}', writeback=True) + data = dict(data_file) + data_file.close() + return data + + async def get_symbols_info(self): + """""" + tasks = [self.get_symbol_info(symbol) for symbol in self.symbols] + res = await asyncio.gather(*tasks) + return {symbol: info for symbol, info in res} + + async def get_symbols_ticks(self): + """""" + tasks = [self.get_symbol_ticks(symbol) for symbol in self.symbols] + res = await asyncio.gather(*tasks) + return {symbol: ticks for symbol, ticks in res} + + async def get_account_info(self): + """""" + res = await super().account_info() + return res._asdict() + + async def get_symbols_prices(self): + """""" + tasks = [self.get_symbol_prices(symbol) for symbol in self.symbols] + res = await asyncio.gather(*tasks) + return {symbol: prices for symbol, prices in res} + + async def get_symbols_rates(self): + """""" + tasks = [self.get_symbol_rates(symbol, timeframe) for symbol in self.symbols for timeframe in self.timeframes] + res = await asyncio.gather(*tasks) + data = {} + for symbol, timeframe, rates in res: + data.setdefault(symbol, {}).setdefault(timeframe.name, rates) + return data + + async def get_symbol_info(self, symbol: str): + """""" + res = await super().symbol_info(symbol) + return symbol, res._asdict() + + async def get_symbol_ticks(self, symbol: str): + """""" + res = await super().copy_ticks_range(symbol, self.start, self.end, CopyTicks.ALL) + # res = pd.DataFrame(res) + # res.drop_duplicates(subset=['time'], keep='last', inplace=True) + # res.set_index('time', inplace=True, drop=False) + return symbol, res + + async def get_symbol_prices(self, symbol: str): + """""" + res = await super().copy_ticks_range(symbol, self.start, self.end, CopyTicks.ALL) + # res = pd.DataFrame(res) + # res.drop_duplicates(subset=['time'], keep='last', inplace=True) + # res.set_index('time', inplace=True, drop=False) + # res = res.reindex(self.span, method='nearest') + return symbol, res + + async def get_symbol_rates(self, symbol: str, timeframe: TimeFrame): + """""" + res = await super().copy_rates_range(symbol, timeframe, self.start, self.end) + # res = pd.DataFrame(res) + # res.drop_duplicates(subset=['time'], keep='last', inplace=True) + # res.set_index('time', inplace=True, drop=False) + return symbol, timeframe, 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 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 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/lib/backtester/test_data.py b/src/aiomql/lib/backtester/test_data.py new file mode 100644 index 0000000..2c4b15c --- /dev/null +++ b/src/aiomql/lib/backtester/test_data.py @@ -0,0 +1,23 @@ +from typing import Literal +from ...core.models import AccountInfo, SymbolInfo +from ...core.constants import TimeFrame +from ...core.meta_trader import MetaTrader +# from ...account import Account + + +class TestData: + + def __init__(self, data): + self._data = data + + def __getitem__(self, item: tuple[Literal['ticks', 'rates'], SymbolInfo, TimeFrame]): + type_, symbol, time_frame = item + if type_ == 'ticks': + return self._data[type_][symbol.name] + return self._data[type_][symbol.name][time_frame.name] + + + +# res = pd.DataFrame(res) +# res.drop_duplicates(subset=['time'], keep='last', inplace=True) +# res.set_index('time', inplace=True, drop=False) diff --git a/src/aiomql/lib/symbols/forex_symbol.py b/src/aiomql/lib/symbols/forex_symbol.py index e552cea..cfb0fcf 100644 --- a/src/aiomql/lib/symbols/forex_symbol.py +++ b/src/aiomql/lib/symbols/forex_symbol.py @@ -15,7 +15,7 @@ class ForexSymbol(Symbol): 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 = True, + 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. @@ -40,8 +40,8 @@ class ForexSymbol(Symbol): 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 = True) -> tuple[float, float]: + 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) diff --git a/src/aiomql/order.py b/src/aiomql/order.py index 2e3580a..ac7c77a 100644 --- a/src/aiomql/order.py +++ b/src/aiomql/order.py @@ -38,7 +38,7 @@ class Order(TradeRequest): """ return await self.mt5.orders_total() - async def get_order(self, *, ticket: int, retries: int = 3) -> TradeOrder: + async def get_order(self, *, ticket: int, retries: int = 3) -> TradeOrder | None: """ Get the order by ticket number. Args: @@ -47,16 +47,14 @@ class Order(TradeRequest): Returns: """ if retries < 1: - raise OrderError(f'Failed to get orders for {self.symbol}: {self.mt5.error}') + return None orders = await self.mt5.orders_get(ticket=ticket) - if orders is not None: - order = TradeOrder(**orders[0]._asdict()) - assert order.ticket == ticket, f'Order ticket mismatch {order.ticket} != {ticket}' - return order + 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) - raise OrderError(f'Failed to get orders for {self.symbol}: {self.mt5.error}') + return None async def get_orders(self, *, ticket: int = 0, symbol: str = '', group: str = '', retries=3)\ -> tuple[TradeOrder, ...]: @@ -69,7 +67,7 @@ class Order(TradeRequest): tuple[TradeOrder]: A Tuple of active trade orders as TradeOrder objects """ if retries < 1: - raise OrderError(f'Failed to get orders for {self.symbol}: {self.mt5.error}') + return tuple() symbol = getattr(self, 'symbol', symbol) orders = await self.mt5.orders_get(symbol=symbol, ticket=ticket, group=group) if orders is not None: @@ -78,9 +76,9 @@ class Order(TradeRequest): 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) - raise OrderError(f'Failed to get orders for {self.symbol}: {self.mt5.error}') + return tuple() - async def check(self) -> OrderCheckResult: + async def check(self, **kwargs) -> OrderCheckResult: """Check funds sufficiency for performing a required trading operation and the possibility of executing it. Returns: @@ -89,7 +87,8 @@ class Order(TradeRequest): Raises: OrderError: If not successful """ - res = await self.mt5.order_check(self.dict) + 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()) @@ -106,7 +105,15 @@ class Order(TradeRequest): 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}') - return OrderSendResult(**res._asdict()) + 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 _: + pass + return res async def calc_margin(self) -> float: """Return the required margin in the account currency to perform a specified trading operation. @@ -122,16 +129,17 @@ class Order(TradeRequest): raise OrderError(f'Failed to calculate margin for {self.symbol} due to {self.mt5.error.description}') return res - async def calc_profit(self) -> float: + async def calc_profit(self, **kwargs) -> float | None: """Return profit in the account currency for a specified trading operation. Returns: float: Returns float value if successful - - Raises: - OrderError: If not successful + None: If not successful """ - res = await self.mt5.order_calc_profit(self.type, self.symbol, self.volume, self.price, self.tp) - if res is None: - raise OrderError(f'Failed to calculate profit for {self.symbol} due to {self.mt5.error.description}') + include = {'tp', 'price', 'symbol', 'volume', 'type'} + args = self.get_dict(include=include) + args |= kwargs + if len(include.intersection(args.keys())) < len(include): + return None + res = await self.mt5.order_calc_profit(args['type'], args['symbol'], args['volume'], args['price'], args['tp']) return res diff --git a/src/aiomql/positions.py b/src/aiomql/positions.py index 5317a89..7b796da 100644 --- a/src/aiomql/positions.py +++ b/src/aiomql/positions.py @@ -68,7 +68,7 @@ class Positions: logger.warning(f'Failed to get positions for {symbol or self.symbol}. {self.mt5.error}') return [] - async def position_get(self, *, ticket: int) -> TradePosition: + async def position_get(self, *, ticket: int) -> TradePosition | None: """Get an open position by ticket. Args: ticket (int): Position ticket. @@ -78,9 +78,8 @@ class Positions: """ positions = await self.positions_get(ticket=ticket) position = positions[0] if positions else None - if position is None: - raise ValueError(f'Position with ticket {ticket} not found') - assert position.ticket == ticket, f'Position with ticket {ticket} not found' + if position is None or position.ticket != ticket: + return None return position async def close(self, *, ticket: int, symbol: str, price: float, volume: float, order_type: OrderType): diff --git a/src/aiomql/symbol.py b/src/aiomql/symbol.py index 5c8d2a1..e1fff22 100644 --- a/src/aiomql/symbol.py +++ b/src/aiomql/symbol.py @@ -174,15 +174,12 @@ class Symbol(SymbolInfo): within the limits of the symbol. The float is the volume to use if the volume is not within the limits of the symbol. """ - check = self.volume_min <= volume <= self.volume_max - if check: + if check := self.volume_min <= volume <= self.volume_max: return check, volume - if not check and volume < self.volume_min: - return check, self.volume_min else: - return check, self.volume_max + return check, self.volume_min if volume <= self.volume_min else self.volume_max - def round_off_volume(self, volume: float, round_down: bool = True) -> float: + def round_off_volume(self, volume: float, round_down: bool = False) -> float: """Round off the volume to the nearest volume step. Args: @@ -226,7 +223,7 @@ class Symbol(SymbolInfo): quote: The quote currency of the pair Returns: - float: Amount in terms of the base currency + float: Amount in terms of the quote currency Raises: ValueError: If conversion is impossible diff --git a/src/aiomql/trader.py b/src/aiomql/trader.py index 829351e..00183d8 100644 --- a/src/aiomql/trader.py +++ b/src/aiomql/trader.py @@ -11,7 +11,6 @@ from .ticks import Tick from .ram import RAM from .core.models import OrderType, OrderSendResult from .core.config import Config -from .utils import dict_to_string from .result import Result logger = getLogger(__name__) @@ -90,8 +89,7 @@ class Trader(ABC): """ check = await self.order.check() if check.retcode != 0: - req = check.request._asdict() | check.get_dict(include={'comment', 'retcode'}) - logger.warning(f"Invalid order for {self.symbol}: {dict_to_string(req)}") + logger.warning(f"Invalid order for {self.symbol} due to {check.comment}") return False return True @@ -99,12 +97,9 @@ class Trader(ABC): """Send the order to the broker.""" result = await self.order.send() if result.retcode != 10009: - req = result.request._asdict() | result.get_dict(include={'comment', 'retcode'}) - logger.warning(f"Unable to place order for {self.symbol}: {dict_to_string(req)}") + logger.warning(f"Unable to place order for {self.symbol} due to {result.comment}") return result - res = result.get_dict(exclude={'request', 'retcode_external', 'retcode', 'request_id'}) - logger.info(f"Placed Trade for {self.symbol}: {dict_to_string(res)}") - await self.record_trade(result, parameters=self.parameters.copy()) + 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): @@ -117,9 +112,9 @@ class Trader(ABC): """ if result.retcode != 10009 or not self.config.record_trades: return - params = parameters or self.parameters.copy() + params = parameters or self.parameters params = {k: v for k, v in params.items() if k not in (exclude or set())} - profit = await self.order.calc_profit() + profit = result.profit or await self.order.calc_profit() params["expected_profit"] = profit date = datetime.utcnow() date = date.replace(tzinfo=ZoneInfo("UTC")) diff --git a/src/aiomql/utils.py b/src/aiomql/utils.py index 9d3ef1d..29ebeb1 100644 --- a/src/aiomql/utils.py +++ b/src/aiomql/utils.py @@ -1,6 +1,10 @@ """Utility functions for aiomql.""" import decimal +import random +from functools import wraps, partial +import asyncio + from .candle import Candles, Candle @@ -18,7 +22,7 @@ def dict_to_string(data: dict, multi=False) -> str: return f"{sep}".join(f"{key}: {value}" for key, value in data.items()) -def round_off(value: float, step: float, round_down: bool = True) -> float: +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 @@ -35,3 +39,25 @@ 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] + + +def backoff_decorator(func=None, *, max_retries: int = 3, retries: int = 0, delay: int = 1, error=None) -> callable: + if func is None: + return partial(backoff_decorator, max_retries=max_retries, retries=retries, delay=delay, error=error) + + @wraps(func) + async def wrapper(*args, **kwargs): + nonlocal delay, retries + + try: + res = await func(*args, **kwargs) + if res == error: + raise Exception('Invalid return type') + return res + + except Exception as _: + await asyncio.sleep(delay * 2 ** retries + random.uniform(0, 1)) + delay += 1 + retries += 1 + return await wrapper(*args, **kwargs) + return wrapper