From f9f40be1da08021013c476f06cd16f67c5186dbe Mon Sep 17 00:00:00 2001 From: Ichinga Samuel Date: Mon, 2 Sep 2024 05:59:00 +0100 Subject: [PATCH] testdata --- Untitled.ipynb | 235 ++++++++++++++++-------- src/aiomql/account.py | 6 +- src/aiomql/core/backtester/check.py | 40 ++-- src/aiomql/core/backtester/get_data.py | 40 ++-- src/aiomql/core/backtester/test_data.py | 172 ++++++++++++++--- src/aiomql/core/config.py | 143 +++++++------- 6 files changed, 420 insertions(+), 216 deletions(-) diff --git a/Untitled.ipynb b/Untitled.ipynb index 565a53c..fbb3d4e 100644 --- a/Untitled.ipynb +++ b/Untitled.ipynb @@ -2,16 +2,11 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "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, TimeFrame, AccountInfo, TimeFrame, CopyTicks, Account, Symbol\n", "from MetaTrader5 import SymbolInfo\n", @@ -22,7 +17,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "id": "f2ef126c-6edc-4651-8a81-06bb97eed6f9", "metadata": {}, "outputs": [ @@ -41,21 +36,108 @@ }, { "cell_type": "code", - "execution_count": 3, - "id": "2c598a85-1e90-49b0-abf1-87329597feae", + "execution_count": 21, + "id": "113d9327-bebc-4e99-8562-e6dbef29b108", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.58\n", + "OrderCheckResult(retcode=0, balance=65.76, equity=63.35, profit=-2.41, margin=1.74, margin_free=61.61, margin_level=3640.8045977011498, comment='Done', request=TradeRequest(action=1, magic=0, order=0, symbol='Volatility 25 (1s) Index', volume=0.005, price=464167.9, stoplimit=0.0, sl=0.0, tp=0.0, deviation=0, type=0, type_filling=0, type_time=0, expiration=0, comment='', position=0, position_by=0))\n" + ] + }, + { + "data": { + "text/plain": [ + "OrderSendResult(retcode=10009, deal=3978355266, order=8068179971, volume=0.005, price=464076.06, bid=464042.01, ask=464076.06, comment='Request executed', request_id=943226517, retcode_external=0, request=TradeRequest(action=1, magic=0, order=0, symbol='Volatility 25 (1s) Index', volume=0.005, price=464167.9, stoplimit=0.0, sl=0.0, tp=0.0, deviation=0, type=0, type_filling=0, type_time=0, expiration=0, comment='', position=0, position_by=0))" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "sym = 'Volatility 25 (1s) Index'\n", + "# sym = 'EURUSD'\n", + "symb = Symbol(name=sym)\n", + "await symb.init()\n", + "op = symb.tick.ask + 100\n", + "cl = round((symb.trade_stops_level + symb.spread) * symb.point + symb.ask, symb.digits)\n", + "pr = await symb.mt5.order_calc_profit(action=0, symbol=sym, volume=symb.volume_min, price_open=op, price_close=cl)\n", + "rp = symb.volume_min * symb.trade_contract_size * (cl - op)\n", + "order = {'symbol': sym, 'price': op, 'volume': symb.volume_min, 'action': MetaTrader._TRADE_ACTION_A}\n", + "res = await symb.mt5.order_calc_margin(MetaTrader._ORDER_TYPE_BUY, sym, symb.volume_min, op)\n", + "print(res)\n", + "ocr = await symb.mt5.order_check(order)\n", + "print(ocr)\n", + "await symb.mt5.order_send(order)" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "f9189016-55fb-42d4-af77-e389767bc96c", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "True" + "(410, 7710.71, 0.01, 553.9999999999964)" ] }, - "execution_count": 3, + "execution_count": 22, "metadata": {}, "output_type": "execute_result" } ], + "source": [ + "symb.trade_stops_level, op, symb.point, abs(cl-op) / symb.point" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "73109885-8291-42cd-90a6-8a04f5f25466", + "metadata": {}, + "outputs": [], + "source": [ + "acc = Account()\n", + "await acc.refresh()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "dbb5e4f3-286d-4a1f-b59c-60a5af1dc94a", + "metadata": {}, + "outputs": [ + { + "ename": "AttributeError", + "evalue": "readonly attribute", + "output_type": "error", + "traceback": [ + "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[1;31mAttributeError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[1;32mIn[16], line 2\u001b[0m\n\u001b[0;32m 1\u001b[0m \u001b[38;5;66;03m# acc = await acc.mt5.account_info()\u001b[39;00m\n\u001b[1;32m----> 2\u001b[0m \u001b[43macc\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mmargin\u001b[49m \u001b[38;5;241m+\u001b[39m\u001b[38;5;241m=\u001b[39m \u001b[38;5;241m9\u001b[39m\n\u001b[0;32m 3\u001b[0m \u001b[38;5;66;03m# acc._replace(margin=0)\u001b[39;00m\n", + "\u001b[1;31mAttributeError\u001b[0m: readonly attribute" + ] + } + ], + "source": [ + "# acc = await acc.mt5.account_info()\n", + "acc.margin += 9\n", + "# acc._replace(margin=0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2c598a85-1e90-49b0-abf1-87329597feae", + "metadata": {}, + "outputs": [], "source": [ "sym = Symbol(name='Volatility 25 Index')\n", "await sym.init()" @@ -63,42 +145,20 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "0a7d319c-5760-4058-994f-27e8d10df108", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0.5" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "await sym.mt5.order_calc_margin(0, 'Volatility 25 Index', 1, sym.tick.ask)" ] }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "4ec498ea-5e4e-4ff6-85da-051acc2eaf28", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "4" - ] - }, - "execution_count": 17, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "sy = await sym.mt5.symbol_info('Volatility 25 Index')\n", "await sym.mt5.symbol_select('Volatility 25 Index', enable=True)\n", @@ -107,21 +167,10 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "9307e3df-a5f3-42cf-9d3d-e65e5254c0e5", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0.50097375" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# margin\n", "# worked for forex.\n", @@ -432,26 +481,75 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 70, "id": "e47bd638-42fa-40d9-b573-474a39884dbc", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + " time open high low close tick_volume spread \\\n", + "0 1724859360 6770.27 6775.26 6765.13 6765.13 60 144 \n", + "1 1724859420 6766.28 6769.70 6761.25 6761.35 60 144 \n", + "2 1724859480 6762.58 6777.91 6762.58 6776.98 58 144 \n", + "3 1724859540 6778.78 6784.64 6771.89 6782.54 60 144 \n", + "4 1724859600 6781.56 6787.20 6779.02 6784.20 60 144 \n", + ".. ... ... ... ... ... ... ... \n", + "495 1724889060 6622.30 6626.43 6620.74 6620.76 60 144 \n", + "496 1724889120 6620.70 6623.06 6611.94 6623.06 60 144 \n", + "497 1724889180 6623.13 6625.32 6618.51 6623.52 60 144 \n", + "498 1724889240 6622.50 6627.44 6621.98 6626.63 60 144 \n", + "499 1724889300 6626.77 6626.77 6626.48 6626.48 2 144 \n", + "\n", + " real_volume \n", + "0 0 \n", + "1 0 \n", + "2 0 \n", + "3 0 \n", + "4 0 \n", + ".. ... \n", + "495 0 \n", + "496 0 \n", + "497 0 \n", + "498 0 \n", + "499 0 \n", + "\n", + "[500 rows x 8 columns]" + ] + }, + "execution_count": 70, + "metadata": {}, + "output_type": "execute_result" + } + ], "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)" + "await symb.copy_rates_from_pos(timeframe=TimeFrame.M1)\n" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 72, "id": "a4bc7b28-6dac-4863-b257-64d2c5c62575", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "Empty DataFrame\n", + "Columns: [time, bid, ask, last, volume, time_msc, flags, volume_real]\n", + "Index: []" + ] + }, + "execution_count": 72, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "res = pd.DataFrame(res)\n", - "len(res.index)" + "tz = pytz.timezone('Etc/UTC')\n", + "now = datetime.now(tz=tz)\n", + "# now.replace(tz=tz-tz)\n", + "await symb.copy_ticks_from(date_from=now)" ] }, { @@ -591,21 +689,10 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "fe60fc41-8844-4e00-b254-bb95d28528f2", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "1.2" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "6 / (0 or 5)" ] @@ -635,7 +722,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.4" + "version": "3.11.6" } }, "nbformat": 4, diff --git a/src/aiomql/account.py b/src/aiomql/account.py index 93a06a9..47a294f 100644 --- a/src/aiomql/account.py +++ b/src/aiomql/account.py @@ -29,10 +29,8 @@ class Account(AccountInfo): 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.exclude = self.exclude | {'_instance', 'symbols'} + acc = {k: (self.dict[k] or v) for k, v in self.config.account_info().items()} self.set_attributes(**acc) async def refresh(self): diff --git a/src/aiomql/core/backtester/check.py b/src/aiomql/core/backtester/check.py index 5bcd9c8..868e5a3 100644 --- a/src/aiomql/core/backtester/check.py +++ b/src/aiomql/core/backtester/check.py @@ -1,21 +1,23 @@ -from collections import namedtuple -class ITR: +import socket - def __init__(self) -> None: - self.span = iter(range(0, 10)) - self.start = 0 - - def __next__(self): - self.start = next(self.span) - return self.start - -Gender = namedtuple('Gender', ['man', 'woman']) -gen = Gender(man='Manny', woman='Babe') -gend = gen._asdict() -genz = Gender(gend) -print(gen, genz) -# b = ITR() -# print(next(b)) -# print(next(b)) -# print(next(b)) \ No newline at end of file +class socketserver: + def __init__(self, address = '192.168.1.15', port = 9090): + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.address = address + self.port = port + self.sock.bind((self.address, self.port)) + self.cummdata = '' + + + def recvmsg(self): + g=self.sock.listen(1) + print(g) + self.conn, self.addr = self.sock.accept() + print('connected to', self.addr) + data = self.conn.recv(10) + self.cummdata += data.decode("utf-8") + + +so = socketserver() +so.recvmsg() diff --git a/src/aiomql/core/backtester/get_data.py b/src/aiomql/core/backtester/get_data.py index 39fa3eb..34a9372 100644 --- a/src/aiomql/core/backtester/get_data.py +++ b/src/aiomql/core/backtester/get_data.py @@ -1,4 +1,4 @@ -from typing import TypedDict +from dataclasses import dataclass import pickle import lzma from datetime import datetime @@ -16,24 +16,36 @@ from ...core.constants import TimeFrame, CopyTicks from ...utils import backoff_decorator logger = getLogger(__name__) +from MetaTrader5 import TradePosition, TradeOrder, TradeDeal + +tof = list(TradeOrder._fields) +tof.append('symbol') +tpf = list(TradePosition._fields) +tpf.append('symbol') +tdf = list(TradeDeal._fields) +tdf.append('symbol') -class Data(TypedDict): +@dataclass +class Data: account: dict symbols: dict[str, dict] prices: dict[str, DataFrame] ticks: dict[str, DataFrame] rates: dict[str, dict[str, DataFrame]] - interval: range + span: range + range: range + history_orders: DataFrame = DataFrame([], columns=tof) + history_deals: DataFrame = DataFrame([], columns=tdf) + positions: DataFrame = DataFrame([], columns=tpf) class GetData: - config: Config = Config() def __init__(self, *, start: datetime, end: datetime, timeframes: set[TimeFrame], symbols: set[str], name: str = '', tz: str = 'Etc/UTC'): """""" - super().__init__() + self.config = Config() self.tz = pytz.timezone(tz) self.start = start.replace(tzinfo=self.tz) self.end = end.replace(tzinfo=self.tz) @@ -41,24 +53,18 @@ class GetData: self.timeframes = timeframes self.name = name or f"{start:%d-%m-%y}_{end:%d-%m-%y}" diff = int((self.end - self.start).total_seconds()) - self.interval = range(start := int(self.start.timestamp()), diff + start) + self.range = range(diff) + self.span = range(start := int(self.start.timestamp()), diff + start) self.mt5 = MetaTrader() async def get_data(self) -> Data: """""" - data = {} + rates, ticks, prices, symbols, account = await asyncio.gather(self.get_symbols_rates(), self.get_symbols_ticks(), self.get_symbols_prices(), self.get_symbols_info(), self.get_account_info()) - - data['rates'] = rates - data['ticks'] = ticks - data['prices'] = prices - data['symbols'] = symbols - data['account'] = account - data['range'] = self.interval - - return Data(**data) + return Data(account=account, symbols=symbols, prices=prices, ticks=ticks, rates=rates, + span=self.span, range=self.range) async def pickle_data(self) -> None: """""" @@ -145,7 +151,7 @@ class GetData: 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.interval, method='nearest') + res = res.reindex(self.span, method='nearest') return symbol, res @backoff_decorator(max_retries=5) diff --git a/src/aiomql/core/backtester/test_data.py b/src/aiomql/core/backtester/test_data.py index bab9ddf..e037ac0 100644 --- a/src/aiomql/core/backtester/test_data.py +++ b/src/aiomql/core/backtester/test_data.py @@ -1,19 +1,22 @@ -from datetime import datetime, tzinfo +from collections import namedtuple +from datetime import datetime from typing import Literal +from itertools import zip_longest +import random import pytz import numpy as np -import pandas as pd from pandas import DataFrame from MetaTrader5 import (Tick, SymbolInfo, AccountInfo, TradeOrder, TradePosition, TradeDeal, - ORDER_TYPE_BUY, ORDER_TYPE_SELL, TradeRequest) -import MetaTrader5 as mt5 + ORDER_TYPE_BUY, ORDER_TYPE_SELL, TradeRequest, OrderCheckResult, OrderSendResult, + ACCOUNT_STOPOUT_MODE_PERCENT) from ..meta_trader import MetaTrader -from ..constants import TimeFrame, CopyTicks, OrderType -from .get_data import Data, GetData +from ..constants import TimeFrame, CopyTicks, OrderType, TradeAction +from .get_data import Data from ...utils import round_down, round_up tz = pytz.timezone('Etc/UTC') +Cursor = namedtuple('Cursor', ['index', 'time']) class TestData: @@ -22,27 +25,33 @@ class TestData: def __init__(self, data: Data): self._data = data - self.account = AccountInfo(**data['account']) - self.symbols = {symbol: SymbolInfo(**info) for symbol, info in data['symbols'].items()} - self.prices = data['prices'] - self.ticks = data['ticks'] - self.rates = data['rates'] - self.interval = data['interval'] - self.cursor = 0 - self.iter = iter(self.interval) + self.account = AccountInfo(**data.account) + self.symbols = {symbol: SymbolInfo(**info) for symbol, info in data.symbols.items()} + self.prices = data.prices + self.ticks = data.ticks + self.rates = data.rates + self.span = data.span + self.range = data.range + self.cursor = Cursor(index=self.range[0], time=self.span[0]) + self.iter = zip_longest(self.range, self.span) self.orders: dict[str, dict[int, TradeOrder]] = {} self.open_orders: dict[int, TradeOrder] = {} self.positions: dict[str, dict[int, TradePosition]] = {} self.open_positions: dict[int, TradePosition] = {} - self.mt = MetaTrader() + self.history_orders = data.history_orders + self.history_deals = data.history_deals + self.margins: dict[int, float] = {} + self.mt5 = MetaTrader() def __next__(self): - self.cursor = next(self.iter) + index, time = next(self.iter) + self.cursor = Cursor(index=index, time=time) return self.cursor def reset(self): - self.iter = iter(self.interval) - return self.iter + self.iter = zip_longest(self.range, self.span) + self.cursor = Cursor(index=self.range[0], time=self.span[0]) + return self.cursor def get_symbols_total(self) -> int: return len(self.symbols) @@ -54,7 +63,7 @@ class TestData: return AccountInfo(**self.account._asdict()) def get_symbol_info_tick(self, symbol: str) -> Tick: - tick = self.prices[symbol].iloc[self.cursor] + tick = self.prices[symbol].iloc[self.cursor.index] return Tick(**tick) def get_symbol_info(self, symbol: str) -> SymbolInfo: @@ -108,25 +117,136 @@ class TestData: async def order_calc_margin(self, action: Literal[OrderType.BUY, OrderType.SELL], symbol: str, volume: float, price: float, use_terminal=False): - if use_terminal or self.mt.config.use_terminal: - return await self.mt.order_calc_margin(OrderType(action), symbol, volume, price) + if use_terminal and self.mt5.config.use_terminal_for_backtesting: + return await self.mt5.order_calc_margin(OrderType(action), symbol, volume, price) sym = self.symbols[symbol] margin = (volume * sym.trade_contract_size * price) / (self.account.leverage / (sym.margin_initial or 1)) return margin async def order_calc_profit(self, action: Literal[OrderType.BUY, OrderType.SELL], symbol: str, volume: float, - price_open: float, price_close: float, use_terminal=False): - if use_terminal or self.mt.config.use_terminal: - return await self.mt.order_calc_profit(action, symbol, volume, price_open, price_close) + price_open: float, price_close: float, use_terminal=True): + if use_terminal and self.mt5.config.use_terminal_for_backtesting: + return await self.mt5.order_calc_profit(action, symbol, volume, price_open, price_close) sym = self.symbols[symbol] profit = volume * sym.trade_contract_size * (price_close - price_open) return profit - def order_send(self, request: dict) -> dict: + def check_order(self, order: TradeOrder) -> bool: ... - def order_check(self, request: dict) -> dict: + def check_position(self, position: TradePosition) -> bool: ... + + def close_position(self, position: TradePosition): + profit = position.profit + self.open_orders.pop(position.ticket) + self.open_positions.pop(position.ticket) + margin = self.margins.pop(position.ticket) + self.update_account(profit, margin=margin) + + def modify_stops(self, ticket: int, sl: int = None, tp: int = None): + ... + + def update_account(self, profit: float, margin: float = 0): + self.account.balance += profit + self.account.equity += profit + self.account.margin -= margin + self.account.margin_free = self.account.equity - self.account.margin + self.account.margin_level = (self.account.equity / self.account.margin) * 100 + + async def order_send(self, request: dict, use_terminal: bool = True) -> OrderSendResult: + osr = {'retcode': 10009, 'comment': 'Request completed', 'request': TradeRequest(**request)} + + if (position := request.get('position')) in self.open_positions: + pos = self.open_positions[position] + order_type = OrderType(request['type']) + pos_type = OrderType(pos.type) + if order_type.opposite == pos_type: # ToDo: is there another way to check if the order is a close order? + # close position + self.close_position(pos) + return OrderSendResult(**osr) # ToDo: Create a deal object here + action = request['action'] + if action == TradeAction.SLTP: + self.modify_stops(position, request['sl'], request['tp']) + return OrderSendResult(**osr) + + if (action := request.get('action')) == TradeAction.DEAL: + ocr = await self.order_check(request, use_terminal=use_terminal) + if ocr.retcode != 0: + osr.update({'comment': ocr.comment, 'retcode': ocr.retcode}) + return OrderSendResult(**osr) + + ticket = random.randint(100_000_000, 999_999_999) + deal_ticket = random.randint(100_000_000, 999_999_999) + tick = self.get_symbol_info_tick(request['symbol']) + order_type = request['type'] + price = tick.ask if request['type'] == ORDER_TYPE_BUY else tick.bid + volume = request['volume'] + sl, tp = request.get('sl', 0), request.get('tp', 0) + symbol = request['symbol'] + pos = {'comment': 'open position', 'ticket': ticket, 'symbol': symbol, 'volume': volume, + 'price_open': price, 'price_current': price, 'type': order_type, 'profit': 0, + 'sl': sl, 'tp': tp, 'time': tick.time, + 'time_msc': tick.time_msc} + order = {'ticket': ticket, 'symbol': symbol, 'volume': volume, 'price': price, 'price_current': price, + 'price_open': price, 'type': order_type, 'time_setup': tick.time, + 'time_setup_msc': tick.time_msc, 'volume_current': volume, 'sl': sl, 'tp': tp,} + pos = TradePosition(**pos) + order = TradeOrder(**order) + # ToDo: Create a deal object here + self.open_positions[pos.ticket] = pos + self.open_orders[order.ticket] = order + self.orders.setdefault(order.symbol, {})[order.ticket] = order + self.positions.setdefault(pos.symbol, {})[pos.ticket] = pos + osr.update({'order': ticket, 'price': price, 'volume': volume, 'bid': tick.bid, + 'ask': tick.ask, 'deal': deal_ticket}) + margin = await self.order_calc_margin(action, symbol, volume, price) + self.margins[ticket] = margin + return OrderSendResult(**osr) + + async def order_check(self, request: dict, use_terminal=True) -> OrderCheckResult: + action, symbol, volume = request.get('action'), request.get('symbol'), request.get('volume') + price = request.get('price') + ocr = {'retcode': 0, 'balance': 0, 'profit': 0, 'margin': 0, 'equity': 0, 'margin_free': 0, + 'margin_level': 0, 'comment': 'Done', request: TradeRequest(**request)} + + margin = 0 + if all([action, symbol, volume, price]): + margin = await self.order_calc_margin(action, symbol, volume, price) + + acc = self.get_account_info() + equity = acc.equity + used_margin = acc.margin + margin + free_margin = acc.margin_free - margin + margin_level = (equity / used_margin) * 100 + + if use_terminal and self.mt5.config.use_terminal_for_backtesting: + ocr_t = await self.mt5.order_check(request) + # return order check result if invalid stops level are detected or bad request + if ocr_t.retcode in (10016, 10013, 10014): + return ocr_t + else: + sym = self.symbols[symbol] + tsl = sym.trade_stops_level + sl, tp = request.get('sl', 0), request.get('tp', 0) + + if tp or sl: + min_sl = min(sl, tp) + dsl = abs(price - min_sl) / sym.point + if dsl < tsl: + ocr['retcode'] = 10016 + ocr['comment'] = 'Invalid stops' + return OrderCheckResult(**ocr) + + level = margin_level if acc.margin_mode == ACCOUNT_STOPOUT_MODE_PERCENT else free_margin + if level < acc.margin_so_call or free_margin <= 0: + ocr['retcode'] = 10019 + ocr['comment'] = 'No money' + + ocr.update({'balance': acc.balance, 'profit': acc.profit, 'margin': used_margin, 'equity': equity, + 'margin_free': free_margin, 'margin_level': margin_level}) + + return OrderCheckResult(**ocr) def get_orders_total(self) -> int: return len(self.open_orders) diff --git a/src/aiomql/core/config.py b/src/aiomql/core/config.py index 7e4d5b3..5e4d2dd 100644 --- a/src/aiomql/core/config.py +++ b/src/aiomql/core/config.py @@ -23,54 +23,49 @@ class Config: 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 + root (str): 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 = {} + login: int + trade_record_mode: Literal['csv', 'json'] + password: str + server: str + path: str | Path + timeout: int + filename: str + state: dict root: Path - root_dir: Path + record_trades: bool records_dir: Path - config_dir: str = '' - task_queue: TaskQueue = TaskQueue() - bot: Bot = None + records_dir_name: str + test_data_dir: Path + test_data_dir_name: str + task_queue: TaskQueue + bot: Bot _instance: 'Config' - mode: Literal['backtest', 'live'] = 'live' - test_data_dir: str = 'test_data' - use_terminal: bool = False + mode: Literal['backtest', 'live'] + use_terminal_for_backtesting: bool + _defaults = {"timeout": 60000, "record_trades": True, "trade_record_mode": "csv", "mode": "live", + 'filename': "aiomql.json", "records_dir_name": "trade_records", "test_data_dir_name": "test_data", + "use_terminal_for_backtesting": True, 'path': '', 'login': 0, 'password': '', 'server': ''} def __new__(cls, *args, **kwargs): if not hasattr(cls, "_instance"): cls._instance = super().__new__(cls) + cls._instance.state = {} + cls._instance.task_queue = TaskQueue() + cls._instance.set_attributes(**cls._defaults) + cls._instance.load_config(**kwargs) 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) + self.set_attributes(**kwargs) def set_attributes(self, **kwargs): """Set keyword arguments as object attributes @@ -95,10 +90,9 @@ class Config: 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): + def find_config_file(self): try: - path = self.root_dir / self.config_dir - for dirname in self.walk_to_root(path): + for dirname in self.walk_to_root(self.root): check_path = os.path.join(dirname, self.filename) if os.path.isfile(check_path): return check_path @@ -106,54 +100,51 @@ class Config: 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 'trade_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): + def load_config(self, *, file: str | Path = None, filename: str = None, root: str | Path = None, **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: + Keyword Args: + file (str | Path): The absolute path to the config file. + filename (str): The name of the file to load if file path is not specified. If not provided aiomql.json is used + root (str): The root directory of the project. + kwargs: Additional keyword arguments to set as object attributes. + """ + if root is not None: + root = Path(root).resolve() + root.mkdir(parents=True, exist_ok=True) if not root.exists() else ... + self.root = root + else: + self.root = self.root if hasattr(self, 'root') else Path.cwd() + + if file is not None: + file = Path(file).resolve() + if not file.exists(): + self.filename = filename or self.filename + file = self.find_config_file() + else: + self.filename = file.name + else: + self.filename = filename or self.filename + file = self.find_config_file() + + if file is None: logger.warning("No Config File Found") + file_config = {} else: fh = open(file, mode="r") - data = json.load(fh) + file_config = json.load(fh) fh.close() - data |= kwargs + + data = file_config | kwargs self.set_attributes(**data) - self._initialize = False + + if self.record_trades and not hasattr(self, "records_dir"): + self.records_dir = self.root / self.records_dir_name + self.records_dir.mkdir(parents=True, exist_ok=True) + + if self.mode == "backtest" and not hasattr(self, "test_data_dir"): + self.test_data_dir = self.root / self.test_data_dir_name + self.test_data_dir.mkdir(parents=True, exist_ok=True) def account_info(self) -> dict[str, int | str]: """Returns Account login details as found in the config object if available @@ -161,4 +152,4 @@ class Config: Returns: dict: A dictionary of login details """ - return {"login": self.login, "password": self.password, "server": self.server} + return {'login': self.login, 'password': self.password, 'server': self.server}