4.0.15dev

This commit is contained in:
Ichinga Samuel
2025-08-11 02:43:53 +01:00
parent 0d166565f2
commit 3cf15f578f
25 changed files with 278 additions and 101 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aiomql"
version = "4.0.15b"
version = "4.0.15dev"
readme = "README.md"
requires-python = ">=3.11"
classifiers = [
-1
View File
@@ -3,5 +3,4 @@ from .candle_patterns import *
from .symbols import *
from .utils import *
from .traders import *
from .quants import *
from .trackers import *
@@ -1,6 +1,6 @@
from aiomql import Candle, Candles
from ..quants.change import percentage_difference
from ...utils.change import percentage_difference
def is_bullish_fractal(candles: Candles) -> tuple[bool, Candle|None]:
-1
View File
@@ -1 +0,0 @@
from .change import *
+18 -6
View File
@@ -6,7 +6,7 @@ from ...lib import Symbol, Positions, Order
from ...core.models import TradePosition, TradeAction, OrderSendResult
from ...core.constants import OrderType
from ...core.config import Config
from ..quants import percentage_increase, percentage_decrease
from ...utils.change import percentage_increase, percentage_decrease
from .position_tracker import PositionTracker
logger = getLogger()
@@ -54,26 +54,38 @@ class OpenPosition:
await self.update_position()
if not self.is_open:
self.remove_from_state()
await self.close_pending_order()
except Exception as exe:
logger.error("%s: Unable to remove closed position from state", exe)
async def close_pending_order(self) -> tuple[bool, OrderSendResult | None]:
try:
if self.pending_hedge:
res = await Order.cancel_order(order=self.pending_hedge.order, symbol=self.symbol.name)
if res.retcode != 10009:
logger.critical("%s: Unable to cancel pending order", res.comment)
return False, res
self.pending_hedge = None
return True, res
except Exception as exe:
logger.error("%s: Unable to cancel pending order", exe)
return False, None
async def update_position(self) -> bool:
# ToDo: remove pending order
pos = await self.positions.get_position_by_ticket(ticket=self.ticket)
if pos is not None:
self.position = pos
self.is_open = True
else:
self.is_open = False
await self.close_pending_order()
return self.is_open
async def modify_stops(self, *, sl: float = None, tp: float = None,
use_stop_levels=False) -> tuple[bool, OrderSendResult | None]:
try:
# todo: add stops
tick = await self.symbol.info_tick()
# modify stop_loss
if sl is not None and use_stop_levels is True:
min_stops_value = (self.symbol.trade_stops_level + self.symbol.spread) * self.symbol.point
if self.position.type == OrderType.BUY:
@@ -149,8 +161,8 @@ class OpenPosition:
logger.error("%s: Error occurred in track method of Open Position for %d:%s",
exe, self.symbol.name, self.ticket)
async def profit_to_price(self, profit):
action = OrderType.BUY if self.position.type == 0 else OrderType.SELL
async def profit_to_price(self, *, profit):
action = self.position.type
volume = self.position.volume
price_open = self.position.price_open
price_close = percentage_increase(price_open, 50) if action == 0 else percentage_decrease(price_open, 50)
@@ -2,7 +2,7 @@ from logging import getLogger
from .open_position import OpenPosition
from ..quants import extend_interval_by_percentage, get_percentage_position, percentage_position
from ...utils.change import extend_interval_by_percentage, get_percentage_position, percentage_position
logger = getLogger(__name__)
@@ -56,7 +56,7 @@ async def exit_at_checkpoint(pos: OpenPosition, /, start: float = 80, trail: flo
if is_open is False:
return
position = pos.position
if position.profit > 0 and percentage_position(position.price_open, position.tp, position.price_current) >= start:
if percentage_position(position.price_open, position.tp, position.price_current) >= start:
new_checkpoint = get_percentage_position(position.price_open, position.price_current, 100-trail)
change_checkpoint = False
if position.type.long and new_checkpoint > (pos.checkpoint or position.price_open):
@@ -66,6 +66,7 @@ async def exit_at_checkpoint(pos: OpenPosition, /, start: float = 80, trail: flo
if change_checkpoint:
pos.checkpoint = new_checkpoint
pos.use_checkpoint = True
await pos.modify_stops(sl=new_checkpoint, use_stop_levels=True)
logger.info("New checkpoint created for %s:%d at %f:%f",
position.symbol, position.ticket, new_checkpoint, position.profit)
close = False
+7 -9
View File
@@ -119,15 +119,13 @@ class Base:
Returns:
dict: A dictionary of instance and class attributes
"""
try:
_filter = self.exclude.difference(self.include)
return {
key: value
for key, value in (self.class_vars | self.__dict__).items()
if key not in _filter and value is not None
}
except Exception as err:
logger.warning(err)
_filter = self.exclude.difference(self.include)
return {
key: value
for key, value in (self.class_vars | self.__dict__).items()
if key not in _filter and value is not None
}
class _Base(Base):
+2 -2
View File
@@ -86,7 +86,7 @@ class Bot:
logger.info("Login Successful")
await self.init_strategies()
self.add_coroutine(coroutine=self.config.task_queue.run, on_separate_thread=True)
self.add_coroutine(coroutine=self.executor.exit)
self.add_function(function=self.executor.exit)
if len(self.executor.strategy_runners) == 0:
logger.warning("No strategies were added to the bot. Exiting in one second")
@@ -112,7 +112,7 @@ class Bot:
logger.info("Login Successful")
self.init_strategies_sync()
self.add_coroutine(coroutine=self.config.task_queue.run, on_separate_thread=True)
self.add_coroutine(coroutine=self.executor.exit)
self.add_function(function=self.executor.exit)
if len(self.executor.strategy_runners) == 0:
logger.warning("No strategies were added to the bot. Exiting in one second")
+5 -4
View File
@@ -1,5 +1,6 @@
import asyncio
import os
import time
from concurrent.futures import ThreadPoolExecutor
from signal import signal, SIGINT
from typing import Coroutine, Callable
@@ -93,16 +94,16 @@ class Executor:
def sigint_handle(self, signum, frame):
self.config.shutdown = True
async def exit(self):
def exit(self):
"""Shutdown the executor"""
start = asyncio.get_event_loop().time()
start = time.time()
try:
while self.config.shutdown is False and self.config.force_shutdown is False:
if self.timeout is not None and self.timeout < (asyncio.get_event_loop().time() - start):
if self.timeout is not None and self.timeout < (time.time() - start):
self.config.shutdown = True
break
timeout = self.timeout or 1
await asyncio.sleep(timeout)
time.sleep(timeout)
for strategy in self.strategy_runners:
strategy.running = False
self.config.task_queue.cancel()
+5 -6
View File
@@ -32,12 +32,11 @@ class History:
group: str
def __new__(cls, *args, **kwargs):
instance = super().__new__(cls)
if not hasattr(instance.__class__, 'config'):
instance.__class__.config = Config()
if not hasattr(instance.__class__, 'mt5'):
instance.__class__.mt5 = MetaTrader() if instance.config.mode != "backtest" else MetaBackTester()
return instance
if not hasattr(cls, 'config'):
cls.config = Config()
if not hasattr(cls, 'mt5'):
cls.mt5 = MetaTrader() if cls.config.mode != "backtest" else MetaBackTester()
return super().__new__(cls)
def __init__(
self, *, date_from: datetime | float, date_to: datetime | float, group: str = "", use_utc: bool = float
+11 -2
View File
@@ -1,10 +1,10 @@
from logging import getLogger
from ..core.models import TradeRequest, TradeOrder, OrderCheckResult, OrderSendResult
from ..core.constants import TradeAction, OrderTime, OrderFilling
from ..core.constants import TradeAction, OrderTime, OrderFilling, OrderType
from ..core.exceptions import OrderError
from ..core.base import _Base
from ..utils import error_handler
from ..utils import error_handler, percentage_decrease, percentage_increase
logger = getLogger(__name__)
@@ -152,3 +152,12 @@ class Order(_Base, TradeRequest):
def request(self) -> dict:
"""Return the order request as a dictionary."""
return {key: value for key, value in self.dict.items() if key in self.mt5.TradeRequest.__match_args__}
@classmethod
async def profit_to_price(cls, *, profit: float, order_type: OrderType, volume: float, symbol: str, price_open: float):
price_close = percentage_increase(price_open, 50) if order_type == 0 else percentage_decrease(price_open, 50)
half_profit = await cls.mt5.order_calc_profit(symbol=symbol, action=order_type, volume=volume,
price_open=price_open, price_close=price_close)
rate = profit / half_profit * 50
rate = percentage_increase(price_open, rate) if order_type == 0 else percentage_decrease(price_open, rate)
return rate
+1 -1
View File
@@ -71,7 +71,7 @@ class Positions:
kwargs["group"] = group
positions = await cls.mt5.positions_get(**kwargs)
if positions is not None:
return cls.positions
return tuple(TradePosition(**pos._asdict()) for pos in positions)
logger.warning("Failed to get open positions")
return ()
+36 -3
View File
@@ -41,6 +41,20 @@ class RAM:
"""
[setattr(self, key, value) for key, value in kwargs.items()]
def get_amount_sync(self) -> float:
"""Calculate the amount to risk per trade as a percentage of margin_free.
Returns:
float: Amount to risk per trade
"""
if self.fixed_amount:
return self.fixed_amount
self.account.refresh_sync()
amount = self.account.margin_free * (self.risk / 100)
if self.min_amount and self.max_amount:
return max(self.min_amount, min(self.max_amount, amount))
return amount
async def get_amount(self) -> float:
"""Calculate the amount to risk per trade as a percentage of margin_free.
@@ -61,8 +75,8 @@ class RAM:
Returns:
bool: True if the number of losing positions is less than or equal the loss limit
"""
positions = await self.positions.get_positions()
loosing = [position for position in positions if position.profit < 0]
positions = await self.account.mt5.positions_get()
loosing = [position for position in positions if position.profit <= 0]
return len(loosing) <= self.loss_limit
async def check_open_positions(self) -> bool:
@@ -71,5 +85,24 @@ class RAM:
Returns:
bool: True if the number of open positions is less than the open limit
"""
positions = await self.positions.get_positions()
positions = await self.account.mt5.positions_get()
return len(positions) <= self.open_limit
def check_losing_positions_sync(self) -> bool:
"""Check if the number of losing positions is less than the loss limit
Returns:
bool: True if the number of losing positions is less than or equal the loss limit
"""
positions = self.account.mt5._positions_get()
loosing = [position for position in positions if position.profit <= 0]
return len(loosing) <= self.loss_limit
def check_open_positions_sync(self) -> bool:
"""Check if the number of open positions is less than or equal the loss limit.
Returns:
bool: True if the number of open positions is less than the open limit
"""
positions = self.account.mt5._positions_get()
return len(positions) <= self.open_limit
+21 -4
View File
@@ -61,15 +61,32 @@ class Result:
with self.lock:
trade_record_mode = trade_record_mode or self.config.trade_record_mode
if trade_record_mode == "csv":
await self.to_csv()
self.to_csv()
elif trade_record_mode == "json":
await self.to_json()
self.to_json()
elif trade_record_mode == "sql":
self.to_sql()
else:
logger.error(f"Invalid trade record mode: {trade_record_mode}")
async def to_csv(self):
def save_sync(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
"""
with self.lock:
trade_record_mode = trade_record_mode or self.config.trade_record_mode
if trade_record_mode == "csv":
self.to_csv()
elif trade_record_mode == "json":
self.to_json()
elif trade_record_mode == "sql":
self.to_sql()
else:
logger.error(f"Invalid trade record mode: {trade_record_mode}")
def to_csv(self):
"""Record trade results and associated parameters as a csv file"""
try:
data = self.get_data()
@@ -99,7 +116,7 @@ class Result:
logger.error("%s: Unable to serialize value", err)
return ""
async def to_json(self):
def to_json(self):
"""Save trades and strategy parameters in a json file"""
try:
file = self.config.records_dir / f"{self.name}.json"
+130
View File
@@ -0,0 +1,130 @@
from typing import ClassVar
from datetime import datetime, UTC
from logging import getLogger
from ...core.config import Config
from ...core.sync.meta_trader import MetaTrader
from ...core.models import TradeDeal, TradeOrder
from ...core.meta_backtester import MetaBackTester
logger = getLogger(__name__)
class History:
"""The history class handles completed trade deals and trade orders in the trading history of an account.
Attributes:
deals (list[TradeDeal]): Iterable of trade deals
orders (list[TradeOrder]): Iterable of trade orders
total_deals: Total number of deals
total_orders (int): Total number orders
group (str): Filter for selecting history by symbols.
mt5 (MetaTrader): MetaTrader instance
config (Config): Config instance
"""
mt5: ClassVar[MetaTrader | MetaBackTester]
config: ClassVar[Config]
deals: tuple[TradeDeal, ...]
orders: tuple[TradeOrder, ...]
total_deals: int
total_orders: int
group: str
def __new__(cls, *args, **kwargs):
if not hasattr(cls, 'config'):
cls.config = Config()
if not hasattr(cls, 'mt5'):
cls.mt5 = MetaTrader() if cls.config.mode != "backtest" else MetaBackTester()
return super().__new__(cls)
def __init__(
self, *, date_from: datetime | float, date_to: datetime | float, group: str = "", use_utc: bool = float
):
"""
Args:
date_from (datetime, float): Date the orders are requested from. Set by the 'datetime' object or as a
number of seconds elapsed since 1970.01.01.
date_to (datetime, float): Date up to which the orders are requested. Set by the 'datetime' object or as a
number of seconds elapsed since 1970.01.01.
use_utc (bool): Convert date_from and date_to to UTC. Default is False.
group (str): Filter for selecting history by symbols. This defaults to an empty string
"""
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(UTC) if use_utc else date_from
self.date_to = date_to.astimezone(UTC) if use_utc else date_to
self.group = group
self.deals: tuple[TradeDeal, ...] = ()
self.orders: tuple[TradeOrder, ...] = ()
self.total_deals: int = 0
self.total_orders: int = 0
def initialize(self):
"""Get history deals and orders"""
deals, orders = [self.get_deals(), self.get_orders()]
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)
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
"""
deals = 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()
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.
Args:
ticket (int): The order ticket
Returns:
tuple[TradeDeal]: A tuple of all deals with the order ticket
"""
return tuple(sorted((deal for deal in self.deals if deal.order == ticket), key=lambda x: x.time_msc))
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:
position (int): The position ticket
Returns:
tuple[TradeDeal]: A tuple of all deals with the position ticket
"""
return tuple(sorted((deal for deal in self.deals if deal.position_id == position), key=lambda x: x.time_msc))
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
"""
orders = 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)
logger.warning(f"Failed to get orders")
return tuple()
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))
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)
)
+6
View File
@@ -48,6 +48,12 @@ class Order(_Base, TradeRequest):
"""
return cls.mt5.orders_total()
@classmethod
def cancel_order(cls, *, order: int, symbol: str) -> OrderSendResult:
"""Cancel an active pending order by ticket number."""
res = cls.mt5.order_send({"symbol": symbol, "order": order, "action": TradeAction.REMOVE})
return res
@classmethod
def get_pending_order(cls, *, ticket: int) -> TradeOrder | None:
"""
+1 -1
View File
@@ -69,7 +69,7 @@ class Positions:
kwargs["group"] = group
positions = cls.mt5.positions_get(**kwargs)
if positions is not None:
return cls.positions
return tuple(TradePosition(**pos._asdict()) for pos in positions)
logger.warning("Failed to get open positions")
return ()
+1 -2
View File
@@ -7,11 +7,10 @@ import csv
import logging
from typing import Iterable
from MetaTrader5 import TradePosition
from ..core.config import Config
from ..core.meta_trader import MetaTrader
from ..core.meta_backtester import MetaBackTester
from ..core.models import TradePosition
logger = logging.getLogger(__name__)
-1
View File
@@ -3,7 +3,6 @@ from datetime import datetime, UTC
from typing import TypeVar
from logging import getLogger
from ..core.models import OrderType, OrderSendResult, OrderCheckResult
from ..core.config import Config
from ..core.task_queue import QueueItem
+1
View File
@@ -1,2 +1,3 @@
from .utils import *
from .process_pool import *
from .change import *
-5
View File
@@ -1,7 +1,3 @@
# -*- coding: utf-8 -*-
# from importlib.metadata import version
# version = version("pandas-ta")
from pandas_ta.maps import EXCHANGE_TZ, RATE, Category, Imports
from pandas_ta.utils import *
from pandas_ta.utils import __all__ as utils_all
@@ -42,7 +38,6 @@ __all__ = [
"RATE",
"Category",
"Imports",
# "version",
"ma",
"create_dir",
"import_dir",
-7
View File
@@ -1,7 +0,0 @@
# #-*- coding: utf-8 -*-
# from pandas_ta import version
#
# SUPPORT="http://www.pandas-ta.dev/support"
#
# if __name__ == "__main__":
# print(f"Pandas TA: {version}\nSupport: {SUPPORT}")
+28 -42
View File
@@ -1,17 +1,5 @@
# -*- coding: utf-8 -*-
from pathlib import Path
from typing import (
Any,
Dict,
Iterable,
List,
Optional,
Sequence,
TextIO,
Tuple,
TypeVar,
Union
)
from typing import Any, Iterable, Sequence, TypeVar, Dict, List, Tuple, TextIO, Union, Optional
from numpy import ndarray, recarray, void
from numpy import bool_ as np_bool_
@@ -21,40 +9,38 @@ from numpy import integer as np_integer
from numpy import number as np_number
from pandas import DataFrame, Series
# Generic types
T = TypeVar("T")
# Scalars
Scalar = Union[str, float, int, complex, bool, object, np_generic]
Number = Union[int, float, complex, np_number, np_bool_]
Int = Union[int, np_integer]
Float = Union[float, np_floating]
IntFloat = Union[Int, Float]
Scalar = str | float | int | complex | bool | object | np_generic
Number = int | float | complex | np_number | np_bool_
Int = int | np_integer
Float = float | np_floating
IntFloat = Int | Float
# Basic sequences
MaybeTuple = Union[T, Tuple[T, ...]]
MaybeList = Union[T, List[T]]
TupleList = Union[List[T], Tuple[T, ...]]
MaybeTupleList = Union[T, List[T], Tuple[T, ...]]
MaybeIterable = Union[T, Iterable[T]]
MaybeSequence = Union[T, Sequence[T]]
ListStr = List[str]
MaybeTuple = T | tuple[T, ...]
MaybeList = T | list[T]
TupleList = list[T] | tuple[T, ...]
MaybeTupleList = T | list[T] | tuple[T, ...]
MaybeIterable = T | Iterable[T]
MaybeSequence = T | Sequence[T]
ListStr = list[str]
DictLike = Union[None, dict]
DictLike = None | dict
DictLikeSequence = MaybeSequence[DictLike]
Args = Tuple[Any, ...]
ArgsLike = Union[None, Args]
Kwargs = Dict[str, Any]
KwargsLike = Union[None, Kwargs]
Args = tuple[Any, ...]
ArgsLike = None | Args
Kwargs = dict[str, Any]
KwargsLike = None | Kwargs
KwargsLikeSequence = MaybeSequence[KwargsLike]
FileName = Union[str, Path]
FileName = str | Path
DTypeLike = Any
PandasDTypeLike = Any
Shape = Tuple[int, ...]
RelaxedShape = Union[int, Shape]
Shape = tuple[int, ...]
RelaxedShape = int | Shape
Array = ndarray
Array1d = ndarray
Array2d = ndarray
@@ -62,10 +48,10 @@ Array3d = ndarray
Record = void
RecordArray = ndarray
RecArray = recarray
MaybeArray = Union[T, Array]
SeriesFrame = Union[Series, DataFrame]
MaybeSeries = Union[T, Series]
MaybeSeriesFrame = Union[T, Series, DataFrame]
AnyArray = Union[Array, Series, DataFrame]
AnyArray1d = Union[Array1d, Series]
AnyArray2d = Union[Array2d, DataFrame]
MaybeArray = T | Array
SeriesFrame = Series | DataFrame
MaybeSeries = T | Series
MaybeSeriesFrame = T | Series | DataFrame
AnyArray = Array | Series | DataFrame
AnyArray1d = Array1d | Series
AnyArray2d = Array2d | DataFrame
View File