This commit is contained in:
Ichinga Samuel
2024-05-05 00:08:57 +01:00
parent 9b22d253df
commit 1eeabd99fd
39 changed files with 1192 additions and 407 deletions
+1
View File
@@ -6,6 +6,7 @@ from .strategy import Strategy
from .bot_builder import Bot
from .result import Result
from .records import Records
from .trade_records import TradeRecords
from .candle import Candle, Candles
from .positions import Positions
from .executor import Executor
+1 -1
View File
@@ -83,7 +83,7 @@ class Account(AccountInfo):
if ini and res:
return True
else:
await asyncio.sleep(tries)
await asyncio.sleep(5+tries)
return await self._login(acc=acc, tries=tries-1)
def has_symbol(self, symbol: str | SymbolInfo):
+9 -4
View File
@@ -37,11 +37,16 @@ class Bot:
self.executor = Executor()
@classmethod
def run_bots(cls, bots: dict[Callable: dict] = None, num_workers: int = None):
"""Run multiple bots at the same time."""
num_workers = num_workers or len(bots) * 2
def run_bots(cls, funcs: dict[Callable: dict] = None, num_workers: int = None):
"""Run multiple scripts or bots in parallel with different accounts.
Args:
funcs (dict): A dictionary of functions to run with their respective keyword arguments as a dictionary
num_workers (int): Number of workers to run the functions
"""
num_workers = num_workers or len(funcs) * 2
with ProcessPoolExecutor(max_workers=num_workers) as executor:
for bot, kwargs in bots.items():
for bot, kwargs in funcs.items():
executor.submit(bot, **kwargs)
async def initialize(self):
+42 -13
View File
@@ -4,7 +4,9 @@ from typing import Type, TypeVar, Generic, Iterable
from logging import getLogger
from pandas import DataFrame, Series
import pandas as pd
import pandas_ta as ta
import mplfinance as mplt
from .core.constants import TimeFrame
@@ -12,7 +14,7 @@ logger = getLogger(__name__)
class Candle:
"""A class representing bars from the MetaTrader 5 terminal as a customized class analogous to Japanese
"""A customized class representing rates from the MetaTrader 5 terminal analogous to Japanese
Candlesticks. You can subclass this class for added customization.
Attributes:
@@ -25,7 +27,6 @@ class Candle:
real_volume (float): Trade volume
spread (float): Spread
Index (int): Custom attribute representing the position of the candle in a sequence.
mid (float): The median of the high and low price.
"""
time: float
open: float
@@ -36,7 +37,6 @@ class Candle:
spread: float
tick_volume: float
Index: int
mid: float
def __init__(self, **kwargs):
"""Create a Candle object from keyword arguments. This class must always be instantiated with open, high, low
@@ -49,17 +49,15 @@ class Candle:
raise ValueError("Candle must be instantiated with open, high, low and close prices")
self.time = kwargs.pop('time', 0)
self.Index = kwargs.pop('Index', 0)
self.mid = kwargs.pop('mid', (kwargs['high'] + kwargs['low']) / 2)
self.set_attributes(**kwargs)
def __repr__(self):
return ("%(class)s(Index=%(Index)s, time=%(time)s, open=%(open)s, high=%(high)s, low=%(low)s, close=%(close)s,"
" mid=%(mid)s)") % {"class": self.__class__.__name__, "open": self.open, "high": self.high,
"low": self.low, "close": self.close, "time": self.time, "mid": self.mid,
'Index': self.Index}
return ("%(class)s(Index=%(Index)s, time=%(time)s, open=%(open)s, high=%(high)s, low=%(low)s, close=%(close)s)"
% {"class": self.__class__.__name__, "open": self.open, "high": self.high,
"low": self.low, "close": self.close, "time": self.time, 'Index': self.Index})
def __str__(self):
return self.dict()
return str(self.dict())
def __eq__(self, other: "Candle"):
return self.time == other.time
@@ -152,7 +150,6 @@ class Candles(Generic[_Candle]):
tick_volume: Series
real_volume: Series
spread: Series
mid: Series
Candle: Type[Candle]
timeframe: TimeFrame
_data: DataFrame
@@ -177,9 +174,6 @@ class Candles(Generic[_Candle]):
raise ValueError(f"Cannot create DataFrame from object of {type(data)}")
self._data = data.loc[::-1].reset_index(drop=True) if flip else data
if 'mid' not in self._data.columns.values:
mid = (self._data['high'] + self._data['low']) / 2
self._data.insert(0, 'mid', mid)
self.Candle = candle_class or Candle
def __repr__(self):
@@ -268,3 +262,38 @@ class Candles(Generic[_Candle]):
"""
res = self._data.rename(columns=kwargs, inplace=inplace)
return self if inplace else self.__class__(data=res)
def make_addplot(self, *, count: int = 50, columns: list = None, **kwargs) -> dict:
"""
Make subplots for adding to the main plot
Args:
count (int): The numbers of candles to make the addplot for. Defaults to 50.
columns (list[str]): The columns to make the plot from. Defaults to None.
**kwargs: Valid arguments for the mplfinance make_addplot function
"""
columns = columns or []
data = self._data[-count:]
data.index = pd.to_datetime(data['time'], unit='s')
return mplt.make_addplot(data[columns], **kwargs)
def visualize(self, *, count: int = 50, type='candle', savefig: str | dict = None, addplot: dict = None,
style: str = 'charles', ylabel: str = 'Price', title: str = 'Chart', **kwargs):
"""Visualize the candles using the mplfinance library.
Args:
count (int): The number of candles to visualize, counting from behind, i.e the most recent candles.
Defaults to 50.
type: Type of chart, defaults to candle
savefig (str|dict): The path to save the figure or a dictionary of parameters to pass to the savefig method.
addplot: Additional plots to add to the chart. Defaults to None. They should match the dimension of the
original data which is specified via the count parameter.
style (str): The style of the chart. Defaults to 'charles'.
ylabel (str): The label of the y-axis. Defaults to 'Price'.
title (str): The title of the chart. Defaults to 'Chart'.
kwargs: valid kwargs for the plot function.
"""
kwargs |= {key: arg for key, arg in (('savefig', savefig), ('addplot', addplot), ('style', style),
('ylabel', ylabel), ('title', title), ('type', type)) if arg}
data = self._data[-count:]
data.index = pd.to_datetime(data['time'], unit='s')
mplt.plot(data, **kwargs)
+37 -24
View File
@@ -1,12 +1,13 @@
import os
from pathlib import Path
from typing import Iterator
from typing import Iterator, Literal, TypeVar
import json
from logging import getLogger
from .task_queue import TaskQueue
logger = getLogger(__name__)
Bot = TypeVar("Bot")
class Config:
@@ -14,6 +15,7 @@ class Config:
Attributes:
record_trades (bool): Whether to keep record of trades or not.
trade_record_mode: How to save trade, json or csv. Defaults to json
filename (str): Name of the config file
records_dir (str): Path to the directory where trade records are saved
login (int): Trading account number
@@ -31,20 +33,21 @@ class Config:
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"
win_percentage: float = 0.85
records_dir: str | Path = 'records'
config_dir: str = ''
_initialize = True
state: dict = {}
root_dir: Path = Path('.').absolute().resolve()
root: Path
root_dir: Path
records_dir: Path
config_dir: str = ''
task_queue: TaskQueue = TaskQueue()
bot: 'Bot' = None
bot: Bot = None
_instance: 'Config'
def __new__(cls, *args, **kwargs):
@@ -54,19 +57,16 @@ class Config:
def __init__(self, **kwargs):
reload = kwargs.pop('reload', False)
root_dir = kwargs.pop('root_dir', None)
setattr(self, 'root_dir', root_dir) if root_dir else ...
[setattr(self, key, value) for key, value in kwargs.items()]
self.load_config(reload=reload)
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 == 'root_dir':
value = Path(value).absolute().resolve()
if key == 'records_dir':
self.create_records_dir(records_dir=value)
return
if key == 'path':
value = self.root_dir / Path(value) if not Path(value).exists() else value
value = str(self.root_dir / Path(value).absolute().resolve())
super().__setattr__(key, value)
@staticmethod
@@ -96,40 +96,53 @@ class Config:
return
def create_records_dir(self, *, records_dir: str | Path = 'records'):
"""Create records directory if it does not exist. Relative to the root directory of the project.
"""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 name of the directory to create
records_dir (str|Path): The directory to save trade records. Default is 'records'
"""
try:
records_dir = Path(records_dir) if isinstance(records_dir, str) else records_dir
records_dir = self.root_dir / records_dir
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)
super().__setattr__('records_dir', records_dir)
return records_dir
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 = ''):
def load_config(self, *, file: str = None, reload: bool = True, filename: str = None,
config_dir: str = '', **kwargs):
"""Load configuration settings from a file.
Keyword Args:
file (str): The path to the file to load. If not provided, the file is searched for
reload (bool): Whether to reload the config object. Default is True
filename (str): The name of the file to load. If not provided, the default filename is used
config_dir (str): The name of the directory to search for the file. Default is the root directory
root_dir (str): The root directory of the project
kwargs: Additional keyword arguments
"""
if not (self._initialize or reload):
return
self._initialize = False
data = {}
self.filename = filename or self.filename
self.config_dir = config_dir or self.config_dir
root_dir = kwargs.pop('root_dir', None)
records_dir = kwargs.pop('records_dir', 'records')
if self._initialize or (root_dir is not None):
self.set_root(root=(root_dir or '.'))
self.create_records_dir(records_dir=records_dir)
if (file := (file or self.find_config())) is None:
logger.warning("No Config File Found")
else:
fh = open(file, mode="r")
data = json.load(fh)
fh.close()
data |= kwargs
[setattr(self, key, value) for key, value in data.items()]
self._initialize = False
def account_info(self) -> dict[str, int | str]:
"""Returns Account login details as found in the config object if available
+9 -10
View File
@@ -310,7 +310,7 @@ class MetaTrader(metaclass=BaseMeta):
async def positions_total(self) -> int:
return await asyncio.to_thread(self._positions_total)
async def positions_get(self, group: str = "", ticket: int = 0, symbol: str = "") -> tuple[TradePosition] | None:
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:
@@ -324,11 +324,10 @@ class MetaTrader(metaclass=BaseMeta):
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 = 0, position: int = 0) -> tuple[TradeOrder] | None:
kwargs = {key: value for key, value in (('date_from', date_from), ('date_to', date_to), ('group', group),
('ticket', ticket), ('position', position)) if value}
res = await asyncio.to_thread(self._history_orders_get, **kwargs)
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)
@@ -340,10 +339,10 @@ class MetaTrader(metaclass=BaseMeta):
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 = 0, position: int = 0) -> tuple[TradeDeal] | None:
kwargs = {key: value for key, value in (('date_from', date_from), ('date_to', date_to), ('group', group),
('ticket', ticket), ('position', position)) if value}
res = await asyncio.to_thread(self._history_deals_get, **kwargs)
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)
+2 -1
View File
@@ -18,7 +18,8 @@ class QueueItem:
else:
return self.task(*self.args, **self.kwargs)
except Exception as err:
logger.error(f'Error in running {self.task.__name__} with {str(self.args)}, {self.kwargs}: {err}')
logger.error(f"Error in running {getattr(self.task, '__name__', str(self.task))}"
f" with {str(self.args)}, {self.kwargs}: {err}")
class TaskQueue:
+134 -29
View File
@@ -2,8 +2,11 @@ import asyncio
from datetime import datetime
from logging import getLogger
from pandas import DataFrame
import pandas as pd
from .core.config import Config
from .core.meta_trader import MetaTrader
from .core.meta_trader import MetaTrader, CopyTicks, OrderType
from .core.models import TradeDeal, TradeOrder
logger = getLogger(__name__)
@@ -27,8 +30,8 @@ class History:
mt5: MetaTrader
config: Config
def __init__(self, *, date_from: datetime | float = None, date_to: datetime | float = None,
group: str = "", ticket: int = 0, position: int = 0):
def __init__(self, *, date_from: datetime | int = None, date_to: datetime | int = None,
group: str = "", ticket: int = None, position: int = None):
"""
Args:
date_from (datetime, float): Date the orders are requested from. Set by the 'datetime' object or as a
@@ -71,63 +74,165 @@ class History:
self.initialized = all(res)
return self.initialized
async def get_deals(self, retries=3) -> list[TradeDeal]:
async def get_deals(self, *, date_from: datetime | int = None, date_to: datetime | int = None, group: str = '',
retries: int = 3) -> tuple[TradeDeal, ...]:
"""Get deals from trading history using the parameters set in the constructor.
Returns:
list[TradeDeal]: A list of trade deals
tuple[TradeDeal]: A list of trade deals
"""
if retries < 1:
logger.warning(f'Failed to get deals: {self.mt5.error}')
return []
deals = await self.mt5.history_deals_get(date_from=self.date_from, date_to=self.date_to, position=self.position,
group=self.group, ticket=self.ticket)
return tuple()
date_from, date_to, group = date_from or self.date_from, date_to or self.date_to, group or self.group
deals = await self.mt5.history_deals_get(date_from=date_from, date_to=date_to, group=group)
if deals is not None:
self.deals = [TradeDeal(**deal._asdict()) for deal in deals]
self.deals = tuple(TradeDeal(**deal._asdict()) for deal in deals)
self.total_deals = len(self.deals)
return self.deals
if self.mt5.error.is_connection_error():
await asyncio.sleep(retries)
return await self.get_deals(retries=retries - 1)
return await self.get_deals(date_from=date_from, date_to=date_to, group=group, retries=retries-1)
logger.warning(f'Failed to get deals: {self.mt5.error}')
return []
return tuple()
async def deals_total(self) -> int:
async def get_deals_ticket(self, *, ticket: int = None) -> tuple[TradeDeal, ...]:
"""Call specifying the order ticket. Return all deals having the specified order ticket in the DEAL_ORDER
property.
Args:
ticket (int): The order ticket
Returns:
tuple[TradeDeal]: A tuple of all deals with the order ticket
"""
ticket = ticket or self.ticket
assert ticket is not None, 'ticket not provided'
deals = await self.mt5.history_deals_get(ticket=ticket)
return tuple(sorted([TradeDeal(**deal._asdict()) for deal in deals], key=lambda x: x.time_msc))
async def get_deals_position(self, *, position: int = None) -> tuple[TradeDeal, ...]:
"""
Get all deals with the specified position ticket in the DEAL_POSITION_ID property
Args:
position (int): The position ticket
Returns:
tuple[TradeDeal]: A tuple of all deals with the position ticket
"""
position = position or self.position
assert position is not None, 'position not provided'
deals = await self.mt5.history_deals_get(position=position)
return tuple(sorted([TradeDeal(**deal._asdict()) for deal in deals], 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
"""
self.total_deals = await self.mt5.history_deals_total(self.date_from, self.date_to)
return self.total_deals
date_from, date_to = date_from or self.date_from, date_to or self.date_to
assert date_from is not None and date_to is not None, 'date_from and/or date_to not provided'
total_deals = await self.mt5.history_deals_total(date_from, date_to)
return total_deals
async def get_orders(self, retries=3) -> list[TradeOrder]:
"""Get orders from trading history using the parameters set in the constructor.
async def get_orders(self, *, date_from: datetime | int = None, date_to: datetime | int = None, group: str = '',
retries: int = 3) -> tuple[TradeOrder, ...]:
"""Get orders from trading history using the parameters set in the constructor or the method arguments.
Returns:
list[TradeOrder]: A list of trade orders
"""
if retries < 1:
logger.warning(f'Failed to get orders: {self.mt5.error}')
return []
orders = await self.mt5.history_orders_get(date_from=self.date_from, date_to=self.date_to, group=self.group,
position=self.position, ticket=self.ticket)
return tuple()
date_from, date_to, group = date_from or self.date_from, date_to or self.date_to, group or self.group
orders = await self.mt5.history_orders_get(date_from=date_from, date_to=date_to, group=group)
if orders is not None:
self.orders = [TradeOrder(**order._asdict()) for order in orders]
self.total_orders = len(self.orders)
return self.orders
return tuple(TradeOrder(**order._asdict()) for order in orders)
if self.mt5.error.is_connection_error():
await asyncio.sleep(retries)
return await self.get_orders(retries=retries - 1)
logger.warning(f'Failed to get orders: {self.mt5.error}')
return []
return await self.get_orders(date_from=date_from, date_to=date_to, group=group, retries=retries - 1)
async def orders_total(self) -> int:
logger.warning(f'Failed to get orders: {self.mt5.error}')
return tuple()
async def get_order_ticket(self, ticket: int | None = None) -> TradeOrder:
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())
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], key=lambda x: x.time_done_msc))
async def orders_total(self, date_from: int | datetime = None, date_to: int | datetime = None) -> int:
"""Get total number of orders within the specified period in the constructor.
Returns:
int: Total number of orders
"""
self.total_orders = await self.mt5.history_orders_total(self.date_from, self.date_to)
return self.total_orders
date_from, date_to = date_from or self.date_from, date_to or self.date_to
assert date_from is not None and date_to is not None, 'date_from and/or date_to not provided'
total_orders = await self.mt5.history_orders_total(date_from, date_to)
return total_orders
async def track_order(self, *, position: int = None, end_time: datetime = None) -> DataFrame:
"""
Track an order from the time it was opened to the time it was closed or any given time.
The tracking is done by getting the ticks
for the order symbol from the time the order was opened to the time it was closed. The profit for each tick is
calculated using the order type, symbol, initial volume, open price and the bid or ask price of the tick
depending on the order type.
Args:
end_time (datetime): The time to stop tracking the order. If not provided, the tracking will continue until
the order is closed.
position (int): The position ticket
end_time (int): The time to stop tracking the order in seconds. If not provided, the tracking will continue
until the order is closed.
Returns:
DataFrame: A pandas DataFrame of the ticks and profit for the order.
"""
orders = await self.get_orders_position(position=position)
deals = await self.get_deals_position(position=position)
open_order = orders[0]
open_deal = deals[0]
close_deal = deals[-1]
time_done = datetime.timestamp(end_time) if end_time is not None else close_deal.time
time_done_msc = int(time_done * 1000)
open_order.set_attributes(time_done_msc=time_done_msc, time_done=time_done, price_open=open_deal.price)
ticks = await self.mt5.copy_ticks_range(open_order.symbol, open_order.time_setup, open_order.time_done,
CopyTicks.ALL)
data = pd.DataFrame(ticks)
profit = lambda x: self.mt5._order_calc_profit(open_order.type, open_order.symbol, open_order.volume_initial,
open_order.price_open,
x.ask if open_order.type == OrderType.BUY else x.bid)
data['profits'] = data.apply(profit, axis=1)
data['time'] = pd.to_datetime(data['time'], unit='s')
data.set_index('time', inplace=True)
return data
+5 -7
View File
@@ -69,15 +69,13 @@ class FingerTrap(Strategy):
self.tracker.update(new=True, entry_time=current)
candles.ta.ema(length=self.entry_ema, append=True)
candles.rename(**{f"EMA_{self.entry_ema}": "ema"})
cae = candles.ta_lib.cross(candles.close, candles.ema)
cbe = candles.ta_lib.cross(candles.close, candles.ema, above=False)
trend = self.ttf.time // self.etf.time
bull_trend = cae.iloc[-trend:]
bear_trend = cbe.iloc[-trend:]
if self.tracker.bullish and any(bull_trend):
candles['cae'] = candles.ta_lib.cross(candles.close, candles.ema)
candles['cbe'] = candles.ta_lib.cross(candles.close, candles.ema, above=False)
current = candles[-1]
if self.tracker.bullish and current.cae:
sl = find_bullish_fractal(candles).low
self.tracker.update(snooze=self.ttf.time, order_type=OrderType.BUY, sl=sl)
elif self.tracker.bearish and any(bear_trend):
elif self.tracker.bearish and current.cbe:
sl = find_bearish_fractal(candles).high
self.tracker.update(snooze=self.ttf.time, order_type=OrderType.SELL, sl=sl)
else:
+20
View File
@@ -38,6 +38,26 @@ class Order(TradeRequest):
"""
return await self.mt5.orders_total()
async def get_order(self, *, ticket: int, retries: int = 3) -> TradeOrder:
"""
Get the order by ticket number.
Args:
ticket (int): Order ticket number
retries (int): Number of retries
Returns:
"""
if retries < 1:
raise OrderError(f'Failed to get orders for {self.symbol}: {self.mt5.error}')
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 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}')
async def get_orders(self, *, ticket: int = 0, symbol: str = '', group: str = '', retries=3)\
-> tuple[TradeOrder, ...]:
"""Get the list of active orders for the current symbol.
+30 -3
View File
@@ -68,9 +68,30 @@ class Positions:
logger.warning(f'Failed to get positions for {symbol or self.symbol}. {self.mt5.error}')
return []
async def close(self, *, ticket: int, symbol: str, price: float, volume: float, order_type: OrderType):
"""Close an open position for the trading account."""
async def position_get(self, *, ticket: int) -> TradePosition:
"""Get an open position by ticket.
Args:
ticket (int): Position ticket.
Returns:
TradePosition: Return an open position
"""
positions = await self.positions_get(ticket=ticket)
position = positions[0] if positions else None
if position is None:
raise ValueError(f'Position with ticket {ticket} not found')
assert position.ticket == ticket, f'Position with ticket {ticket} not found'
return position
async def close(self, *, ticket: int, symbol: str, price: float, volume: float, order_type: OrderType):
"""Close an open position for the trading account using the ticket and other parameters.
Args:
ticket (int): Position ticket.
symbol (str): Financial instrument name.
price (float): Closing price.
volume (float): Volume to close.
order_type (OrderType): Order type.
"""
order = Order(action=TradeAction.DEAL, price=price, position=ticket, symbol=symbol, volume=volume,
type=order_type.opposite)
return await order.send()
@@ -81,6 +102,12 @@ class Positions:
price=pos.price_current)
return await order.send()
async def close_position(self, *, position: TradePosition):
"""Close an open position for the trading account. Using a position object."""
order = Order(position=position.ticket, symbol=position.symbol, volume=position.volume,
type=position.type.opposite, price=position.price_current)
return await order.send()
async def close_all(self, symbol: str = '', group: str = '') -> int:
"""Close all open positions for the trading account. Specify a symbol or group to filter positions.
@@ -94,6 +121,6 @@ class Positions:
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_by(pos) for pos in positions]
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])
+29 -13
View File
@@ -9,10 +9,11 @@ class RAM:
risk: float
points: float
pips: float
min_amount: float
max_amount: float
balance_level: float = 10
min_amount: float = 0
max_amount: float = 0
risk_level: float = 50
loss_limit: int = 3
open_limit: int = 6
def __init__(self, *, risk_to_reward: float = 1, risk: float = 0.01, **kwargs):
"""Initialize Risk Assessment and Management with the provided keyword arguments.
@@ -28,23 +29,38 @@ 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 balance.
"""Calculate the amount to risk per trade as a percentage of equity.
Returns:
float: Amount to risk per trade
"""
await self.account.refresh()
return self.account.balance * self.risk
amount = self.account.margin_free * self.risk
if self.min_amount and self.max_amount:
return max(self.min_amount, min(self.max_amount, amount))
return amount
async def check_losing_positions(self) -> bool:
"""Check if the number of losing positions is greater than or equal the loss limit."""
positions = await Positions().positions_get()
positions.sort(key=lambda pos: pos.time_msc)
async def check_losing_positions(self, *, symbol: str = '') -> bool:
"""Check if the number of losing positions is greater than or equal the loss limit.
Args:
symbol (str): Symbol to check. Defaults to ''.
"""
positions = await Positions().positions_get(symbol=symbol)
loosing = [trade for trade in positions if trade.profit <= 0]
return len(loosing) >= self.loss_limit
async def check_balance_level(self) -> bool:
"""Check if the balance level is greater than or equal to the balance level."""
async def check_open_positions(self, *, symbol: str = '') -> bool:
"""Check if the number of open positions is greater than or equal the loss limit.
Args:
symbol (str): Symbol to check. Defaults to ''.
"""
positions = await Positions().positions_get(symbol=symbol)
return len(positions) >= self.open_limit
async def check_risk_level(self) -> bool:
"""Check the risk level."""
await self.account.refresh()
balance_level = (self.account.margin / self.account.balance) * 100
return balance_level >= self.balance_level
risk_level = (1 - (self.account.margin_free / self.account.equity)) * 100
return risk_level >= self.risk_level
+2 -1
View File
@@ -4,6 +4,7 @@ import asyncio
from pathlib import Path
import csv
import logging
from typing import Iterable
from .core import Config, MetaTrader
@@ -50,7 +51,7 @@ class Records:
"""
try:
fr = open(file, mode='r', newline='')
reader = csv.DictReader(fr)
reader: Iterable[dict] | csv.DictReader = csv.DictReader(fr)
rows = [row for row in reader]
rows = await self.update_rows(rows)
fr.close()
+51 -13
View File
@@ -1,7 +1,7 @@
import csv
import json
from logging import getLogger
from threading import RLock
from pathlib import Path
from typing import Iterable, Literal
from .core import Config
from .core.models import OrderSendResult
@@ -31,26 +31,64 @@ class Result:
self.parameters = parameters or {}
self.result = result
self.name = name or parameters.get('name', 'Trades')
if not Path(self.config.records_dir).exists():
Path(self.config.records_dir).mkdir(parents=True, exist_ok=True)
def get_data(self) -> dict:
res = self.result.get_dict(exclude={'retcode', 'comment', 'retcode_external', 'request_id', 'request'})
return self.parameters | res | {'actual_profit': 0, 'closed': False, 'win': False}
async def save(self, *, trade_record_mode: Literal['csv', 'json'] = None):
"""Record trade results as a csv or json file
Args:
trade_record_mode (Literal['csv'|'json']): Mode of saving trade records
"""
trade_record_mode = trade_record_mode or self.config.trade_record_mode
if trade_record_mode == 'csv':
await self.to_csv()
else:
await self.to_json()
async def to_csv(self):
"""Record trade results and associated parameters as a csv file
"""
try:
data = self.get_data()
file = self.config.records_dir / f"{self.name}.csv"
exists = file.exists()
with RLock():
with open(file, 'a', newline='') as fh:
f_names = sorted(list(data.keys()))
writer = csv.DictWriter(fh, fieldnames=f_names, extrasaction='ignore', restval=None)
if not exists:
writer.writeheader()
writer.writerow(data)
file.touch(exist_ok=True) if not file.exists() else ...
reader: Iterable[dict] = csv.DictReader(file.open('r', newline=''))
rows: list[dict] = []
headers = set()
[(rows.append(row), headers.update(row.keys())) for row in reader]
rows.append(data)
headers.update(data.keys())
writer = csv.DictWriter(file.open('w', newline=''), fieldnames=headers, restval=None,
extrasaction='ignore')
writer.writeheader()
writer.writerows(rows)
except Exception as err:
logger.error(f'Error: {err}. Unable to save trade results')
logger.error(f'Unable to save to csv: {err}')
@staticmethod
def serialize(value) -> str:
"""Serialize the trade records and strategy parameters
"""
try:
return str(value)
except (ValueError, TypeError) as _:
return ""
async def to_json(self):
"""Save trades and strategy parameters in a json file
"""
try:
file = self.config.records_dir / f"{self.name}.json"
data = self.get_data()
exists = file.touch(exist_ok=True) if not file.exists() else True
if not exists:
json.dump([], file.open('w'))
with file.open('r') as fh:
rows = json.load(fh)
rows.append(data)
with file.open('w') as fh:
json.dump(rows, fh, indent=2, skipkeys=True, default=self.serialize)
except Exception as err:
logger.error(f"Unable to save as json file: {err}")
+52
View File
@@ -4,6 +4,8 @@ from typing import TypeVar, Iterable
from pandas import DataFrame, Series
import pandas_ta as ta
import mplfinance as mplt
import pandas as pd
from .core.constants import TickFlag
@@ -47,6 +49,21 @@ class Tick:
% {"class": self.__class__.__name__, "time": self.time, "bid": self.bid,
"ask": self.ask, "last": self.last, "volume": self.volume, 'Index': self.Index})
def dict(self, exclude: set = None, include: set = None) -> dict:
"""
Returns a dictionary of the instance attributes.
Args:
exclude: A set of attributes to exclude from the dictionary. Defaults to None.
include: A set of attributes to include in the dictionary. Defaults to None.
Returns: dict
"""
exclude = exclude or set()
include = include or set()
keys = include or set(self.__dict__.keys()).difference(exclude)
return {k: v for k, v in self.__dict__.items() if k in keys}
def set_attributes(self, **kwargs):
"""Set attributes from keyword arguments"""
for key, value in kwargs.items():
@@ -158,3 +175,38 @@ class Ticks:
"""
res = self._data.rename(columns=kwargs, inplace=inplace)
return res if inplace else self.__class__(data=res)
def make_addplot(self, *, count: int = 50, columns: list = None, **kwargs) -> dict:
"""
Make subplots for adding to the main plot
Args:
count (int): The numbers of candles to make the addplot for. Defaults to 50.
columns (list[str]): The columns to make the plot from. Defaults to None.
**kwargs: Valid arguments for the mplfinance make_addplot function
"""
columns = columns or []
data = self._data[-count:]
data.index = pd.to_datetime(data['time'], unit='s')
return mplt.make_addplot(data[columns], **kwargs)
def visualize(self, *, count: int = 50, type='candle', savefig: str | dict = None, addplot: dict = None,
style: str = 'charles', ylabel: str = 'Price', title: str = 'Chart', **kwargs):
"""Visualize the candles using the mplfinance library.
Args:
count (int): The number of candles to visualize, counting from behind, i.e the most recent candles.
Defaults to 50.
type: Type of chart, defaults to candle
savefig (str|dict): The path to save the figure or a dictionary of parameters to pass to the savefig method.
addplot: Additional plots to add to the chart. Defaults to None. They should match the dimension of the
original data which is specified via the count parameter.
style (str): The style of the chart. Defaults to 'charles'.
ylabel (str): The label of the y-axis. Defaults to 'Price'.
title (str): The title of the chart. Defaults to 'Chart'.
kwargs: valid kwargs for the plot function.
"""
kwargs |= {key: arg for key, arg in (('savefig', savefig), ('addplot', addplot), ('style', style),
('ylabel', ylabel), ('title', title), ('type', type)) if arg}
data = self._data[-count:]
data.index = pd.to_datetime(data['time'], unit='s')
mplt.plot(data, **kwargs)
+154
View File
@@ -0,0 +1,154 @@
"""This module contains the Records class, which is used to read and update trade records from csv files."""
import asyncio
import json
from pathlib import Path
import csv
import logging
from typing import Iterable
from .core import Config, MetaTrader
logger = logging.getLogger(__name__)
class TradeRecords:
"""This utility class read trade records from csv files, and update them based on their closing positions.
Attributes:
config: Config object
records_dir(Path): Absolute path to directory containing record of placed trades, If not given takes the default
from the config
"""
config: Config
mt5: MetaTrader
def __init__(self, *, records_dir: Path | str = ''):
"""Initialize the Records class. The main method of this class is update_records which you should call to update
all the records specified in the records_dir.
Keyword Args:
records_dir (Path): Absolute path to directory containing record of placed trades.
"""
self.config = Config()
self.mt5 = MetaTrader()
self.records_dir = records_dir or self.config.records_dir
async def get_csv_records(self):
"""Get trade records saved as csv from records_dir folder
Yields:
files: Trade record files
"""
for file in self.records_dir.iterdir():
if file.is_file() and file.name.endswith('.csv'):
yield file
async def get_json_records(self):
"""Get trade records from records_dir folder
Yields:
files: Trade record files
"""
for file in self.records_dir.iterdir():
if file.is_file() and file.name.endswith('.json'):
yield file
async def read_update_csv(self, *, file: Path):
"""Read and update csv trade records
Args:
file: Trade record file in csv format
"""
try:
fr = open(file, mode='r', newline='')
reader: Iterable[dict] | csv.DictReader = csv.DictReader(fr)
rows = [row for row in reader]
rows = await self.update_rows(rows=rows)
fr.close()
fw = open(file, mode='w', newline='')
writer = csv.DictWriter(fw, fieldnames=reader.fieldnames, extrasaction='ignore', restval=None)
writer.writeheader()
writer.writerows(rows)
fw.close()
except Exception as err:
logger.error(f'Error: {err}. Unable to read and update csv trade records')
async def read_update_json(self, *, file: Path):
"""Read and update json trade records
Args:
file: Trade record file in csv format
"""
try:
fh = open(file, mode='r')
data = json.load(fh)
rows = [row for row in data]
rows = await self.update_rows(rows=rows)
fh.close()
fh = open(file, mode='w')
json.dump(rows, fh, indent=2)
fh.close()
except Exception as err:
logger.error(f'Error: {err}. Unable to read and update json trade records')
async def update_row(self, *, row: dict) -> dict:
"""Update a single row of entered trade in the csv or json file with the actual profit.
Args:
row: A dictionary from the dictionary writer object of the csv file.
Returns:
dict: A dictionary with the actual profit and win status.
"""
try:
order = int(row['order'])
deals = await self.mt5.history_deals_get(position=order)
if not deals or len(deals) <= 1:
return row
deals = [deal for deal in deals if (deal.order != deal.position_id and deal.position_id == order
and deal.entry == 1)]
deals.sort(key=lambda x: x.time_msc)
deal = deals[-1]
row.update(actual_profit=deal.profit, win=deal.profit > 0, closed=True)
return row
except Exception as err:
logging.error(f'Error: {err}. Unable to update trade record')
return row
async def update_rows(self, *, rows: list[dict]) -> list[dict]:
"""Update the rows of entered trades in the csv or json file with the actual profit.
Args:
rows: A list of dictionaries.
Returns:
list[dict]: A list of dictionaries with the actual profit and win status.
"""
closed, unclosed = [], []
for row in rows:
closed_ = row.get('closed', False)
closed_ = closed_.title() == 'True' if isinstance(closed_, str) else closed_
if closed_:
closed.append(row)
else:
unclosed.append(row)
unclosed = await asyncio.gather(*[self.update_row(row=row) for row in unclosed])
return closed + list(unclosed)
async def update_csv_records(self):
"""Update csv trade records in the records_dir folder."""
records = [self.read_update_csv(file=record) async for record in self.get_csv_records()]
await asyncio.gather(*records)
async def update_json_records(self):
"""Update json trade records in the records_dir folder."""
records = [self.read_update_json(file=record) async for record in self.get_json_records()]
await asyncio.gather(*records)
async def update_csv_record(self, *, file: Path | str):
"""Update a single trade record csv file."""
await self.read_update_csv(file=file)
async def update_json_record(self, *, file: Path | str):
"""Update a single json trade record file"""
await self.read_update_json(file=file)
+5 -4
View File
@@ -107,17 +107,18 @@ class Trader(ABC):
await self.record_trade(result, parameters=self.parameters.copy())
return result
async def record_trade(self, result: OrderSendResult, parameters: dict = None, name: str = ''):
"""Record the trade in a csv file.
async def record_trade(self, result: OrderSendResult, parameters: dict = None, name: str = '', exclude: set = None):
"""Record the trade in csv or json.
Args:
result (OrderSendResult): Result of the order send
parameters: parameters of the trading strategy used to place the trade
name: Name of the trading strategy
exclude: Exclude these fields from the recorded trade
"""
if result.retcode != 10009 or not self.config.record_trades:
return
params = parameters or self.parameters.copy()
params = {k: v for k, v in params.items() if k not in (exclude or set())}
profit = await self.order.calc_profit()
params["expected_profit"] = profit
date = datetime.utcnow()
@@ -125,7 +126,7 @@ class Trader(ABC):
params["date"] = str(date.date())
params["time"] = str(date.time())
res = Result(result=result, parameters=params, name=name)
self.config.task_queue.add_task(res.to_csv)
self.config.task_queue.add_task(res.save)
@abstractmethod
async def place_trade(self, *args, **kwargs):