diff --git a/examples/positions_history.py b/examples/positions_history.py index 82ca894..2cc96e1 100644 --- a/examples/positions_history.py +++ b/examples/positions_history.py @@ -4,7 +4,7 @@ import asyncio from datetime import datetime from aiomql import ForexSymbol, Account, Positions, History, SimpleTrader as Trader, OrderType, RAM -logging.basicConfig(level=logging.INFO, filemode='w', filename='example.log', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logging.basicConfig(level=logging.INFO) async def main(): # Account details are in the aiomql.json file diff --git a/pyproject.toml b/pyproject.toml index 98e7837..646fbe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "aiomql" -version = "3.15" +version = "3.16" readme = "README.md" requires-python = ">=3.10" classifiers = [ diff --git a/src/aiomql/__init__.py b/src/aiomql/__init__.py index 6571a60..ed12045 100644 --- a/src/aiomql/__init__.py +++ b/src/aiomql/__init__.py @@ -15,5 +15,5 @@ from .history import History from .trader import Trader from .terminal import Terminal from .sessions import Session, Sessions -from .utils import dict_to_string +from .utils import dict_to_string, round_off from .lib import * \ No newline at end of file diff --git a/src/aiomql/bot_builder.py b/src/aiomql/bot_builder.py index a487538..32f059d 100644 --- a/src/aiomql/bot_builder.py +++ b/src/aiomql/bot_builder.py @@ -37,6 +37,7 @@ class Bot: async def initialize(self): """Prepares the bot by signing in to the trading account and initializing the symbols for the trading session. + Starts the global task queue. Raises: SystemExit if sign in was not successful @@ -48,6 +49,7 @@ class Bot: logger.info("Login Successful") await self.init_symbols() self.executor.remove_workers() + self.add_coroutine(self.config.task_queue.start) def add_function(self, func: Callable, **kwargs: dict): """Add a function to the executor. @@ -58,7 +60,7 @@ class Bot: """ self.executor.add_function(func, kwargs) - def add_coroutine(self, coro: Coroutine, **kwargs): + def add_coroutine(self, coro: Coroutine | Callable, **kwargs): """Add a coroutine to the executor. Args: diff --git a/src/aiomql/candle.py b/src/aiomql/candle.py index 7c1ed57..8817596 100644 --- a/src/aiomql/candle.py +++ b/src/aiomql/candle.py @@ -183,7 +183,7 @@ class Candles(Generic[_Candle]): elif isinstance(index, int): index = index if index >= 0 else len(self) + index - return self.Candle(**self._data.iloc[index]) + return self.Candle(**self._data.iloc[index], Index=index) raise TypeError(f"Expected int, slice or str got {type(index)}") def __setitem__(self, index, value: Series): diff --git a/src/aiomql/core/__init__.py b/src/aiomql/core/__init__.py index 4e57e54..5740804 100644 --- a/src/aiomql/core/__init__.py +++ b/src/aiomql/core/__init__.py @@ -4,4 +4,5 @@ from .models import * from .constants import * from .base import Base from .errors import Error -from .exceptions import * \ No newline at end of file +from .exceptions import * +from .task_queue import TaskQueue \ No newline at end of file diff --git a/src/aiomql/core/base.py b/src/aiomql/core/base.py index 84a4799..236986c 100644 --- a/src/aiomql/core/base.py +++ b/src/aiomql/core/base.py @@ -13,14 +13,16 @@ class Base: This class provides a set of common methods and attributes for all data model classes. For the data model classes attributes are annotated on the class body and are set as object attributes when the class is instantiated. - - Keyword Args: - **kwargs: Object attributes and values as keyword arguments. Only added if they are annotated on the class body. """ mt5: MetaTrader config: Config def __init__(self, **kwargs): + """ + Initialize a new instance of the Base class + Args: + **kwargs: Object attributes and values as keyword arguments. Only added if they are annotated on the class body. + """ self.config = Config() self.mt5 = MetaTrader() self.exclude = {'mt5', "config", 'exclude', 'include', 'annotations', 'class_vars', 'dict'} diff --git a/src/aiomql/core/config.py b/src/aiomql/core/config.py index 6a436d1..4446a77 100644 --- a/src/aiomql/core/config.py +++ b/src/aiomql/core/config.py @@ -1,20 +1,17 @@ import os from pathlib import Path -from sys import _getframe from typing import Iterator import json from logging import getLogger +from .task_queue import TaskQueue + logger = getLogger(__name__) class Config: """A class for handling configuration settings for the aiomql package. - Keyword Args: - **kwargs: Configuration settings as keyword arguments. - Variables set this way supersede those set in the config file. - Attributes: record_trades (bool): Whether to keep record of trades or not. filename (str): Name of the config file @@ -26,9 +23,12 @@ class Config: path (str): Path to terminal file timeout (int): Timeout for terminal connection _initialize (bool): First time initialization flag + state (dict): A global state dictionary for storing data across the framework + root_dir (str): The root directory of the project Notes: By default, the config class looks for a file named aiomql.json. - You can change this by passing the filename keyword argument to the constructor. + 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. """ @@ -38,11 +38,16 @@ class Config: path: str = "" timeout: int = 60000 record_trades: bool = True - filename: str + filename: str = "aiomql.json" win_percentage: float = 0.85 records_dir = Path.home() / "Documents" / "Aiomql" / "Trade Records" config_dir: str = '' _initialize = True + state: dict = {} + root_dir: Path = Path('.').absolute().resolve() + task_queue: TaskQueue = TaskQueue() + + _instance: 'Config' def __new__(cls, *args, **kwargs): if not hasattr(cls, "_instance"): @@ -50,13 +55,17 @@ class Config: return cls._instance def __init__(self, **kwargs): - self.filename = kwargs.pop('filename', "aiomql.json") - self.config_dir = kwargs.pop('config_dir', '') - self.load_config(reload=kwargs.pop('reload', False)) + reload = kwargs.pop('reload', False) [setattr(self, key, value) for key, value in kwargs.items()] + self.load_config(reload=reload) + + def __setattr__(self, key, value): + if key == 'root_dir': + value = Path(value).absolute().resolve() + super().__setattr__(key, value) @staticmethod - def walk_to_root(path: str) -> Iterator[str]: + def walk_to_root(path: str | Path) -> Iterator[str]: if not os.path.exists(path): raise IOError("Starting path not found") @@ -71,21 +80,15 @@ class Config: last_dir, current_dir = current_dir, parent_dir def find_config(self): - current_file = __file__ - frame = _getframe() - while frame.f_code.co_filename == current_file: - if frame.f_back is None: - return None - frame = frame.f_back - frame_filename = frame.f_code.co_filename - path = os.path.dirname(os.path.abspath(frame_filename)) - path = os.path.join(path, self.config_dir) if self.config_dir else path - - for dirname in self.walk_to_root(path): - check_path = os.path.join(dirname, self.filename) - if os.path.isfile(check_path): - return check_path - return None + try: + path = self.root_dir / self.config_dir + for dirname in self.walk_to_root(path): + check_path = os.path.join(dirname, self.filename) + if os.path.isfile(check_path): + return check_path + return None + except Exception as _: + return def load_config(self, file: str = None, reload: bool = True, filename: str = None, config_dir: str = ''): """Load configuration settings from a file.""" diff --git a/src/aiomql/core/models.py b/src/aiomql/core/models.py index aa1015e..f400ff3 100644 --- a/src/aiomql/core/models.py +++ b/src/aiomql/core/models.py @@ -24,7 +24,7 @@ class AccountInfo(Base): leverage: float profit: float point: float - amount: float = 0 + amount: float equity: float credit: float margin: float diff --git a/src/aiomql/core/task_queue.py b/src/aiomql/core/task_queue.py new file mode 100644 index 0000000..043821a --- /dev/null +++ b/src/aiomql/core/task_queue.py @@ -0,0 +1,48 @@ +import asyncio +from typing import Coroutine, Callable, Awaitable +from logging import getLogger + +logger = getLogger(__name__) + + +class QueueItem: + def __init__(self, task: Callable | Awaitable | Coroutine, *args, **kwargs): + self.task = task + self.args = args + self.kwargs = kwargs + + async def run(self): + try: + if asyncio.iscoroutinefunction(self.task): + return await self.task(*self.args, **self.kwargs) + else: + return self.task(*self.args, **self.kwargs) + except Exception as err: + logger.error(f'Error in running {self.task.__name__} with {str(self.args)}, {self.kwargs}: {err}') + + +class TaskQueue: + def __init__(self): + self.queue = asyncio.Queue() + + def add(self, item: QueueItem): + try: + self.queue.put_nowait(item) + except asyncio.QueueFull: + return + + async def worker(self): + while True: + try: + item: QueueItem = self.queue.get_nowait() + await item.run() + self.queue.task_done() + except asyncio.QueueEmpty: + return + + def add_task(self, item: Callable | Awaitable | Coroutine, *args, **kwargs): + self.add(QueueItem(item, *args, **kwargs)) + asyncio.create_task(self.worker()) + + async def start(self): + await self.queue.join() \ No newline at end of file diff --git a/src/aiomql/executor.py b/src/aiomql/executor.py index 38c1687..532b8c4 100644 --- a/src/aiomql/executor.py +++ b/src/aiomql/executor.py @@ -20,7 +20,7 @@ class Executor: self.workers: list[type(Strategy)] = [] self.coroutines: dict[Coroutine | Callable: dict] = {} self.functions: dict[Callable: dict] = {} - self.bot = bot + self.bot: 'Bot' = bot def add_function(self, func: Callable, kwargs: dict): self.functions[func] = kwargs | {'bot': self.bot} @@ -82,4 +82,4 @@ class Executor: with self.executor(max_workers=workers) as executor: [loop.run_in_executor(executor, self.trade, worker) for worker in self.workers] [loop.run_in_executor(executor, self.run, coro, kwargs) for coro, kwargs in self.coroutines.items()] - [loop.run_in_executor(executor, func, kwargs) for func, kwargs in self.functions.items()] + [loop.run_in_executor(executor, func, kwargs) for func, kwargs in self.functions.items()] \ No newline at end of file diff --git a/src/aiomql/lib/strategies/finger_trap.py b/src/aiomql/lib/strategies/finger_trap.py index 779b611..68903a6 100644 --- a/src/aiomql/lib/strategies/finger_trap.py +++ b/src/aiomql/lib/strategies/finger_trap.py @@ -14,78 +14,74 @@ logger = logging.getLogger(__name__) class FingerTrap(Strategy): - trend_time_frame: TimeFrame - entry_time_frame: TimeFrame + ttf: TimeFrame + etf: TimeFrame trend: int - fast_period: int - slow_period: int - entry_period: int + fast_ema: int + slow_ema: int + entry_ema: int parameters: dict - entry_candles_count: int - trend_candles_count: int + ecc: int + tcc: int trader: Trader tracker: Tracker - parameters = {"trend": 3, "fast_period": 8, "slow_period": 34, "entry_time_frame": TimeFrame.M5, - "trend_time_frame": TimeFrame.H1, "entry_period": 8, - "trend_candles_count": 48, "entry_candles_count": 50} + parameters = {"trend": 3, "fast_ema": 8, "slow_ema": 20, "etf": TimeFrame.M5, + "ttf": TimeFrame.H1, "entry_ema": 5, "tcc": 50, "ecc": 600} def __init__(self, *, symbol: Symbol, params: dict | None = None, trader: Trader = None, sessions: Sessions = None, name: str = 'FingerTrap'): super().__init__(symbol=symbol, params=params, sessions=sessions, name=name) self.trader = trader or SimpleTrader(symbol=self.symbol) - self.tracker: Tracker = Tracker(snooze=self.trend_time_frame.time) + self.tracker: Tracker = Tracker(snooze=self.ttf.time) async def check_trend(self): try: - candles: Candles = await self.symbol.copy_rates_from_pos(timeframe=self.trend_time_frame, - count=self.trend_candles_count) + candles: Candles = await self.symbol.copy_rates_from_pos(timeframe=self.ttf, count=self.tcc) if not ((current := candles[-1].time) >= self.tracker.trend_time): - self.tracker.new = False + self.tracker.update(new=False, order_type=None) return self.tracker.update(new=True, trend_time=current) - candles.ta.ema(length=self.slow_period, append=True, fillna=0) - candles.ta.ema(length=self.fast_period, append=True, fillna=0) - candles.rename(inplace=True, **{f"EMA_{self.fast_period}": "fast", f"EMA_{self.slow_period}": "slow"}) - # Compute - candles["fast_A_slow"] = candles.ta_lib.above(candles.fast, candles.slow) - candles["fast_B_slow"] = candles.ta_lib.below(candles.fast, candles.slow) - candles["close_A_fast"] = candles.ta_lib.above(candles.close, candles.fast) - candles["close_B_fast"] = candles.ta_lib.below(candles.close, candles.fast) + candles.ta.ema(length=self.slow_ema, append=True, fillna=0) + candles.ta.ema(length=self.fast_ema, append=True, fillna=0) + candles.rename(inplace=True, **{f"EMA_{self.fast_ema}": "fast", f"EMA_{self.slow_ema}": "slow"}) - trend = candles[-self.trend: -1] - if all((c.is_bullish() and c.fast_A_slow and c.close_A_fast) for c in trend): + fas = candles.ta_lib.above(candles.fast, candles.slow) # fast above slow + fbs = candles.ta_lib.below(candles.fast, candles.slow) # fast below slow + caf = candles.ta_lib.above(candles.close, candles.fast) # close above fast + cbf = candles.ta_lib.below(candles.close, candles.fast) # close below fast + current = candles[-2] + if fas.iloc[-1] and caf.iloc[-1] and current.is_bullish(): self.tracker.update(trend="bullish") - elif all(c.is_bearish() and c.fast_B_slow and c.close_B_fast for c in trend): + elif fbs.iloc[-1] and cbf.iloc[-1] and current.is_bearish(): self.tracker.update(trend="bearish") else: - self.tracker.update(trend="ranging", snooze=self.trend_time_frame.time) - except Exception as exe: - logger.error(f"{exe}. Error in {self.__class__.__name__}.check_trend") + self.tracker.update(trend="ranging", snooze=self.ttf.time, order_type=None) + except Exception as err: + logger.error(f"{err} for {self.symbol} in {self.__class__.__name__}.check_trend") + self.tracker.update(snooze=self.ttf.time, order_type=None) async def confirm_trend(self): try: - candles = await self.symbol.copy_rates_from_pos(timeframe=self.entry_time_frame, - count=self.entry_candles_count) + candles = await self.symbol.copy_rates_from_pos(timeframe=self.etf, count=self.ecc) if not ((current := candles[-1].time) >= self.tracker.entry_time): - self.tracker.new = False + self.tracker.update(new=False, order_type=None) return + self.tracker.update(new=True, entry_time=current) - candles.ta.ema(length=self.entry_period, append=True, fillna=0) - candles.rename(**{f"EMA_{self.entry_period}": "ema"}) - candles["close_A_ema"] = candles.ta_lib.above(candles.close, candles.ema) - candles["close_B_ema"] = candles.ta_lib.below(candles.close, candles.ema) - candles["close_XA_ema"] = candles.ta_lib.cross(candles.close, candles.ema) - candles["close_XB_ema"] = candles.ta_lib.cross(candles.close, candles.ema, above=False) - current = candles[-2] - if self.tracker.bullish and current.close_XA_ema: - self.tracker.update(snooze=self.entry_time_frame.time, order_type=OrderType.BUY) - elif self.tracker.bearish and current.close_XB_ema: - self.tracker.update(snooze=self.entry_time_frame.time, order_type=OrderType.SELL) + 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) + if self.tracker.bullish and any([cae.iloc[-1], cae.iloc[-2]]): + self.tracker.update(snooze=self.ttf.time, order_type=OrderType.BUY) + elif self.tracker.bearish and any([cbe.iloc[-1], cbe.iloc[-2]]): + self.tracker.update(snooze=self.ttf.time, order_type=OrderType.SELL) else: - self.tracker.update(snooze=self.entry_time_frame.time, order_type=None) - except Exception as exe: - logger.error(f"{exe} Error in {self.name}.confirm_trend") + self.tracker.update(snooze=self.etf.time, order_type=None) + except Exception as err: + logger.error(f"{err} for {self.symbol} in {self.__class__.__name__}.confirm_trend\n") + self.tracker.update(snooze=self.etf.time, order_type=None) async def watch_market(self): await self.check_trend() @@ -95,6 +91,7 @@ class FingerTrap(Strategy): async def trade(self): logger.info(f"Trading {self.symbol}") async with self.sessions as sess: + await self.sleep(self.ttf.time) while True: await sess.check() try: @@ -108,6 +105,5 @@ class FingerTrap(Strategy): await self.trader.place_trade(order_type=self.tracker.order_type, parameters=self.parameters) await self.sleep(self.tracker.snooze) except Exception as err: - logger.error(f"Error: {err}\t Symbol: {self.symbol} in {self.__class__.__name__}.trade") - await self.sleep(self.trend_time_frame.time) - continue \ No newline at end of file + logger.error(f"{err} For {self.symbol} in {self.__class__.__name__}.trade\n") + await self.sleep(self.ttf.time) \ No newline at end of file diff --git a/src/aiomql/lib/symbols/forex_symbol.py b/src/aiomql/lib/symbols/forex_symbol.py index e2da628..45b0657 100644 --- a/src/aiomql/lib/symbols/forex_symbol.py +++ b/src/aiomql/lib/symbols/forex_symbol.py @@ -1,19 +1,71 @@ from ...symbol import Symbol from ...core.exceptions import VolumeError - class ForexSymbol(Symbol): """Subclass of Symbol for Forex Symbols. Handles the conversion of currency and the computation of stop loss, take profit and volume. """ + def compute_points(self, *, amount: float, volume) -> float: + """Compute the number of points required for a trade. Given the amount and the volume of the trade. + Args: + amount (float): Amount to trade + volume (float): Volume to trade + """ + points = amount / (volume * self.point * self.trade_contract_size) + return points + + async def compute_volume_points(self, *, amount: float, points: float, use_limits=False, round_down: bool = True, + adjust: float = False) -> tuple[float, float]: + """Compute the volume and points required for a trade. Given the amount and the number of points. - async def compute_volume(self, *, amount: float, points, use_limits=False) -> float: + Args: + amount (float): Amount to trade + points (float): Number of points + round_down: round down the computed volume to the nearest step default True + adjust: Adjust the points if the computed volume is outside the range of permitted volumes + use_limits: Adjust the computed volume to the nearest permitted volume if the computed volume is outside + """ + amount = await self.check_amount(amount) + volume = amount / (self.point * points * self.trade_contract_size) + volume = self.round_off_volume(volume, round_down=round_down) + if (chk_vol := self.check_volume(volume))[0]: + if adjust: + points = self.compute_points(amount=amount, volume=volume) + return volume, points + + if use_limits: + vol = chk_vol[1] + if adjust: + points = self.compute_points(amount=amount, volume=vol) + return vol, points + raise VolumeError(f"Incorrect Volume. Computed Volume outside the range of permitted volumes") + + async def compute_volume_sl(self, *, amount: float, price: float, sl: float, + use_limits=False, adjust: bool = False, round_down: bool = True) -> tuple[float, float]: + amount = await self.check_amount(amount) + volume = amount / ((price - sl) * self.trade_contract_size) + volume = self.round_off_volume(volume, round_down=round_down) + sign = volume / abs(volume) if volume else 1 + if (chk_vol := self.check_volume(abs(volume)))[0]: + if adjust: + sl = price - (amount / (volume * self.trade_contract_size)) + return abs(volume), sl + return abs(volume), sl + if use_limits: + vol = chk_vol[1] * sign + if adjust: + sl = price - (amount / (vol * self.trade_contract_size)) + return abs(vol), sl + raise VolumeError(f"Incorrect Volume. Computed Volume outside the range of permitted volumes") + + async def compute_volume(self, *, amount: float, points, use_limits=False, round_down=True) -> float: """Compute volume given an amount to risk and target points. Round the computed volume to the nearest step. Args: amount (float): Amount to risk. Given in terms of the account currency. points (float): Target points. use_limits (bool): If True, the computed volume checked against the maximum and minimum volume. + round_down: round down the computed volume to the nearest step default True Returns: float: volume @@ -21,10 +73,9 @@ class ForexSymbol(Symbol): Raises: VolumeError: If the computed volume is less than the minimum volume or greater than the maximum volume. """ - if self.currency_profit != self.account.currency: - amount = await self.convert_currency(amount=amount, base=self.currency_profit, quote=self.account.currency) + amount = await self.check_amount(amount) volume = amount / (self.point * points * self.trade_contract_size) - volume = self.round_off_volume(volume) + volume = self.round_off_volume(volume, round_down=round_down) if self.check_volume(volume)[0]: return volume if use_limits: diff --git a/src/aiomql/lib/traders/simple_trader.py b/src/aiomql/lib/traders/simple_trader.py index 0347eda..e96c073 100644 --- a/src/aiomql/lib/traders/simple_trader.py +++ b/src/aiomql/lib/traders/simple_trader.py @@ -3,26 +3,22 @@ from logging import getLogger from ..symbols import ForexSymbol from ...ram import RAM from ...core.models import OrderType -from ...positions import Positions from ...trader import Trader logger = getLogger(__name__) class SimpleTrader(Trader): - """A simple trader class. Limits the number of loosing trades per symbol""" - def __init__(self, *, symbol: ForexSymbol, ram: RAM = None, loss_limit: int = 3): + """A simple trader class""" + def __init__(self, *, symbol: ForexSymbol, ram: RAM = None): """Initializes the order object and RAM instance - The default risk to reward ratio is 1:1. Args: symbol (Symbol): Financial instrument ram (RAM): Risk Assessment and Management instance - loss_limit (int): Maximum number of losing trades allowed at a time. """ - ram = ram or RAM(risk_to_reward=1, points=100) + ram = ram or RAM(risk_to_reward=2) super().__init__(symbol=symbol, ram=ram) - self.loss_limit = loss_limit async def create_order(self, *, order_type: OrderType): """Complete the order object with the required values. Creates a simple order. @@ -30,16 +26,16 @@ class SimpleTrader(Trader): Args: order_type (OrderType): Type of order """ - positions = await Positions().positions_get() - loosing = [trade for trade in positions if trade.profit < 0] - if (losses := len(loosing)) > self.loss_limit: - raise RuntimeError(f"Last {losses} trades in a losing position") - points = self.ram.points or self.symbol.trade_stops_level * 3 + losing = await self.ram.check_losing_positions() + if losing: + raise RuntimeError(f"More than {self.ram.loss_limit} losing positions") amount = await self.ram.get_amount() - self.order.volume = await self.symbol.compute_volume(amount=amount, points=points) + points = self.symbol.compute_points(amount=amount, volume=self.symbol.volume_min) + self.order.volume = self.symbol.volume_min self.order.type = order_type - self.order.comment = self.parameters.get('name', '') - await self.set_trade_stop_levels(points=points) + self.order.comment = self.parameters.get('name', 'SimpleTrader') + tick = await self.symbol.info_tick() + self.set_trade_stop_levels(points=points, tick=tick) async def place_trade(self, order_type: OrderType, parameters: dict = None): """Places a trade based on the order_type.""" diff --git a/src/aiomql/order.py b/src/aiomql/order.py index 63c2e08..55702d3 100644 --- a/src/aiomql/order.py +++ b/src/aiomql/order.py @@ -1,7 +1,7 @@ """Order Class""" from logging import getLogger -from .core.models import TradeRequest, OrderSendResult, OrderCheckResult, TradeOrder +from .core.models import TradeRequest, OrderSendResult, OrderCheckResult, TradeOrder, SymbolInfo from .core.constants import TradeAction, OrderTime, OrderFilling from .core.exceptions import SymbolError, OrderError from .symbol import Symbol @@ -23,14 +23,9 @@ class Order(TradeRequest): action (TradeAction.DEAL): Trade action type_time (OrderTime.DAY): Order time type_filling (OrderFilling.FOK): Order filling - - Raises: - SymbolError: If symbol is not provided """ - if 'symbol' not in kwargs: - raise SymbolError('symbol is required') - sym = kwargs.pop('symbol') - self.symbol = sym.name if isinstance(sym, Symbol) else sym + 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) diff --git a/src/aiomql/positions.py b/src/aiomql/positions.py index 8f05950..9277329 100644 --- a/src/aiomql/positions.py +++ b/src/aiomql/positions.py @@ -69,8 +69,13 @@ class Positions: type=order_type.opposite) return await order.send() + async def close_by(self, pos: TradePosition): + """Close an open position for the trading account.""" + order = Order(position=pos.ticket, symbol=pos.symbol, volume=pos.volume, type=pos.type.opposite, price=pos.price_current) + return await order.send() + async def close_all(self, symbol: str = '', group: str = '') -> int: - """Close all open positions for the trading account. + """Close all open positions for the trading account. Specify a symbol or group to filter positions. Keyword Args: symbol (str): Financial instrument name. @@ -82,8 +87,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(price=pos.price_current, ticket=pos.ticket, order_type=pos.type, volume=pos.volume, - symbol=pos.symbol) for pos in positions] - + orders = [self.close_by(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.retcode == 10009]) \ No newline at end of file + return len([res for res in results if (res and res.retcode) == 10009]) \ No newline at end of file diff --git a/src/aiomql/ram.py b/src/aiomql/ram.py index 184a27e..6c7315b 100644 --- a/src/aiomql/ram.py +++ b/src/aiomql/ram.py @@ -1,37 +1,48 @@ """Risk Assessment and Management""" from .account import Account +from .positions import Positions class RAM: account: Account risk_to_reward: float risk: float - amount: float points: float pips: float min_amount: float max_amount: float + balance_level: float = 50 + loss_limit: int = 3 - def __init__(self, *, risk_to_reward: float = 1, risk: float = 0.01, amount: float = 0, **kwargs): + def __init__(self, *, risk_to_reward: float = 1, risk: float = 0.01, **kwargs): """Initialize Risk Assessment and Management with the provided keyword arguments. Keyword Args: risk_to_reward (float): Risk to reward ratio. Defaults to 1 risk (float): Percentage of account balance to risk per trade 0.01 # 1% - amount (float): Amount to risk per trade in terms of account currency 0 kwargs: extra keyword arguments are set as object attributes """ self.risk_to_reward = risk_to_reward self.risk = risk - self.amount = amount self.account = Account() [setattr(self, key, value) for key, value in kwargs.items()] async def get_amount(self) -> float: - """Calculate the amount to risk per trade as a percentage of equity. + """Calculate the amount to risk per trade as a percentage of balance. Returns: float: Amount to risk per trade """ await self.account.refresh() - return self.account.equity * self.risk \ No newline at end of file + return self.account.balance * self.risk + + async def check_losing_positions(self) -> bool: + positions = await Positions().positions_get() + positions.sort(key=lambda pos: pos.time_msc) + loosing = [trade for trade in positions if trade.profit <= 0] + return len(loosing) > self.loss_limit + + async def check_balance_level(self) -> bool: + await self.account.refresh() + balance_level = (self.account.margin / self.account.balance) * 100 + return balance_level >= self.balance_level \ No newline at end of file diff --git a/src/aiomql/records.py b/src/aiomql/records.py index ef80c8e..61c3579 100644 --- a/src/aiomql/records.py +++ b/src/aiomql/records.py @@ -102,7 +102,7 @@ class Records: else: unclosed.append(row) unclosed = await asyncio.gather(*[self.update_row(row) for row in unclosed]) - return closed + unclosed + return closed + list(unclosed) async def update_records(self): """Update trade records in the records_dir folder.""" diff --git a/src/aiomql/result.py b/src/aiomql/result.py index ee6c5da..09a70fd 100644 --- a/src/aiomql/result.py +++ b/src/aiomql/result.py @@ -1,5 +1,7 @@ +import asyncio import csv from logging import getLogger +from threading import RLock from .core import Config from .core.models import OrderSendResult @@ -34,22 +36,18 @@ class Result: return (self.parameters | self.result.get_dict(exclude={'retcode', 'comment', 'retcode_external', 'request_id', 'request'}) | {'actual_profit': 0, 'closed': False, 'win': False}) - def to_csv(self): + 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 open(file, 'a', newline='') as fh: - writer = csv.DictWriter(fh, fieldnames=sorted(list(data.keys())), extrasaction='ignore', restval=None) - if not exists: - writer.writeheader() - writer.writerow(data) + with RLock(): + with open(file, 'a', newline='') as fh: + writer = csv.DictWriter(fh, fieldnames=sorted(list(data.keys())), extrasaction='ignore', restval=None) + if not exists: + writer.writeheader() + writer.writerow(data) except Exception as err: - logger.error(f'Error: {err}. Unable to save trade results') - - async def save_csv(self): - """Save trade results and associated parameters as a csv file in a separate thread - """ - self.to_csv() \ No newline at end of file + logger.error(f'Error: {err}. Unable to save trade results') \ No newline at end of file diff --git a/src/aiomql/symbol.py b/src/aiomql/symbol.py index 60be761..8da6e02 100644 --- a/src/aiomql/symbol.py +++ b/src/aiomql/symbol.py @@ -2,6 +2,7 @@ from datetime import datetime from logging import getLogger from math import log10, ceil +import decimal from .core.constants import TimeFrame, CopyTicks from .core.models import SymbolInfo, BookInfo @@ -9,6 +10,8 @@ from .ticks import Tick from .account import Account from .candle import Candles from .ticks import Ticks +from .utils import round_off + logger = getLogger(__name__) @@ -165,17 +168,22 @@ class Symbol(SymbolInfo): else: return check, self.volume_max - def round_off_volume(self, volume) -> float: + def round_off_volume(self, volume: float, round_down: bool = True) -> float: """Round off the volume to the nearest volume step. Args: volume (float): Volume to round off + down (bool): If True, round down. If False, round up. Optional unnamed parameter. Defaults to True. Returns: float: Rounded off volume """ - step = ceil(abs(log10(self.volume_step))) - return round(volume, step) + return round_off(value=volume, step=self.volume_step, round_down=round_down) + + async def check_amount(self, amount: float) -> float: + if self.currency_profit != self.account.currency: + amount = await self.convert_currency(amount=amount, base=self.currency_profit, quote=self.account.currency) + return amount async def compute_volume(self, *args, **kwargs) -> float: """Computes the volume required for a trade usually based on the amount and any other keyword arguments. diff --git a/src/aiomql/ticks.py b/src/aiomql/ticks.py index 0f7c53c..03a673d 100644 --- a/src/aiomql/ticks.py +++ b/src/aiomql/ticks.py @@ -120,7 +120,7 @@ class Ticks: return self._data[index] item = self._data.iloc[index] - return Tick(Index=index, **item) + return Tick(**item, Index=index) def __setitem__(self, index, value: Series): if isinstance(value, Series): diff --git a/src/aiomql/trader.py b/src/aiomql/trader.py index ccf6099..fb4a4ce 100644 --- a/src/aiomql/trader.py +++ b/src/aiomql/trader.py @@ -7,6 +7,7 @@ from zoneinfo import ZoneInfo from .order import Order from .symbol import Symbol as _Symbol +from .ticks import Tick from .ram import RAM from .core.models import OrderType, OrderSendResult from .core.config import Config @@ -43,19 +44,15 @@ class Trader(ABC): self.ram = ram or RAM() self.parameters = {} - @abstractmethod - async def create_order(self, *args, **kwargs): - """Complete the order object with the required values. Creates a simple order.""" - - async def set_order_limits(self, *, pips: float): + def set_order_limits(self, *, pips: float, tick: Tick): """Sets the stop loss and take profit for the order. This method uses pips as defined for forex instruments. Args: pips: Target pips + tick: Tick object """ pips = pips * self.symbol.pip sl, tp = pips, pips * self.ram.risk_to_reward - tick = await self.symbol.info_tick() if self.order.type == OrderType.BUY: self.order.sl, self.order.tp = round(tick.ask - sl, self.symbol.digits), round(tick.ask + tp, self.symbol.digits) @@ -67,15 +64,15 @@ class Trader(ABC): else: raise ValueError(f"Invalid order type: {self.order.type}") - async def set_trade_stop_levels(self, *, points): - """Set the stop loss and take profit levels of the order based on the points. + def set_trade_stop_levels(self, *, points: float, tick: Tick): + """Set the stop loss and take profit levels of the order based on the points and price tick. Args: points: Target points + tick: Tick object """ points = points * self.symbol.point sl, tp = points, points * self.ram.risk_to_reward - tick = await self.symbol.info_tick() if self.order.type == OrderType.BUY: self.order.sl, self.order.tp = round(tick.ask - sl, self.symbol.digits), round(tick.ask + tp, self.symbol.digits) @@ -93,22 +90,22 @@ class Trader(ABC): """ check = await self.order.check() if check.retcode != 0: - logger.warning(f"""Unable to place order for {self.symbol}\n - {dict_to_string(check.get_dict(include={'comment', 'retcode'}) | check.request._asdict(), multi=True)}""") + logger.warning(f"""Invalid order for {self.symbol} + \r\r{dict_to_string(check.request._asdict() | check.get_dict(include={'comment', 'retcode'}))}""") return False return True - async def send_order(self): + async def send_order(self) -> OrderSendResult: """Send the order to the broker.""" result = await self.order.send() if result.retcode != 10009: - logger.warning(f"""Unable to place order for {self.symbol}\n - {dict_to_string(result.get_dict(include={'comment', 'retcode'}) | result.request._asdict(), - multi=True)}\n""") - return - logger.info(f"""Placed Trade for {self.symbol}\n{dict_to_string( - result.get_dict(exclude={'request', 'retcode_external', 'retcode', 'request_id'}), multi=True)}\n""") + logger.warning(f"""Unable to place order for {self.symbol} + \r\r{dict_to_string(result.request._asdict() | result.get_dict(include={'comment', 'retcode'}))}\n""") + return result + logger.info(f"""Placed Trade for {self.symbol} + \r\r{dict_to_string(result.get_dict(exclude={'request', 'retcode_external', 'retcode', 'request_id'}), multi=True)}\n""") 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. @@ -128,7 +125,7 @@ class Trader(ABC): params["date"] = str(date.date()) params["time"] = str(date.time()) res = Result(result=result, parameters=params, name=name) - await res.save_csv() + self.config.task_queue.add_task(res.to_csv) @abstractmethod async def place_trade(self, *args, **kwargs): diff --git a/src/aiomql/utils.py b/src/aiomql/utils.py index c69df22..08404fb 100644 --- a/src/aiomql/utils.py +++ b/src/aiomql/utils.py @@ -1,15 +1,24 @@ """Utility functions for aiomql.""" +import decimal -def dict_to_string(data: dict, multi=False) -> str: - """Convert a dict to a string. Use for logging. + +def dict_to_string(data: dict, multi=True) -> str: + """Convert a dict to a string. Useful for logging. Args: data (dict): The dict to convert. - multi (bool, optional): If True, each key-value pair will be on a new line. Defaults to False. + multi (bool, optional): If True, each key-value pair will be on a new line. Defaults to True. Returns: str: The string representation of the dict. """ sep = '\n' if multi else ', ' - return f"{sep}".join(f"{key}: {value}" for key, value in data.items()) \ No newline at end of file + return f"{sep}".join(f"{key}: {value}" for key, value in data.items()) + + +def round_off(value: float, step: float, round_down: bool = True) -> float: + """Round off a number to the nearest step.""" + with decimal.localcontext() as ctx: + ctx.rounding = decimal.ROUND_DOWN if round_down else decimal.ROUND_UP + return float(decimal.Decimal(str(value)).quantize(decimal.Decimal(str(step)))) \ No newline at end of file