This commit is contained in:
Ichinga Samuel
2024-10-02 00:25:24 +01:00
parent 81bdedcdaa
commit e34d96f064
20 changed files with 312 additions and 475 deletions
+7 -73
View File
@@ -1,7 +1,7 @@
import asyncio
from logging import getLogger
from typing import Self
from .core.models import AccountInfo, SymbolInfo
from .core.models import AccountInfo
from .core.exceptions import LoginError
logger = getLogger(__name__)
@@ -13,33 +13,26 @@ class Account(AccountInfo):
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'
_instance: Self
connected: bool
symbols = set()
def __new__(cls, *args, **kwargs):
if not hasattr(cls, '_instance'):
cls._instance = super().__new__(cls)
cls._instance.exclude = cls._instance.exclude | {'_instance'}
return cls._instance
def __init__(self, **kwargs):
super().__init__(**kwargs)
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):
"""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':
async def __aenter__(self) -> Self:
"""Connect to a trading account and return the account instance.
Async context manager for the Account class.
@@ -49,70 +42,11 @@ class Account(AccountInfo):
Raises:
LoginError: If login fails
"""
res = await self.sign_in()
if not res:
self.connected = await self.mt5.login()
if not self:
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, **kwargs) -> 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, **kwargs)
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, **kwargs) -> bool:
res = False
if tries == 0:
return False
init_args = {**acc} | {'path': self.config.path} | {**kwargs}
ini = await self.mt5.initialize(**init_args)
if ini:
res = await self.mt5.login(**acc)
if not res:
await self.mt5.shutdown()
if ini and res:
return True
else:
await asyncio.sleep(5+tries)
return await self._login(acc=acc, tries=tries-1, **kwargs)
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}
+31 -44
View File
@@ -1,38 +1,34 @@
import asyncio
from concurrent.futures import ProcessPoolExecutor
from typing import Type, Iterable, TypeVar, Callable, Coroutine
from typing import Type, Iterable, Callable, Coroutine
import logging
from .executor import Executor
from .account import Account
from .core.config import Config
from .core.meta_trader import MetaTrader
from .symbol import Symbol as Symbol
from .strategy import Strategy as Strategy
logger = logging.getLogger(__name__)
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
mt (MetaTrader): MetaTrader instance
"""
config: Config
account: Account
symbols: set
executor: Executor
mt: MetaTrader
def __init__(self):
self.config = Config()
self.account = Account()
self.symbols = set()
self.config = Config(bot=self)
self.executor = Executor()
self.mt = MetaTrader()
@classmethod
def run_bots(cls, funcs: dict[Callable: dict] = None, num_workers: int = None):
@@ -55,20 +51,18 @@ class Bot:
SystemExit if sign in was not successful
"""
try:
init = await self.account.sign_in()
if not init:
login = await self.mt.login()
if not login:
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)
await self.init_strategies()
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):
def add_function(self, func: Callable[..., ...], **kwargs: dict):
"""Add a function to the executor.
Args:
@@ -77,7 +71,7 @@ class Bot:
"""
self.executor.add_function(func, kwargs)
def add_coroutine(self, coro: Coroutine | Callable, **kwargs):
def add_coroutine(self, coro: Coroutine[..., ...], **kwargs):
"""Add a coroutine to the executor.
Args:
@@ -117,39 +111,32 @@ class Bot:
"""
[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
def add_strategy_all(self, *, strategy: Type[Strategy], params: dict | None = None,
symbols: list[Symbol] = None, **kwargs):
"""Use this to run a single strategy on multiple symbols with the same parameters and keyword arguments.
Keyword Args:
strategy (Strategy): Strategy class
params (dict): A dictionary of parameters for the strategy
symbols (list): A list of symbols to run the strategy on
**kwargs: Additional keyword arguments for the strategy
"""
[
self.add_strategy(strategy(symbol=symbol, params=params))
for symbol in self.symbols
self.add_strategy(strategy(symbol=symbol, params=params, **kwargs))
for symbol in symbols
]
async def init_symbols(self):
@staticmethod
async def init_strategy(strategy: Strategy) -> tuple[bool, Strategy]:
"""Initialize a single strategy. This method is called internally by the bot."""
res = await strategy.symbol.init()
return res, strategy
async def init_strategies(self):
"""Initialize the symbols for the current trading session. This method is called internally by the bot."""
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")
tasks = [self.init_strategy(strategy) for strategy in self.executor.workers]
for task in asyncio.as_completed(tasks):
res = await task
if not res[0]:
logger.warning(f"Failed to initialize symbol {res[1].symbol}")
self.executor.workers.remove(res[1])
@@ -391,7 +391,7 @@ class BackTestEngine:
'sl': sl, 'tp': tp, 'time': current_tick.time, 'time_msc': current_tick.time_msc,
'time_update': current_tick.time, 'time_update_msc': current_tick.time_msc}
deal = {'ticket': deal_ticket, 'position': order_ticket, 'symbol': symbol, 'commission': 0, 'swap': 0,
deal = {'ticket': deal_ticket, 'order': order_ticket, 'symbol': symbol, 'commission': 0, 'swap': 0,
'position_id': order_ticket, 'fee': 0, 'time': current_tick.time, 'time_msc': current_tick.time_msc,
'volume': volume, 'price': price, 'type': DealType(order_type), 'reason': DealReason.EXPERT,
'entry': DealEntry.IN}
@@ -6,7 +6,6 @@ from .event_manager import EventManager
from .meta_tester import MetaTester
from .backtest_engine import BackTestEngine
from .strategy_tester import StrategyTester
from ...core import Config
logger = getLogger(__name__)
@@ -22,7 +21,6 @@ class BackTester:
try:
await self.mt5.initialize()
strategies = [strategy for strategy in self.strategies if await strategy.symbol.init()]
print(f"Number of strategies: {len(strategies)}")
self.event_manager.num_main_tasks = len(strategies)
tasks = [*[asyncio.create_task(strategy.test()) for strategy in strategies],
asyncio.create_task(self.event_manager.event_monitor())]
+4 -20
View File
@@ -1,21 +1,5 @@
glob = dict()
class Check:
def __init__(self, ty):
self.r = ty
@property
def r(self):
print('getting value')
return glob.get('r')
@r.setter
def r(self, value):
print('setting value')
glob['r'] = value
f = Check(465)
print(f.r)
f.r = 56
print(f.r)
class Check1:
f: str
t: str
v: float
+1 -1
View File
@@ -1,10 +1,10 @@
from typing import Callable
import MetaTrader5
from MetaTrader5 import (Tick, SymbolInfo, AccountInfo, TerminalInfo, TradeOrder, TradePosition, TradeDeal,
OrderCheckResult, OrderSendResult, BookInfo, TradeRequest)
from .config import Config
constants = ('TIMEFRAME_M1', 'TIMEFRAME_M2', 'TIMEFRAME_M3', 'TIMEFRAME_M4', 'TIMEFRAME_M5', 'TIMEFRAME_M6',
'TIMEFRAME_M10', 'TIMEFRAME_M12', 'TIMEFRAME_M15', 'TIMEFRAME_M20', 'TIMEFRAME_M30', 'TIMEFRAME_H1',
+2 -2
View File
@@ -1,6 +1,6 @@
import os
from pathlib import Path
from typing import Iterator, Literal, TypeVar
from typing import Iterator, Literal, TypeVar, Self
import json
from logging import getLogger
@@ -51,7 +51,7 @@ class Config:
task_queue: TaskQueue
_backtest_engine: BackTestEngine
bot: Bot
_instance: 'Config'
_instance: Self
mode: Literal['backtest', 'live']
use_terminal_for_backtesting: bool
_defaults = {"timeout": 60000, "record_trades": True, "trade_record_mode": "csv", "mode": "live",
+1 -5
View File
@@ -19,7 +19,6 @@ class AccountInfo(Base):
Attributes:
login: int
password: str
server: str
trade_mode: AccountTradeMode
balance: float
@@ -51,7 +50,6 @@ class AccountInfo(Base):
company: str
"""
login: int = 0
password: str = ''
server: str = ''
trade_mode: AccountTradeMode
balance: float
@@ -334,9 +332,7 @@ class SymbolInfo(Base):
path: str
def __init__(self, **kwargs):
if (name := kwargs.pop('name', '')) == '':
raise AttributeError('Symbol Object Must be initialized with a name')
self.name = name
assert 'name' in kwargs, "Symbol Object Must be initialized with a name"
super().__init__(**kwargs)
def __repr__(self):
-4
View File
@@ -38,10 +38,6 @@ class Executor:
"""
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: Strategy):
"""Add a strategy instance to the list of workers
+34 -103
View File
@@ -1,15 +1,15 @@
import asyncio
from datetime import datetime
from logging import getLogger
import pytz
from pandas import DataFrame
import pandas as pd
from .core.config import Config
from .core.meta_trader import MetaTrader, CopyTicks, OrderType
from .core.models import TradeDeal, TradeOrder
from .contrib.backtester.meta_tester import MetaTester
from .utils import backoff_decorator
logger = getLogger(__name__)
@@ -24,71 +24,56 @@ class History:
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 | MetaTester
config: Config
def __init__(self, *, date_from: datetime | int = None, date_to: datetime | int = None,
group: str = "", ticket: int = None, position: int = None):
def __init__(self, *, date_from: datetime | int, date_to: datetime | int, group: str = '', use_utc: bool = True):
"""
Args:
date_from (datetime, float): Date the orders are requested from. Set by the 'datetime' object or as a
date_from (datetime, int): 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
date_to (datetime, int): 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
group (str): Filter for selecting history by symbols. Defaults to an empty string
"""
self.config = Config()
self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester()
self.date_from = date_from
self.date_to = date_to
date_from = date_from if isinstance(date_from, datetime) else datetime.fromtimestamp(date_from)
date_to = date_to if isinstance(date_to, datetime) else datetime.fromtimestamp(date_to)
self.date_from = date_from.astimezone(pytz.utc) if use_utc else date_from
self.date_to = date_to.astimezone(pytz.utc) if use_utc else date_to
self.group = group
self.ticket = ticket
self.position = position
self.deals: list[TradeDeal] = []
self.orders: list[TradeOrder] = []
self.deals: tuple[TradeDeal, ...] = ()
self.orders: tuple[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()
async def init(self):
"""Get history deals and orders"""
deals, orders = await asyncio.gather(self.get_deals(), self.get_orders(), return_exceptions=True)
self.deals = deals if isinstance(deals, tuple) else ()
self.orders = orders if isinstance(orders, tuple) else ()
self.total_deals = len(self.deals)
self.total_orders = len(self.orders)
@backoff_decorator
async def get_deals(self, *, date_from: datetime | int = None, date_to: datetime | int = None, group: str = '')\
-> tuple[TradeDeal, ...]:
async def get_deals(self) -> tuple[TradeDeal, ...]:
"""Get deals from trading history using the parameters set in the constructor.
Returns:
tuple[TradeDeal]: A list of trade deals
"""
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)
deals = await self.mt5.history_deals_get(date_from=self.date_from, date_to=self.date_to, group=self.group)
if deals is not None:
return tuple(TradeDeal(**deal._asdict()) for deal in deals)
logger.warning(f'Failed to get deals')
return tuple()
@backoff_decorator
async def get_deals_ticket(self, *, ticket: int = None) -> tuple[TradeDeal, ...]:
def get_deals_by_ticket(self, *, ticket: int) -> tuple[TradeDeal, ...]:
"""Call specifying the order ticket. Return all deals having the specified order ticket in the DEAL_ORDER
property.
@@ -98,13 +83,9 @@ class History:
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))
return tuple(sorted((deal for deal in self.deals if deal.order == ticket), key=lambda x: x.time_msc))
@backoff_decorator
async def get_deals_position(self, *, position: int = None) -> tuple[TradeDeal, ...]:
def get_deals_by_position(self, *, position: int = None) -> tuple[TradeDeal, ...]:
"""
Get all deals with the specified position ticket in the DEAL_POSITION_ID property
Args:
@@ -113,36 +94,16 @@ class History:
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
return tuple(sorted((deal for deal in self.deals if deal.position_id == position), key=lambda x: x.time_msc))
@backoff_decorator
async def get_orders(self, *, date_from: datetime | int = None, date_to: datetime | int = None, group: str = '',
retries: int = 3) -> tuple[TradeOrder, ...]:
async def get_orders(self) -> 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
"""
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)
orders = await self.mt5.history_orders_get(date_from=self.date_from, date_to=self.date_to, group=self.group)
if orders is not None:
return tuple(TradeOrder(**order._asdict()) for order in orders)
@@ -150,45 +111,15 @@ class History:
logger.warning(f'Failed to get orders')
return tuple()
@backoff_decorator
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)
def get_orders_by_ticket(self, ticket: int) -> tuple[TradeOrder, ...]:
"""filter orders by ticket"""
return tuple(sorted((order for order in self.orders if order.ticket == ticket), key=lambda x: x.time_done_msc))
if orders and (order := orders[0]).ticket == ticket:
return TradeOrder(**order._asdict())
return None
@backoff_decorator
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))
@backoff_decorator
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
def get_orders_by_position(self, position: int) -> tuple[TradeOrder, ...]:
""" filter orders by position"""
return tuple(sorted((order for order in self.orders if order.position_id == position),
key=lambda x: x.time_done_msc))
async def track_order(self, *, position: int = None, end_time: datetime = None) -> DataFrame:
"""
@@ -206,8 +137,8 @@ class History:
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)
orders = self.get_orders_by_position(position=position)
deals = self.get_deals_by_position(position=position)
open_order = orders[0]
open_deal = deals[0]
close_deal = deals[-1]
+22 -41
View File
@@ -1,9 +1,10 @@
from logging import getLogger
from .core.models import TradeRequest, OrderSendResult, OrderCheckResult, TradeOrder
from .core.models import TradeRequest, TradeOrder
from .core.constants import TradeAction, OrderTime, OrderFilling
from .core.exceptions import OrderError
from .utils import backoff_decorator
from .utils import backoff_decorator, error_handler
from MetaTrader5 import OrderCheckResult, OrderSendResult
logger = getLogger(__name__)
@@ -24,25 +25,20 @@ class Order(TradeRequest):
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)
kwargs = {'action': TradeAction.DEAL, OrderTime.DAY: self.type_time, 'type_filling': OrderFilling.FOK, **kwargs}
super().__init__(**kwargs)
async def orders_total(self):
"""Get the number of active orders.
"""Get the number of active pending orders.
Returns:
(int): total number of active orders
"""
return await self.mt5.orders_total()
@backoff_decorator
async def get_order(self, *, ticket: int) -> TradeOrder | None:
"""
Get the order by ticket number.
Get a pending order by ticket number.
Args:
ticket (int): Order ticket number
@@ -50,15 +46,15 @@ class Order(TradeRequest):
Returns:
"""
orders = await self.mt5.orders_get(ticket=ticket)
order = None
for order_ in orders:
if order_.ticket == ticket:
return TradeOrder(**order_._asdict())
return order
if orders and (order := orders[0]).ticket == ticket:
return TradeOrder(**order._asdict())
return None
@backoff_decorator
async def get_orders(self, *, ticket: int = 0, symbol: str = '', group: str = '') -> tuple[TradeOrder, ...]:
"""Get the list of active orders for the current symbol.
"""Get the list of active pending orders for the current symbol.
Keyword Args:
ticket (int): Order ticket number
symbol (str): Symbol name
@@ -66,15 +62,12 @@ class Order(TradeRequest):
Returns:
tuple[TradeOrder]: A Tuple of active trade orders as TradeOrder objects
"""
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)
return tuple(TradeOrder(**order._asdict()) for order in orders)
return tuple()
@backoff_decorator
async def check(self, **kwargs) -> OrderCheckResult:
"""Check funds sufficiency for performing a required trading operation and the possibility of executing it.
@@ -88,8 +81,9 @@ class Order(TradeRequest):
res = await self.mt5.order_check(req)
if res is None:
raise OrderError(f'Order check failed for {self.symbol}')
return OrderCheckResult(**res._asdict())
return res
@backoff_decorator
async def send(self) -> OrderSendResult:
"""Send a request to perform a trading operation from the terminal to the trade server.
@@ -102,17 +96,9 @@ class Order(TradeRequest):
res = await self.mt5.order_send(self.dict)
if res is None:
raise OrderError(f'Failed to send order {self.symbol}')
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:
async def calc_margin(self) -> float | None:
"""Return the required margin in the account currency to perform a specified trading operation.
Returns:
@@ -122,21 +108,16 @@ class Order(TradeRequest):
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}')
return res
async def calc_profit(self, **kwargs) -> float | None:
@error_handler(response=0)
async def calc_profit(self) -> float:
"""Return profit in the account currency for a specified trading operation.
Returns:
float: Returns float value if successful
None: If not successful
"""
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'])
action, symbol, volume, price_open, price_close = self.type, self.symbol, self.volume, self.price, self.tp
res = await self.mt5.order_calc_profit(action, symbol, volume, price_open, price_close)
return res
+41 -64
View File
@@ -3,12 +3,10 @@ import asyncio
from logging import getLogger
from .core.meta_trader import MetaTrader
from .core.models import TradePosition, TradeAction
from .core.constants import OrderType
from .core.models import TradePosition, OrderSendResult
from .core.constants import OrderType, TradeAction
from .core.config import Config
# from .contrib.backtester.meta_tester import MetaTester
from .contrib.backtester.meta_tester import MetaTester
from .order import Order
from .utils import backoff_decorator
@@ -19,60 +17,32 @@ 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 #| MetaTester
mt5: MetaTrader | MetaTester
positions: tuple[TradePosition, ...]
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
"""
def __init__(self):
"""Get Open Positions"""
self.config = Config()
self.mt5 = MetaTrader() # if self.config.mode == 'live' else MetaTester()
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()
self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester()
self.positions = ()
@backoff_decorator
async def positions_get(self, symbol: str = '', group: str = '', ticket: int = 0) -> list[TradePosition]:
async def get_positions(self) -> tuple[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
tuple[TradePosition, ...]: A tuple of open trade positions
"""
positions = await self.mt5.positions_get(group=group or self.group, symbol=symbol or self.symbol,
ticket=ticket or self.ticket)
positions = await self.mt5.positions_get()
if positions is not None:
return [TradePosition(**pos._asdict()) for pos in positions]
self.positions = tuple(TradePosition(**pos._asdict()) for pos in positions)
return self.positions
logger.warning('Failed to get open positions for')
return ()
logger.warning(f'Failed to get positions for {symbol or self.symbol}')
return []
async def position_get(self, *, ticket: int) -> TradePosition | None:
async def get_position_by_ticket(self, *, ticket: int) -> TradePosition | None:
"""Get an open position by ticket.
Args:
ticket (int): Position ticket.
@@ -80,16 +50,27 @@ class Positions:
Returns:
TradePosition: Return an open position
"""
positions = await self.positions_get(ticket=ticket)
positions = await self.mt5.positions_get(ticket=ticket)
position = positions[0] if positions else None
if position is None or position.ticket != ticket:
return None
return TradePosition(**position._asdict())
return position
async def get_position_by_symbol(self, *, symbol: str) -> tuple[TradePosition, ...]:
"""Get open positions by symbol.
Args:
symbol (str): Financial instrument name.
async def close(self, *, ticket: int, symbol: str, price: float, volume: float, order_type: OrderType):
Returns:
tuple[TradePosition, ...]: A tuple of open trade positions
"""
positions = await self.mt5.positions_get(symbol=symbol)
return tuple(TradePosition(**pos._asdict()) for pos in (positions or ()))
@staticmethod
async def close(ticket: int, symbol: str, price: float, volume: float, order_type: OrderType) -> OrderSendResult:
"""Close an open position for the trading account using the ticket and other parameters.
Args:
ticket (int): Position ticket.
symbol (str): Financial instrument name.
@@ -101,31 +82,27 @@ class Positions:
type=order_type.opposite)
return await order.send()
async def close_by(self, pos: TradePosition):
@staticmethod
async def close_by(pos: TradePosition) -> OrderSendResult:
"""Close an open position for the trading account."""
order = Order(position=pos.ticket, symbol=pos.symbol, volume=pos.volume, type=pos.type.opposite,
price=pos.price_current, action=TradeAction.DEAL)
return await order.send()
async def close_position(self, *, position: TradePosition):
@staticmethod
async def close_position(position: TradePosition):
"""Close an open position for the trading account. Using a position object."""
order = Order(position=position.ticket, symbol=position.symbol, volume=position.volume,
type=position.type.opposite, price=position.price_current, action=TradeAction.DEAL)
return await order.send()
async def close_all(self, symbol: str = '', group: str = '') -> int:
async def close_all(self) -> 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])
positions = self.positions or await self.get_positions()
results = await asyncio.gather(*(self.close_position(position) for position in positions),
return_exceptions=True)
return len([res for res in results if (isinstance(res, OrderSendResult) and res.retcode == 10009)])
+14 -23
View File
@@ -7,13 +7,10 @@ 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
open_limit: int = 5
def __init__(self, *, risk_to_reward: float = 1, risk: float = 0.01, **kwargs):
"""Initialize Risk Assessment and Management with the provided keyword arguments.
@@ -29,7 +26,7 @@ class RAM:
[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.
"""Calculate the amount to risk per trade as a percentage of margin_free.
Returns:
float: Amount to risk per trade
@@ -40,27 +37,21 @@ class RAM:
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.
async def check_losing_positions(self) -> bool:
"""Check if the number of losing positions is greater than or equal the loss limit
Args:
symbol (str): Symbol to check. Defaults to ''.
Returns:
bool: True if the number of losing positions is less than the loss limit
"""
positions = await Positions().positions_get(symbol=symbol)
loosing = [trade for trade in positions if trade.profit <= 0]
return len(loosing) >= self.loss_limit
positions = await Positions().get_positions()
loosing = [position for position in positions if position.profit < 0]
return len(loosing) < self.loss_limit
async def check_open_positions(self, *, symbol: str = '') -> bool:
async def check_open_positions(self) -> bool:
"""Check if the number of open positions is greater than or equal the loss limit.
Args:
symbol (str): Symbol to check. Defaults to ''.
Returns:
bool: True if the number of open positions is less than the open limit
"""
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
positions = await Positions().get_positions()
return len(positions) < self.open_limit
+15 -11
View File
@@ -4,6 +4,8 @@ from logging import getLogger
from typing import Iterable, Literal
from asyncio import Lock
from _typeshed import SupportsWrite, SupportsRead
from .core.config import Config
from .core.models import OrderSendResult
@@ -57,16 +59,18 @@ class Result:
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=''))
read_file = file.open('r', newline='')
reader: Iterable[dict] = csv.DictReader(read_file)
read_file.close()
rows: list[dict] = []
headers = set()
[(rows.append(row), headers.update(row.keys())) for row in reader]
rows.append(data)
headers.update(data.keys())
writer = csv.DictWriter(file.open('w', newline=''), fieldnames=headers, restval=None,
extrasaction='ignore')
writer.writeheader()
writer.writerows(rows)
with file.open('w', newline='') as write_file: # type: SupportsWrite[str]
writer = csv.DictWriter(write_file, fieldnames=headers, restval=None, extrasaction='ignore')
writer.writeheader()
writer.writerows(rows)
except Exception as err:
logger.error(f'Unable to save to csv: {err}')
@@ -89,14 +93,14 @@ class Result:
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:
file.touch(exist_ok=True) if not file.exists() else ...
with file.open('r') as fh: # type: SupportsRead[str]
rows = json.load(fh)
rows.append(data)
with file.open('w') as fh:
rows.append(data)
with file.open('w') as fh: # type: SupportsWrite[str]
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}")
+112 -63
View File
@@ -1,12 +1,15 @@
import asyncio
from datetime import time, timedelta, datetime
from asyncio import sleep, iscoroutinefunction
from typing import Literal, Callable
from typing import Literal, Callable, Iterable
from logging import getLogger
import pytz
from . import TradePosition
from .core.models import OrderSendResult
from .positions import Positions
from .core.config import Config
# from.contrib.backtester.event_manager import EventManager
from.contrib.backtester.event_manager import EventManager
logger = getLogger(__name__)
@@ -20,13 +23,14 @@ def delta(obj: time) -> timedelta:
return timedelta(hours=obj.hour, minutes=obj.minute, seconds=obj.second, microseconds=obj.microsecond)
# async def backtest_sleep(secs):
# """A custom function to call when the session starts."""
# # em = EventManager()
#
# async with em.condition:
# while em.config.test_data.cursor.time < (em.config.test_data.cursor.time + secs):
# await em.condition.wait()
async def backtest_sleep(secs):
"""A custom function to call when the session starts."""
em = EventManager()
config = Config()
sleep = config.backtest_engine.cursor.time + secs
async with em.condition:
while sleep > config.backtest_engine.cursor.time:
await em.wait()
class Session:
@@ -39,7 +43,7 @@ class Session:
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
name (str): A name for the session. Default is a combination of start and end.
"""
def __init__(self, *, start: int | time, end: int | time,
on_start: Literal['close_all', 'close_win', 'close_loss', 'custom_start'] = None,
@@ -58,76 +62,103 @@ class Session:
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.start = start if isinstance(start, time) else time(hour=start, tzinfo=pytz.UTC)
self.end = end if isinstance(end, time) else time(hour=end, tzinfo=pytz.UTC)
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}'
self.name = name or f'{self.start}<-->{self.end}'
self.positions_manager = Positions()
self.config = Config()
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
end = timedelta(days=1, hours=self.start.hour, minutes=self.start.minute, seconds=self.start.second,
microseconds=self.start.microsecond)
start = delta(self.start)
if item < self.start and item < self.end:
item = timedelta(days=1, hours=item.hour, minutes=item.minute, seconds=item.second,
microseconds=item.microsecond)
else:
item = delta(item)
else:
start = delta(self.start)
end = delta(self.end)
return start <= item < end
def __str__(self):
return f'{self.start}-->{self.name}-->{self.end}' if self.name else f'{self.start}-->{self.end}'
return f'{self.start}<-->{self.end}'
def __repr__(self):
return f'{self.start}-->{self.end}'
return f'{self.start}<-->{self.end}'
def __len__(self):
return (delta(self.start) - delta(self.end)).seconds
def in_session(self) -> bool:
"""Check if the current time is within the session."""
now = datetime.now(tz=pytz.UTC).time() if self.config.mode == 'live'\
else datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=pytz.UTC).time()
return now in self
async def begin(self):
"""Call the action specified in on_start or custom_start."""
await self.action(self.on_start)
await self.action(action=self.on_start)
async def close(self):
"""Call the action specified in on_end or custom_end."""
await self.action(self.on_end)
await self.action(action=self.on_end)
async def action(self, action):
async def close_positions(self, *, positions: tuple[TradePosition, ...]):
results = asyncio.gather(*(self.positions_manager.close_position(pos) for pos in positions),
return_exceptions=True)
closed = pending = 0
for result in results:
if isinstance(result, OrderSendResult) and result.retcode == 10009:
closed += 1
continue
pending += 1
logger.info(f'Closed {closed} positions')
logger.warning(f"{pending} positions still pending") if pending else ...
async def close_all(self):
open_positions = await self.positions_manager.get_positions()
await self.close_positions(positions=open_positions)
async def close_win(self):
open_positions = await self.positions_manager.get_positions()
positions = tuple(position for position in open_positions if position.profit >= 0)
await self.close_positions(positions=positions)
async def close_loss(self):
open_positions = await self.positions_manager.get_positions()
positions = tuple(position for position in open_positions if position.profit < 0)
await self.close_positions(positions=positions)
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)
await self.close_all()
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)
await self.close_win()
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)
await self.close_loss()
case 'custom_end':
if iscoroutinefunction(self.custom_end):
await self.custom_end()
self.custom_end()
await self.custom_end()
case 'custom_start':
if iscoroutinefunction(self.custom_start):
await self.custom_start()
self.custom_start()
await self.custom_start()
case _:
pass
@@ -136,7 +167,12 @@ class Session:
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
if self.config.mode == 'backtest':
now = datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=pytz.UTC).time()
secs = delta(self.start) - delta(now)
else:
secs = (delta(self.start) - delta(datetime.now(tz=pytz.UTC).time())).seconds
return secs
class Sessions:
@@ -152,10 +188,13 @@ class Sessions:
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):
sessions: list[Session]
def __init__(self, *, sessions: Iterable[Session]):
self.sessions = list(sessions)
self.sessions.sort(key=lambda x: (x.start, x.end))
self.sessions.sort(key=lambda x: (x.start.hour, x.end.hour))
self.current_session = None
self.config = Config()
def find(self, obj: time) -> Session | None:
"""Find a session that contains a datetime.time object.
@@ -181,7 +220,7 @@ class Sessions:
Session: A Session object.
"""
for session in self.sessions:
if obj < session.start:
if delta(obj) < delta(session.start):
return session
return self.sessions[0]
@@ -197,23 +236,33 @@ class Sessions:
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()
if self.current_session is not None and self.current_session.in_session():
return
if self.config.mode == 'backtest':
now = datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=pytz.UTC).time()
else:
now = datetime.now().time()
self.current_session = current_session
next_session = self.find(now)
if next_session and self.current_session is None:
self.current_session = next_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')
sleep_func = sleep # if Config().mode == 'live' else backtest_sleep
if next_session and self.current_session is not None:
await self.current_session.close()
self.current_session = next_session
await self.current_session.begin()
if next_session is None and self.current_session is not None:
await self.current_session.close()
next_session = self.find_next(now)
secs = next_session.until() + 10
logger.info(f'sleeping for {secs} seconds until next {next_session} session')
sleep_func = asyncio.sleep if self.config.mode == 'live' else backtest_sleep
await sleep_func(secs)
self.current_session = current_session
self.current_session = next_session
await self.current_session.begin()
+1 -1
View File
@@ -46,7 +46,7 @@ class Strategy(ABC):
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.sessions = sessions or Sessions(sessions=[Session(start=0, end=dtime(hour=23, minute=59, second=59))])
self.config = Config()
self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester()
+5 -7
View File
@@ -107,16 +107,14 @@ class Symbol(SymbolInfo):
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()
res = await asyncio.gather(self.symbol_select(), self.info(), self.info_tick(), self.book_add(),
return_exceptions=True)
if all(res):
return True
logger.warning(f'Unable to initialized symbol {self}')
logger.warning(f'Unable to initialized {self}')
return False
except Exception as err:
self.select = False
logger.warning(err)
logger.warning(f'{err}: Unable to initialized {self}')
return False
async def book_add(self) -> bool:
+3 -3
View File
@@ -1,5 +1,5 @@
import pytest
from aiomql import MetaTester, TestData, MetaTrader, Config
from aiomql import Config
import MetaTrader5
@@ -10,5 +10,5 @@ def config():
@pytest.fixture(scope='session', autouse=True)
def metatrader5(config):
return MetaTrader5
def metatrader5():
return MetaTrader5
+10
View File
@@ -0,0 +1,10 @@
{
"login": 31288540,
"password": "nwa0#anaEze",
"server": "Deriv-Demo",
"demo": 5463204,
"fin": 24251812,
"deriv-demo": 5463204,
"deriv-real": 31288540,
"mode": "backtest"
}
+8 -7
View File
@@ -9,16 +9,17 @@ from . import metatrader5
class TestMetaTrader:
@classmethod
def setup_class(self):
def setup_class(cls, metatrader5):
tz = pytz.timezone('Etc/UTC')
self.mt = MetaTrader()
self.mt5 = metatrader5
self.symbol = "Volatility 100 Index"
cls.mt = MetaTrader()
cls.mt5 = metatrader5
cls.symbol = "Volatility 100 Index"
now = datetime.now(tz=tz)
self.start = now - timedelta(hours=24)
self.end = now + timedelta(hours=2)
self.tf = self.mt.TIMEFRAME_H1
cls.start = now - timedelta(hours=24)
cls.end = now + timedelta(hours=2)
cls.tf = cls.mt.TIMEFRAME_H1
@pytest.mark.asyncio
async def test_initialize(self):