mirror of
https://github.com/Ichinga-Samuel/aiomql.git
synced 2026-08-08 17:57:46 +00:00
reorganize the library into three folders lib, contrib and core
Write unittests with pytest Make all functions and method signatures as keyword only arguments
This commit is contained in:
+4
-5
@@ -48,7 +48,6 @@ nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.hypothesis/
|
||||
scrap.py
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
@@ -74,9 +73,9 @@ target/
|
||||
.vscode/
|
||||
|
||||
# config files
|
||||
config.json
|
||||
aiomql.json
|
||||
config/
|
||||
test_data/
|
||||
test.json
|
||||
|
||||
# development
|
||||
terminals/
|
||||
|
||||
|
||||
|
||||
+1
-19
@@ -1,21 +1,3 @@
|
||||
from .core import *
|
||||
from .account import Account
|
||||
from .ram import RAM
|
||||
from .symbol import Symbol
|
||||
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
|
||||
from .order import Order
|
||||
from .ticks import Tick, Ticks
|
||||
from .history import History
|
||||
from .trader import Trader
|
||||
from .terminal import Terminal
|
||||
from .sessions import Session, Sessions
|
||||
from .utils import dict_to_string, round_off, backoff_decorator, error_handler, error_handler_sync, round_up, round_down
|
||||
from .lib import *
|
||||
# from .contrib import *
|
||||
from .contrib import *
|
||||
|
||||
@@ -22,7 +22,7 @@ def dict_to_string(data: dict, multi=False) -> str:
|
||||
return f"{sep}".join(f"{key}: {value}" for key, value in data.items())
|
||||
|
||||
|
||||
def backoff_decorator(func=None, *, max_retries: int = 5, retries: int = 0, error='') -> callable:
|
||||
def backoff_decorator(func=None, *, max_retries: int = 3, retries: int = 0, error='') -> callable:
|
||||
if func is None:
|
||||
return partial(backoff_decorator, max_retries=max_retries, retries=retries, error=error)
|
||||
|
||||
@@ -37,7 +37,9 @@ def backoff_decorator(func=None, *, max_retries: int = 5, retries: int = 0, erro
|
||||
res = await func(*args, **kwargs)
|
||||
if error != '' and res == error:
|
||||
raise TypeError('Invalid return type')
|
||||
return res
|
||||
else:
|
||||
retries = 0
|
||||
return res
|
||||
except Exception as err:
|
||||
logger.error(f'Error in {func.__name__}: {err}')
|
||||
await asyncio.sleep(retries + random.randint(1, max_retries))
|
||||
@@ -72,6 +74,7 @@ def error_handler_sync(func=None, *, msg='', exe=Exception, response=None):
|
||||
return res
|
||||
except exe as err:
|
||||
logger.error(f'Error in {func.__name__}: {msg or err}')
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -83,6 +86,7 @@ def round_up(value: int, base: int) -> int:
|
||||
return value if value % base == 0 else value + base - (value % base)
|
||||
|
||||
|
||||
# noinspection PyShadowingNames
|
||||
def round_off(value: float, step: float, round_down: bool = False) -> float:
|
||||
"""Round off a number to the nearest step."""
|
||||
with decimal.localcontext() as ctx:
|
||||
@@ -1 +1,4 @@
|
||||
# from .backtester import *
|
||||
from .backtesting import *
|
||||
from .strategies import *
|
||||
from .candle_patterns import *
|
||||
from .symbols import *
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from .meta_tester import MetaTester
|
||||
from .backtest_engine import BackTestEngine
|
||||
from .get_data import GetData, TestData
|
||||
from .strategy_tester import StrategyTester
|
||||
from .event_manager import EventManager
|
||||
from .backtester import BackTester
|
||||
from .test_account import TestAccount
|
||||
from .trades_manager import PositionsManager, OrdersManager, DealsManager
|
||||
@@ -1,27 +0,0 @@
|
||||
from .event_manager import EventManager
|
||||
from ...core.config import Config
|
||||
|
||||
|
||||
class StrategyTester:
|
||||
event_manager: EventManager
|
||||
config: Config
|
||||
|
||||
def set_up(self):
|
||||
self.event_manager = EventManager()
|
||||
|
||||
async def sleep(self, secs: float):
|
||||
time = self.config.backtest_engine.cursor.time
|
||||
mod = time % secs
|
||||
secs = secs - mod if mod != 0 else mod
|
||||
if self.event_manager.num_main_tasks == 1:
|
||||
self.config.backtest_engine.fast_forward(secs)
|
||||
await self.event_manager.wait()
|
||||
elif self.event_manager.num_main_tasks > 1:
|
||||
time = self.config.backtest_engine.cursor.time + secs
|
||||
while time > self.config.backtest_engine.cursor.time:
|
||||
await self.event_manager.wait()
|
||||
else:
|
||||
...
|
||||
|
||||
def test(self):
|
||||
raise NotImplementedError("Implement this method in your subclass")
|
||||
@@ -0,0 +1,4 @@
|
||||
from .get_data import GetData, TestData
|
||||
from .backtest_engine import BackTestEngine
|
||||
from .backtest_account import BackTestAccount
|
||||
from .trades_manager import PositionsManager, OrdersManager, DealsManager
|
||||
+1
-1
@@ -5,7 +5,7 @@ from ...core.constants import AccountTradeMode, AccountMarginMode, AccountStopOu
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestAccount:
|
||||
class BackTestAccount:
|
||||
login: int = 0
|
||||
trade_mode: AccountTradeMode = AccountTradeMode.DEMO
|
||||
leverage: float = 0
|
||||
+18
-15
@@ -15,20 +15,19 @@ from MetaTrader5 import (Tick, SymbolInfo, AccountInfo, TradeOrder, TradePositio
|
||||
|
||||
from ...core.meta_trader import MetaTrader
|
||||
from ...core.constants import (TimeFrame, OrderType, TradeAction, AccountStopOutMode, PositionReason,
|
||||
DealType, DealReason, DealEntry, OrderReason, CopyTicks)
|
||||
from ...core.config import Config
|
||||
from ...utils import round_down, round_up, error_handler, error_handler_sync, async_cache
|
||||
DealType, DealReason, DealEntry, OrderReason, CopyTicks)
|
||||
|
||||
from ..._utils import round_down, round_up, error_handler, error_handler_sync, async_cache
|
||||
|
||||
from .get_data import TestData, GetData, Cursor
|
||||
from .test_account import TestAccount
|
||||
from .backtest_account import BackTestAccount
|
||||
from .trades_manager import PositionsManager, OrdersManager, DealsManager
|
||||
|
||||
tz = pytz.timezone('Etc/UTC')
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class BackTestEngine:
|
||||
mt5: MetaTrader = MetaTrader()
|
||||
mt5: MetaTrader
|
||||
span: range
|
||||
range: range
|
||||
cursor: Cursor
|
||||
@@ -39,15 +38,19 @@ class BackTestEngine:
|
||||
orders: OrdersManager
|
||||
deals: DealsManager
|
||||
positions: PositionsManager
|
||||
_account: TestAccount
|
||||
_account: BackTestAccount
|
||||
|
||||
|
||||
def __init__(self, *, data: TestData = None, speed: int = 1, start: float | datetime = 0,
|
||||
end: float | datetime = 0, restart: bool = False, name: str = ''):
|
||||
self._data = data or TestData()
|
||||
self.config = Config(backtest_engine=self)
|
||||
self.mt5 = MetaTrader()
|
||||
self.config = self.mt5.config
|
||||
self.config.backtest_engine = self
|
||||
self.set_up(start=start, end=end, speed=speed, restart=restart)
|
||||
self.prepare_data()
|
||||
_name = f"{datetime.fromtimestamp(self.span[0]):%d-%m-%y}_{datetime.fromtimestamp(self.span[-1]):%d-%m-%y}"
|
||||
start, end = (self.span[0], self.span[-1]) if self.span else ((now := datetime.now(pytz.UTC).timestamp()), now)
|
||||
_name = f"{datetime.fromtimestamp(start):%d-%m-%y}_{datetime.fromtimestamp(end):%d-%m-%y}"
|
||||
self.name = name or _name
|
||||
|
||||
def __next__(self) -> Cursor:
|
||||
@@ -91,7 +94,7 @@ class BackTestEngine:
|
||||
deals[ticket] = TradeDeal((deal.get(k) for k in TradeDeal.__match_args__))
|
||||
self.deals = DealsManager(data=deals)
|
||||
|
||||
self._account: TestAccount = TestAccount(**self._data.account)
|
||||
self._account: BackTestAccount = BackTestAccount(**self._data.account)
|
||||
|
||||
def next(self) -> Cursor:
|
||||
return next(self)
|
||||
@@ -99,7 +102,7 @@ class BackTestEngine:
|
||||
@property
|
||||
def data(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def reset(self):
|
||||
self.iter = zip_longest(self.range, self.span)
|
||||
self.cursor = Cursor(index=self.range.start, time=self.span.start)
|
||||
@@ -107,7 +110,7 @@ class BackTestEngine:
|
||||
def go_to(self, *, time: datetime | float):
|
||||
time = int(time.timestamp()) if isinstance(time, datetime) else int(time)
|
||||
steps = time - self.cursor.time
|
||||
|
||||
|
||||
if steps > 0:
|
||||
self.fast_forward(steps=steps)
|
||||
return
|
||||
@@ -451,11 +454,11 @@ class BackTestEngine:
|
||||
current_price = price
|
||||
if tp and sl:
|
||||
if action == TradeAction.SLTP:
|
||||
pos = self.positions.get(request.get('position'))
|
||||
pos = self.positions.get(request.get('position'))
|
||||
sym = await self.get_symbol_info(pos.symbol)
|
||||
current_tick = sym or await self.get_price_tick(pos.symbol, self.cursor.time)
|
||||
current_price = current_tick.bid if pos.type == OrderType.BUY else current_tick.ask
|
||||
|
||||
|
||||
min_sl = min(sl, tp)
|
||||
dsl = abs(current_price - min_sl) / sym.point
|
||||
tsl = sym.trade_stops_level + sym.spread
|
||||
@@ -549,7 +552,7 @@ class BackTestEngine:
|
||||
@error_handler
|
||||
async def get_rates_from_pos(self, symbol: str, timeframe: TimeFrame, start_pos: int, count: int) -> np.ndarray:
|
||||
if self.config.use_terminal_for_backtesting:
|
||||
now = datetime.now(tz=tz)
|
||||
now = datetime.now(tz=pytz.UTC)
|
||||
b_now = self.cursor.time
|
||||
diff = (now.timestamp() - b_now) // timeframe.time
|
||||
start_pos = int(diff + start_pos)
|
||||
+1
-1
@@ -13,7 +13,7 @@ from ...core.meta_trader import MetaTrader
|
||||
from ...core.config import Config
|
||||
from ...core.constants import TimeFrame
|
||||
from ...core.task_queue import TaskQueue, QueueItem
|
||||
from ...utils import backoff_decorator
|
||||
from ..._utils import backoff_decorator
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
+3
-1
@@ -1,8 +1,10 @@
|
||||
from datetime import datetime
|
||||
from typing import TypeVar, Generic
|
||||
from logging import getLogger
|
||||
|
||||
from MetaTrader5 import TradePosition, TradeOrder, TradeDeal
|
||||
|
||||
from aiomql.utils import logger
|
||||
logger = getLogger(__name__)
|
||||
|
||||
TradeData = TypeVar('TradeData', bound=TradePosition | TradeOrder | TradeDeal)
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
from ...candle import Candle, Candles
|
||||
from ...lib.candle import Candle, Candles
|
||||
|
||||
|
||||
def find_bearish_fractal(candles: Candles) -> Candle | None:
|
||||
@@ -0,0 +1,2 @@
|
||||
from .finger_trap import FingerTrap
|
||||
from .tracker import Tracker
|
||||
+8
-8
@@ -1,15 +1,15 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from .tracker import Tracker
|
||||
from ..traders import SimpleTrader
|
||||
from ...symbol import Symbol
|
||||
from ...trader import Trader
|
||||
from ...candle import Candles
|
||||
from ...strategy import Strategy
|
||||
from ...core import TimeFrame, OrderType
|
||||
from ...sessions import Sessions
|
||||
from ...lib.symbol import Symbol
|
||||
from ...lib.trader import Trader
|
||||
from ...lib.candle import Candles
|
||||
from ...lib.strategy import Strategy
|
||||
from ...core.constants import TimeFrame, OrderType
|
||||
from ...lib.sessions import Sessions
|
||||
from ..candle_patterns import find_bearish_fractal, find_bullish_fractal
|
||||
from ..traders import SimpleTrader
|
||||
from .tracker import Tracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
+11
-1
@@ -1,4 +1,4 @@
|
||||
from ...symbol import Symbol
|
||||
from ...lib.symbol import Symbol
|
||||
from ...core.exceptions import VolumeError
|
||||
|
||||
|
||||
@@ -6,6 +6,16 @@ class ForexSymbol(Symbol):
|
||||
"""Subclass of Symbol for Forex Symbols. Handles the conversion of currency and the computation of stop loss,
|
||||
take profit and volume.
|
||||
"""
|
||||
|
||||
@property
|
||||
def pip(self):
|
||||
"""Returns the pip value of the symbol. This is ten times the point value for forex symbols.
|
||||
|
||||
Returns:
|
||||
float: The pip value of the symbol.
|
||||
"""
|
||||
return self.point * 10
|
||||
|
||||
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:
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
from logging import getLogger
|
||||
|
||||
from ..symbols import ForexSymbol
|
||||
from ...ram import RAM
|
||||
from ...lib.ram import RAM
|
||||
from ...core.models import OrderType
|
||||
from ...trader import Trader
|
||||
from ...lib.trader import Trader
|
||||
from ..symbols import ForexSymbol
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -2,7 +2,7 @@ from .meta_trader import MetaTrader
|
||||
from .config import Config
|
||||
from .models import *
|
||||
from .constants import *
|
||||
from .base import Base
|
||||
from .base import Base, _Base
|
||||
from .errors import Error
|
||||
from .exceptions import *
|
||||
from .task_queue import TaskQueue
|
||||
|
||||
+20
-14
@@ -4,7 +4,7 @@ from logging import getLogger
|
||||
|
||||
from .config import Config
|
||||
from .meta_trader import MetaTrader
|
||||
from ..contrib.backtester import MetaTester
|
||||
from .meta_backtester import MetaBackTester
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -13,18 +13,17 @@ class Base:
|
||||
"""A base class for all data structure classes in the aiomql package. This class provides a set of common methods
|
||||
and attributes for handling data.
|
||||
"""
|
||||
mt5: MetaTrader
|
||||
config: Config
|
||||
exclude: set
|
||||
include: set
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initialize a new instance of the Base class
|
||||
|
||||
Args:
|
||||
**kwargs: Set instance attributes with keyword arguments. Only if they are annotated on the class body.
|
||||
"""
|
||||
self.config = Config()
|
||||
self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester()
|
||||
self.exclude = {'mt5', "config", 'exclude', 'include', 'annotations', 'class_vars', 'dict'}
|
||||
self.exclude = {'mt5', "config", 'exclude', 'include', 'annotations', 'class_vars', 'dict', '_instance'}
|
||||
self.include = set()
|
||||
self.set_attributes(**kwargs)
|
||||
|
||||
@@ -37,7 +36,7 @@ class Base:
|
||||
|
||||
def set_attributes(self, **kwargs):
|
||||
"""Set keyword arguments as object attributes
|
||||
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Object attributes and values as keyword arguments
|
||||
|
||||
@@ -51,15 +50,15 @@ class Base:
|
||||
try:
|
||||
setattr(self, i, self.annotations[i](j))
|
||||
except KeyError:
|
||||
logger.warning(f"Attribute {i} does not belong to class {self.__class__.__name__}")
|
||||
logger.debug(f"Attribute {i} does not belong to class {self.__class__.__name__}")
|
||||
continue
|
||||
|
||||
except ValueError:
|
||||
logger.warning(f'Cannot covert object of type {type(j)} to type {self.annotations[i]}')
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
logger.debug(f'Cannot covert object of type {type(j)} to type {self.annotations[i]}')
|
||||
setattr(self, i, j)
|
||||
|
||||
except Exception as exe:
|
||||
logger.warning(f'Did not set attribute {i} on class {self.__class__.__name__} due to {exe}')
|
||||
logger.debug(f'Did not set attribute {i} on class {self.__class__.__name__} due to {exe}')
|
||||
continue
|
||||
|
||||
@property
|
||||
@@ -71,7 +70,7 @@ class Base:
|
||||
dict: A dictionary of class annotations
|
||||
"""
|
||||
annots = {}
|
||||
for base in self.__class__.__mro__[-3::-1]:
|
||||
for base in self.__class__.__mro__[::-1]:
|
||||
annots |= getattr(base, '__annotations__', {})
|
||||
return annots
|
||||
|
||||
@@ -100,7 +99,7 @@ class Base:
|
||||
Returns:
|
||||
dict: A dictionary of available class attributes in all ancestor classes and the current class.
|
||||
"""
|
||||
clss = self.__class__.__mro__[-3::-1]
|
||||
clss = self.__class__.__mro__[::-1]
|
||||
cls_dict = {}
|
||||
for cls in clss:
|
||||
cls_dict |= cls.__dict__
|
||||
@@ -119,3 +118,10 @@ class Base:
|
||||
key not in _filter}
|
||||
except Exception as err:
|
||||
logger.warning(err)
|
||||
|
||||
|
||||
class _Base(Base):
|
||||
def __init__(self, **kwargs):
|
||||
self.config = Config()
|
||||
self.mt5 = MetaTrader() if self.config.mode != 'backtest' else MetaBackTester()
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Literal, TypeVar, Self
|
||||
@@ -7,10 +8,15 @@ from logging import getLogger
|
||||
from .task_queue import TaskQueue
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
Bot = TypeVar("Bot")
|
||||
BackTestEngine = TypeVar("BackTestEngine")
|
||||
|
||||
def func():
|
||||
stack = inspect.stack()
|
||||
calling_context = next(context for context in stack if context.filename != __file__)
|
||||
print(calling_context.filename)
|
||||
return calling_context.filename
|
||||
|
||||
|
||||
class Config:
|
||||
"""A class for handling configuration settings for the aiomql package.
|
||||
@@ -26,7 +32,7 @@ class Config:
|
||||
path (str): Path to terminal file
|
||||
timeout (int): Timeout for terminal connection
|
||||
state (dict): A global state dictionary for storing data across the framework
|
||||
root (str): Root directory of the project
|
||||
root (Path): Root directory of the project
|
||||
|
||||
Notes:
|
||||
By default, the config class looks for a file named aiomql.json.
|
||||
@@ -80,11 +86,13 @@ class Config:
|
||||
self._backtest_engine = value
|
||||
|
||||
def set_attributes(self, **kwargs):
|
||||
"""Set keyword arguments as object attributes
|
||||
"""Set keyword arguments as object attributes, The root folder attribute can't be set here.
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Object attributes and values as keyword arguments
|
||||
"""
|
||||
if kwargs.pop('root', None) is not None:
|
||||
logger.warning('Tried setting root from set_attributes. Use load_config to change project root')
|
||||
[setattr(self, key, value) for key, value in kwargs.items()]
|
||||
|
||||
@staticmethod
|
||||
@@ -113,7 +121,7 @@ class Config:
|
||||
logger.debug(f"Error finding config file: {err}")
|
||||
return
|
||||
|
||||
def load_config(self, *, file: str | Path = None, filename: str = None, root: str | Path = None, **kwargs):
|
||||
def load_config(self, *, file: str | Path = None, filename: str = None, root: str | Path = None, **kwargs) -> Self:
|
||||
"""Load configuration settings from a file.
|
||||
|
||||
Keyword Args:
|
||||
@@ -128,7 +136,6 @@ class Config:
|
||||
self.root = root
|
||||
else:
|
||||
self.root = self.root if hasattr(self, 'root') else Path.cwd()
|
||||
|
||||
if file is not None:
|
||||
file = Path(file).resolve()
|
||||
if not file.exists():
|
||||
@@ -151,6 +158,9 @@ class Config:
|
||||
data = file_config | kwargs
|
||||
self.set_attributes(**data)
|
||||
|
||||
if self.path:
|
||||
self.path = self.root / self.path if not Path(self.path).resolve().exists() else self.path
|
||||
|
||||
if self.record_trades and not hasattr(self, "records_dir"):
|
||||
self.records_dir = self.root / self.records_dir_name
|
||||
self.records_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -159,6 +169,8 @@ class Config:
|
||||
self.backtest_dir = self.root / self.backtest_dir_name
|
||||
self.backtest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return self
|
||||
|
||||
def account_info(self) -> dict[str, int | str]:
|
||||
"""Returns Account login details as found in the config object if available
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
from asyncio import Condition, Task
|
||||
from typing import Self
|
||||
from datetime import datetime
|
||||
from ...core import Config
|
||||
from .config import Config
|
||||
|
||||
|
||||
class EventManager:
|
||||
@@ -42,7 +42,7 @@ class EventManager:
|
||||
async def event_monitor(self):
|
||||
while True:
|
||||
async with self.condition:
|
||||
if self.task_tracker == self.num_main_tasks:
|
||||
if self.task_tracker == self.num_main_tasks: # all main tasks have been completed in the current cycle
|
||||
self.task_tracker = 0
|
||||
await self.config.backtest_engine.tracker()
|
||||
self.config.backtest_engine.next()
|
||||
@@ -1,21 +1,20 @@
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
from typing import Literal
|
||||
from typing import Literal, TypeVar
|
||||
|
||||
from numpy import ndarray
|
||||
from MetaTrader5 import (Tick, SymbolInfo, AccountInfo, TerminalInfo, TradeOrder, TradePosition, TradeDeal,
|
||||
OrderCheckResult, OrderSendResult)
|
||||
|
||||
from . import BackTestEngine
|
||||
|
||||
from ...core.meta_trader import MetaTrader
|
||||
from ...core.constants import TimeFrame, CopyTicks, OrderType
|
||||
from ...utils import error_handler
|
||||
from .meta_trader import MetaTrader
|
||||
from .constants import TimeFrame, CopyTicks, OrderType
|
||||
from .._utils import error_handler
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
BackTestEngine = TypeVar('BackTestEngine')
|
||||
|
||||
class MetaTester(MetaTrader):
|
||||
class MetaBackTester(MetaTrader):
|
||||
"""A class for testing trading strategies in the MetaTrader 5 terminal. A subclass of MetaTrader."""
|
||||
backtest_engine: BackTestEngine
|
||||
|
||||
@@ -29,7 +28,7 @@ class MetaTester(MetaTrader):
|
||||
|
||||
@backtest_engine.setter
|
||||
def backtest_engine(self, value: BackTestEngine):
|
||||
if isinstance(value, BackTestEngine):
|
||||
if BackTestEngine is not None:
|
||||
self.config.backtest_engine = value
|
||||
|
||||
async def last_error(self) -> tuple[int, str]:
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
from typing import Literal
|
||||
|
||||
@@ -17,6 +17,7 @@ logger = getLogger()
|
||||
|
||||
|
||||
class MetaTrader(MetaCore):
|
||||
|
||||
def __init__(self):
|
||||
self.config = Config()
|
||||
self.error: Error = Error(1)
|
||||
@@ -29,7 +30,8 @@ class MetaTrader(MetaCore):
|
||||
Returns:
|
||||
MetaTrader: An instance of the MetaTrader class.
|
||||
"""
|
||||
await self.initialize(**Config().account_info())
|
||||
await self.initialize()
|
||||
await self.login()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
@@ -50,7 +52,7 @@ class MetaTrader(MetaCore):
|
||||
self.error = Error(*err)
|
||||
|
||||
if self.error.is_connection_error():
|
||||
await self.initialize(path=self.config.path)
|
||||
await self.initialize()
|
||||
await self.login()
|
||||
res = await asyncio.to_thread(func, *args, **kwargs)
|
||||
|
||||
@@ -81,7 +83,7 @@ class MetaTrader(MetaCore):
|
||||
server = server or acc_details.get('server', '')
|
||||
return await asyncio.to_thread(self._login, login, password=password, server=server, timeout=timeout)
|
||||
|
||||
async def initialize(self, path: str = "", login: int = 0, password: str = "", server: str = "",
|
||||
async def initialize(self, path: str = None, login: int = 0, password: str = "", server: str = "",
|
||||
timeout: int | None = None, portable=False) -> bool:
|
||||
"""
|
||||
Initializes the connection to the MetaTrader terminal. All parameters are optional.
|
||||
@@ -97,16 +99,23 @@ class MetaTrader(MetaCore):
|
||||
Returns:
|
||||
bool: True if successful, False otherwise.
|
||||
"""
|
||||
path = path or self.config.path
|
||||
args = (str(path),) if path else ()
|
||||
acc = self.config.account_info()
|
||||
kwargs = {key: value for key, value in (('login', login or acc.get('login')),
|
||||
('password', password or acc.get('password')),
|
||||
('server', server or acc.get('server')),
|
||||
('timeout', timeout or 60000),
|
||||
('portable', portable)) if key is not None}
|
||||
res = await asyncio.to_thread(self._initialize, *args, **kwargs)
|
||||
return res
|
||||
async with asyncio.Lock() as _:
|
||||
path = self.config.path if path is None else path
|
||||
args = (str(path),) if path else ()
|
||||
acc = self.config.account_info()
|
||||
kwargs = {key: value for key, value in (('login', login or acc.get('login')),
|
||||
('password', password or acc.get('password')),
|
||||
('server', server or acc.get('server')),
|
||||
('timeout', timeout or 60000),
|
||||
('portable', portable)) if key is not None}
|
||||
res = await asyncio.to_thread(self._initialize, *args, **kwargs)
|
||||
if res is False:
|
||||
await self.shutdown()
|
||||
res = await asyncio.to_thread(self._initialize, *args, **kwargs)
|
||||
if not res:
|
||||
err = await self.last_error()
|
||||
self.error = Error(*err)
|
||||
return res
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""
|
||||
@@ -228,7 +237,8 @@ class MetaTrader(MetaCore):
|
||||
res = await self._handler(api)
|
||||
return res
|
||||
|
||||
async def order_calc_margin(self, action: OrderType, symbol: str, volume: float, price: float) -> float | None:
|
||||
async def order_calc_margin(self, action: Literal[OrderType.BUY, OrderType.SELL],
|
||||
symbol: str, volume: float, price: float) -> float | None:
|
||||
api = {'func': self._order_calc_margin, 'args': (action, symbol, volume, price),
|
||||
'error_msg': 'Error in calculating margin.'}
|
||||
res = await self._handler(api)
|
||||
|
||||
@@ -327,13 +327,9 @@ class SymbolInfo(Base):
|
||||
exchange: str
|
||||
formula: str
|
||||
isin: str
|
||||
name: str
|
||||
page: str
|
||||
path: str
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
assert 'name' in kwargs, "Symbol Object Must be initialized with a name"
|
||||
super().__init__(**kwargs)
|
||||
name: str = ''
|
||||
|
||||
def __repr__(self):
|
||||
return '%(class)s(name=%(name)s)' % {'class': self.__class__.__name__, 'name': self.name}
|
||||
@@ -346,6 +342,7 @@ class SymbolInfo(Base):
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.name)
|
||||
# return hash(id(self))
|
||||
|
||||
|
||||
class BookInfo(Base):
|
||||
|
||||
@@ -54,34 +54,35 @@ class TaskQueue:
|
||||
item = (priority, item)
|
||||
self.priority_tasks.add(item) if item.must_complete else ...
|
||||
self.queue.put_nowait(item)
|
||||
|
||||
except asyncio.QueueFull:
|
||||
logger.error(f"Queue is full")
|
||||
logger.error("Queue is full")
|
||||
|
||||
async def worker(self):
|
||||
while True:
|
||||
if isinstance(self.queue, asyncio.PriorityQueue):
|
||||
_, item = await self.queue.get()
|
||||
try:
|
||||
if isinstance(self.queue, asyncio.PriorityQueue):
|
||||
_, item = self.queue.get_nowait()
|
||||
|
||||
else:
|
||||
item = await self.queue.get()
|
||||
|
||||
if not self.stop or item.must_complete:
|
||||
await item.run()
|
||||
else:
|
||||
item = self.queue.get_nowait()
|
||||
|
||||
self.queue.task_done()
|
||||
self.priority_tasks.discard(item)
|
||||
if not self.stop or item.must_complete:
|
||||
await item.run()
|
||||
|
||||
if self.stop and len(self.priority_tasks) == 0:
|
||||
print('All priority tasks completed')
|
||||
self.cancel()
|
||||
break
|
||||
self.queue.task_done()
|
||||
self.priority_tasks.discard(item)
|
||||
if self.stop and len(self.priority_tasks) == 0:
|
||||
logger.info('All priority tasks completed')
|
||||
self.cancel()
|
||||
break
|
||||
except Exception as err:
|
||||
logger.error(f"Error {err} occurred in worker")
|
||||
|
||||
def sigint_handle(self, sig, frame):
|
||||
print('SIGINT received, cleaning up...')
|
||||
logger.info('SIGINT received, cleaning up...')
|
||||
|
||||
if self.on_exit == 'complete_priority' and self.priority_tasks:
|
||||
print(f'Completing {len(self.priority_tasks)} priority tasks...')
|
||||
logger.info(f'Completing {len(self.priority_tasks)} priority tasks...')
|
||||
self.stop = True
|
||||
else:
|
||||
self.cancel()
|
||||
@@ -100,26 +101,24 @@ class TaskQueue:
|
||||
await asyncio.wait_for(task, timeout = timeout or self.timeout)
|
||||
|
||||
except TimeoutError:
|
||||
print(f"Timed out after {loop.time() - start} seconds. {self.queue.qsize()} tasks remaining")
|
||||
logger.warning(f"Timed out after {loop.time() - start} seconds. {self.queue.qsize()} tasks remaining")
|
||||
|
||||
if self.on_exit == 'complete_priority' and self.priority_tasks:
|
||||
print(f'Completing {len(self.priority_tasks)} priority tasks...')
|
||||
logger.info(f'Completing {len(self.priority_tasks)} priority tasks...')
|
||||
self.stop = True
|
||||
await self.queue.join()
|
||||
|
||||
else:
|
||||
self.cancel()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print('Tasks cancelled')
|
||||
logger.debug('All tasks cancelled')
|
||||
|
||||
finally:
|
||||
print(f'Exiting queue after {(loop.time() - start)} seconds.'
|
||||
logger.info(f'Exiting queue after {(loop.time() - start)} seconds.'
|
||||
f'{self.queue.qsize()} tasks remaining, {len(self.priority_tasks)} are priority tasks')
|
||||
|
||||
self.cancel()
|
||||
|
||||
def cancel(self):
|
||||
cancelled = [task.cancel() for task in self.tasks if not task.done()]
|
||||
print(f'Cancelled {len(cancelled)} worker tasks') if cancelled else ...
|
||||
[task.cancel() for task in self.tasks if not task.done()]
|
||||
self.tasks.clear()
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
from .strategies import *
|
||||
from .traders import *
|
||||
from .symbols import *
|
||||
from .candle_patterns import *
|
||||
from .account import Account
|
||||
from .backtest_runner import BackTestRunner
|
||||
from .bot_factory import Bot
|
||||
from .candle import Candle, Candles
|
||||
from .executor import Executor
|
||||
from .history import History
|
||||
from .order import Order
|
||||
from .positions import Positions
|
||||
from .ram import RAM
|
||||
from .result import Result
|
||||
from .symbol import Symbol
|
||||
from .ticks import Tick, Ticks
|
||||
from .trader import Trader
|
||||
from .strategy import Strategy
|
||||
from .sessions import Sessions, Session
|
||||
from .trade_records import TradeRecords
|
||||
from .terminal import Terminal
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from logging import getLogger
|
||||
from typing import Self
|
||||
|
||||
from .core.models import AccountInfo
|
||||
from .core.exceptions import LoginError
|
||||
from ..core.base import _Base
|
||||
from ..core.models import AccountInfo
|
||||
from ..core.exceptions import LoginError
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Account(AccountInfo):
|
||||
class Account(_Base, AccountInfo):
|
||||
"""A class for managing a trading account. A singleton class.
|
||||
A subclass of AccountInfo. All AccountInfo attributes are available in this class.
|
||||
|
||||
@@ -23,15 +24,9 @@ class Account(AccountInfo):
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if not hasattr(cls, '_instance'):
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.exclude = cls._instance.exclude | {'_instance'}
|
||||
cls._instance.connected = False
|
||||
return cls._instance
|
||||
|
||||
async def refresh(self):
|
||||
"""Refreshes the account instance with the latest account details from the MetaTrader 5 terminal"""
|
||||
account_info = await self.mt5.account_info()
|
||||
acc = account_info._asdict()
|
||||
self.set_attributes(**acc)
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Connect to a trading account and return the account instance.
|
||||
Async context manager for the Account class.
|
||||
@@ -42,6 +37,7 @@ class Account(AccountInfo):
|
||||
Raises:
|
||||
LoginError: If login fails
|
||||
"""
|
||||
await self.mt5.initialize()
|
||||
self.connected = await self.mt5.login()
|
||||
if not self:
|
||||
raise LoginError('Login failed')
|
||||
@@ -50,3 +46,10 @@ class Account(AccountInfo):
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.mt5.shutdown()
|
||||
self.connected = False
|
||||
|
||||
async def refresh(self):
|
||||
"""Refreshes the account instance with the latest account details from the MetaTrader 5 terminal"""
|
||||
account_info = await self.mt5.account_info()
|
||||
acc = account_info._asdict()
|
||||
self.connected = True
|
||||
self.set_attributes(**acc)
|
||||
@@ -2,27 +2,28 @@ import asyncio
|
||||
import signal
|
||||
from logging import getLogger
|
||||
|
||||
from .event_manager import EventManager
|
||||
from .meta_tester import MetaTester
|
||||
from .backtest_engine import BackTestEngine
|
||||
from .strategy_tester import StrategyTester
|
||||
from ..core.event_manager import EventManager
|
||||
from ..contrib.backtesting.backtest_engine import BackTestEngine
|
||||
from .strategy import Strategy
|
||||
from ..core.meta_backtester import MetaBackTester
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class BackTester:
|
||||
def __init__(self, *, strategies: list[StrategyTester] = None, backtest_engine: BackTestEngine = None):
|
||||
class BackTestRunner:
|
||||
def __init__(self, *, strategies: list[Strategy] = None, backtest_engine: BackTestEngine = None):
|
||||
self.strategies = strategies or []
|
||||
self.event_manager = EventManager()
|
||||
self.mt5 = MetaTester(backtest_engine=backtest_engine)
|
||||
self.mt5 = MetaBackTester(backtest_engine=backtest_engine)
|
||||
signal.signal(signal.SIGINT, self.event_manager.sigint_handler)
|
||||
|
||||
async def run(self):
|
||||
try:
|
||||
await self.mt5.initialize()
|
||||
await self.mt5.login()
|
||||
strategies = [strategy for strategy in self.strategies if await strategy.symbol.init()]
|
||||
self.event_manager.num_main_tasks = len(strategies)
|
||||
tasks = [*[asyncio.create_task(strategy.test()) for strategy in strategies],
|
||||
tasks = [*[asyncio.create_task(strategy.run_strategy()) for strategy in strategies],
|
||||
asyncio.create_task(self.event_manager.event_monitor())]
|
||||
self.event_manager.add_tasks(*tasks)
|
||||
await asyncio.gather(*tasks, return_exceptions=True) if strategies else ...
|
||||
@@ -4,8 +4,8 @@ from typing import Type, Iterable, Callable, Coroutine
|
||||
import logging
|
||||
|
||||
from .executor import Executor
|
||||
from .core.config import Config
|
||||
from .core.meta_trader import MetaTrader
|
||||
from ..core.config import Config
|
||||
from ..core.meta_trader import MetaTrader
|
||||
from .symbol import Symbol as Symbol
|
||||
from .strategy import Strategy as Strategy
|
||||
|
||||
@@ -51,37 +51,38 @@ class Bot:
|
||||
SystemExit if sign in was not successful
|
||||
"""
|
||||
try:
|
||||
await self.mt.initialize()
|
||||
login = await self.mt.login()
|
||||
if not login:
|
||||
logger.warning(f"Unable to sign in to MetaTrder 5 Terminal")
|
||||
logger.critical(f"Unable to sign in to MetaTrder 5 Terminal")
|
||||
raise SystemExit
|
||||
logger.info("Login Successful")
|
||||
await self.init_strategies()
|
||||
self.add_coroutine(self.config.task_queue.start)
|
||||
self.add_coroutine(coroutine=self.config.task_queue.run)
|
||||
except Exception as err:
|
||||
logger.error(f"{err}. Bot initialization failed")
|
||||
raise SystemExit
|
||||
|
||||
def add_function(self, func: Callable[..., ...], **kwargs: dict):
|
||||
def add_function(self, *, function: Callable[..., ...], **kwargs: dict):
|
||||
"""Add a function to the executor.
|
||||
|
||||
Args:
|
||||
func (Callable): A function to be executed
|
||||
function (Callable): A function to be executed
|
||||
**kwargs (dict): Keyword arguments for the function
|
||||
"""
|
||||
self.executor.add_function(func, kwargs)
|
||||
self.executor.add_function(function=function, kwargs=kwargs)
|
||||
|
||||
def add_coroutine(self, coro: Coroutine[..., ...], **kwargs):
|
||||
def add_coroutine(self, *, coroutine: Callable[..., ...] | Coroutine, **kwargs):
|
||||
"""Add a coroutine to the executor.
|
||||
|
||||
Args:
|
||||
coro (Coroutine): A coroutine to be executed
|
||||
coroutine (Coroutine): A coroutine to be executed
|
||||
**kwargs (dict): keyword arguments for the coroutine
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
self.executor.add_coroutine(coro, kwargs)
|
||||
self.executor.add_coroutine(coroutine=coroutine, kwargs=kwargs)
|
||||
|
||||
def execute(self):
|
||||
"""Execute the bot."""
|
||||
@@ -92,7 +93,7 @@ class Bot:
|
||||
await self.initialize()
|
||||
await self.executor.execute()
|
||||
|
||||
def add_strategy(self, strategy: Strategy):
|
||||
def add_strategy(self, *, strategy: Strategy):
|
||||
"""Add a strategy to the executor. An added strategy will only run if it's symbol was successfully initialized.
|
||||
|
||||
Args:
|
||||
@@ -101,15 +102,15 @@ class Bot:
|
||||
Notes:
|
||||
Make sure the symbol has been added to the market
|
||||
"""
|
||||
self.executor.add_worker(strategy)
|
||||
self.executor.add_strategy(strategy=strategy)
|
||||
|
||||
def add_strategies(self, strategies: Iterable[Strategy]):
|
||||
def add_strategies(self, *, strategies: Iterable[Strategy]):
|
||||
"""Add multiple strategies at the same time
|
||||
|
||||
Args:
|
||||
strategies: A list of strategies
|
||||
"""
|
||||
[self.add_strategy(strategy) for strategy in strategies]
|
||||
[self.add_strategy(strategy=strategy) for strategy in strategies]
|
||||
|
||||
def add_strategy_all(self, *, strategy: Type[Strategy], params: dict | None = None,
|
||||
symbols: list[Symbol] = None, **kwargs):
|
||||
@@ -122,21 +123,21 @@ class Bot:
|
||||
**kwargs: Additional keyword arguments for the strategy
|
||||
"""
|
||||
[
|
||||
self.add_strategy(strategy(symbol=symbol, params=params, **kwargs))
|
||||
self.add_strategy(strategy=strategy(symbol=symbol, params=params, **kwargs))
|
||||
for symbol in symbols
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def init_strategy(strategy: Strategy) -> tuple[bool, Strategy]:
|
||||
async def init_strategy(*, strategy: Strategy) -> tuple[bool, Strategy]:
|
||||
"""Initialize a single strategy. This method is called internally by the bot."""
|
||||
res = await strategy.symbol.init()
|
||||
return res, strategy
|
||||
|
||||
async def init_strategies(self):
|
||||
"""Initialize the symbols for the current trading session. This method is called internally by the bot."""
|
||||
tasks = [self.init_strategy(strategy) for strategy in self.executor.workers]
|
||||
tasks = [self.init_strategy(strategy=strategy) for strategy in self.executor.strategy_runners]
|
||||
for task in asyncio.as_completed(tasks):
|
||||
res = await task
|
||||
if not res[0]:
|
||||
logger.warning(f"Failed to initialize symbol {res[1].symbol}")
|
||||
self.executor.workers.remove(res[1])
|
||||
self.executor.strategy_runners.remove(res[1])
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Candle and Candles classes for handling bars from the MetaTrader 5 terminal."""
|
||||
|
||||
from typing import Type, TypeVar, Generic, Iterable
|
||||
import time
|
||||
from typing import Type, Self, Iterable
|
||||
from logging import getLogger
|
||||
|
||||
from pandas import DataFrame, Series
|
||||
@@ -8,7 +8,7 @@ import pandas as pd
|
||||
import pandas_ta as ta
|
||||
import mplfinance as mplt
|
||||
|
||||
from .core.constants import TimeFrame
|
||||
from ..core.constants import TimeFrame
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -47,8 +47,11 @@ class Candle:
|
||||
"""
|
||||
if not all(i in kwargs for i in ['open', 'high', 'low', 'close']):
|
||||
raise ValueError("Candle must be instantiated with open, high, low and close prices")
|
||||
self.time = kwargs.pop('time', 0)
|
||||
self.time = kwargs.pop('time', time.monotonic_ns())
|
||||
self.Index = kwargs.pop('Index', 0)
|
||||
self.real_volume = kwargs.pop('real_volume', 0)
|
||||
self.spread = kwargs.pop('spread', 0)
|
||||
self.tick_volume = kwargs.pop('tick_volume', 0)
|
||||
self.set_attributes(**kwargs)
|
||||
|
||||
def __repr__(self):
|
||||
@@ -56,27 +59,32 @@ class Candle:
|
||||
% {"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 str(self.dict())
|
||||
def __eq__(self, other: Self):
|
||||
return self.time == other.time
|
||||
|
||||
def __eq__(self, other: "Candle"):
|
||||
eq = self.open == other.open and self.high == other.high and self.low == other.low and self.close == other.close
|
||||
return eq
|
||||
def __lt__(self, other: Self):
|
||||
return self.time < other.time
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.time)
|
||||
|
||||
def __lt__(self, other: "Candle"):
|
||||
return self.time < other.time
|
||||
|
||||
def __gt__(self, other: "Candle"):
|
||||
return self.time > other.time
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self.__dict__[item]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.__dict__[key] = value
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.__dict__.items())
|
||||
|
||||
def keys(self):
|
||||
return self.__dict__.keys()
|
||||
|
||||
def values(self):
|
||||
return self.__dict__.values()
|
||||
|
||||
def set_attributes(self, **kwargs):
|
||||
"""Set keyword arguments as instance attributes
|
||||
"""Set keyword arguments as instance attributes and values.
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Instance attributes and values as keyword arguments
|
||||
@@ -99,7 +107,7 @@ class Candle:
|
||||
"""
|
||||
return self.open > self.close
|
||||
|
||||
def dict(self, exclude: set = None, include: set = None) -> dict:
|
||||
def dict(self, *, exclude: set = None, include: set = None) -> dict:
|
||||
"""
|
||||
Returns a dictionary of the instance attributes.
|
||||
|
||||
@@ -112,14 +120,10 @@ class Candle:
|
||||
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}
|
||||
return {k: v for k, v in self if k in keys}
|
||||
|
||||
|
||||
_Candle = TypeVar("_Candle", bound=Candle)
|
||||
_Candles = TypeVar("_Candles", bound="Candles")
|
||||
|
||||
|
||||
class Candles(Generic[_Candle]):
|
||||
class Candles:
|
||||
"""An iterable container class of Candle objects in chronological order.
|
||||
|
||||
Attributes:
|
||||
@@ -155,7 +159,7 @@ class Candles(Generic[_Candle]):
|
||||
timeframe: TimeFrame
|
||||
_data: DataFrame
|
||||
|
||||
def __init__(self, *, data: DataFrame | _Candles | Iterable, flip=False, candle_class: Type[_Candle] = None):
|
||||
def __init__(self, *, data: DataFrame | Self | Iterable, flip=False, candle_class: Candle = None):
|
||||
"""A container class of Candle objects in chronological order.
|
||||
|
||||
Args:
|
||||
@@ -183,10 +187,10 @@ class Candles(Generic[_Candle]):
|
||||
def __len__(self):
|
||||
return len(self._data.index)
|
||||
|
||||
def __contains__(self, item: _Candle):
|
||||
def __contains__(self, item: Self):
|
||||
return item.time == self[item.Index].time
|
||||
|
||||
def __getitem__(self, index) -> _Candle | _Candles | Series:
|
||||
def __getitem__(self, index) -> Self | Self | Series:
|
||||
if isinstance(index, slice):
|
||||
cls = self.__class__
|
||||
data = self._data.iloc[index]
|
||||
@@ -212,6 +216,7 @@ class Candles(Generic[_Candle]):
|
||||
def __getattr__(self, item):
|
||||
if item in self._data.columns:
|
||||
return self._data[item]
|
||||
|
||||
if item == 'Index':
|
||||
return Series(self._data.index)
|
||||
raise AttributeError(f"Attribute {item} not defined on class {self.__class__.__name__}")
|
||||
@@ -224,6 +229,10 @@ class Candles(Generic[_Candle]):
|
||||
tf = self.time[1] - self.time[0]
|
||||
return TimeFrame.get(abs(tf))
|
||||
|
||||
@property
|
||||
def columns(self) -> DataFrame:
|
||||
return self._data.columns
|
||||
|
||||
@property
|
||||
def ta(self):
|
||||
"""Access to the pandas_ta library for performing technical analysis on the underlying data attribute.
|
||||
@@ -247,7 +256,7 @@ class Candles(Generic[_Candle]):
|
||||
"""The original data passed to the class as a pandas DataFrame"""
|
||||
return self._data
|
||||
|
||||
def rename(self, inplace=True, **kwargs) -> _Candles:
|
||||
def rename(self, inplace=True, **kwargs) -> Self:
|
||||
"""Rename columns of the candles class.
|
||||
|
||||
Keyword Args:
|
||||
@@ -274,13 +283,13 @@ class Candles(Generic[_Candle]):
|
||||
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,
|
||||
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
|
||||
_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.
|
||||
@@ -290,7 +299,7 @@ class Candles(Generic[_Candle]):
|
||||
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}
|
||||
('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)
|
||||
@@ -13,49 +13,50 @@ class Executor:
|
||||
|
||||
Attributes:
|
||||
executor (ThreadPoolExecutor): The executor object.
|
||||
workers (list): List of strategies.
|
||||
strategy_runners (list): List of strategies.
|
||||
coroutines (dict[Coroutine, dict]): A dictionary of coroutines to run in the executor
|
||||
functions (dict[Callable, dict]): A dictionary of functions to run in the executor
|
||||
loop (asyncio.AbstractEventLoop): The event loop
|
||||
"""
|
||||
loop: asyncio.AbstractEventLoop
|
||||
|
||||
def __init__(self):
|
||||
self.executor = ThreadPoolExecutor
|
||||
self.workers: list[Strategy] = []
|
||||
self.strategy_runners: list[Strategy] = []
|
||||
self.coroutines: dict[Coroutine | Callable: dict] = {}
|
||||
self.functions: dict[Callable: dict] = {}
|
||||
|
||||
def add_function(self, func: Callable, kwargs: dict):
|
||||
self.functions[func] = kwargs
|
||||
def add_function(self, *, function: Callable, kwargs: dict):
|
||||
self.functions[function] = kwargs
|
||||
|
||||
def add_coroutine(self, coro: Coroutine, kwargs: dict):
|
||||
self.coroutines[coro] = kwargs
|
||||
def add_coroutine(self, *, coroutine: Coroutine, kwargs: dict):
|
||||
self.coroutines[coroutine] = kwargs
|
||||
|
||||
def add_workers(self, strategies: tuple[Strategy]):
|
||||
def add_strategies(self, *, strategies: tuple[Strategy]):
|
||||
"""Add multiple strategies at once
|
||||
|
||||
Args:
|
||||
strategies (Sequence[Strategy]): A sequence of strategies.
|
||||
"""
|
||||
self.workers.extend(strategies)
|
||||
self.strategy_runners.extend(strategies)
|
||||
|
||||
def add_worker(self, strategy: Strategy):
|
||||
def add_strategy(self, *, strategy: Strategy):
|
||||
"""Add a strategy instance to the list of workers
|
||||
|
||||
Args:
|
||||
strategy (Strategy): A strategy object
|
||||
"""
|
||||
self.workers.append(strategy)
|
||||
self.strategy_runners.append(strategy)
|
||||
|
||||
@staticmethod
|
||||
def trade(strategy: Strategy):
|
||||
def run_strategy(self, strategy: Strategy):
|
||||
"""Wraps the coroutine trade method of each strategy with 'asyncio.run'.
|
||||
|
||||
Args:
|
||||
strategy (Strategy): A strategy object
|
||||
"""
|
||||
asyncio.run(strategy.trade())
|
||||
self.loop.run_until_complete(strategy.run_strategy())
|
||||
|
||||
def run(self, func, kwargs: dict):
|
||||
def run_coroutine(self, func, kwargs: dict):
|
||||
"""
|
||||
Run a coroutine function
|
||||
|
||||
@@ -64,11 +65,11 @@ class Executor:
|
||||
kwargs: A dictionary of keyword arguments for the function
|
||||
"""
|
||||
try:
|
||||
asyncio.run(func(**kwargs))
|
||||
self.loop.run_until_complete(func(**kwargs))
|
||||
except Exception as err:
|
||||
logger.error(f'Error: {err}. Unable to run function')
|
||||
|
||||
async def execute(self, workers: int = 5):
|
||||
async def execute(self, *, workers: int = 5):
|
||||
"""Run the strategies with a threadpool executor.
|
||||
|
||||
Args:
|
||||
@@ -77,10 +78,11 @@ class Executor:
|
||||
Notes:
|
||||
No matter the number specified, the executor will always use a minimum of 5 workers.
|
||||
"""
|
||||
workers_ = sum([len(self.workers), len(self.functions), len(self.coroutines)])
|
||||
workers_ = sum([len(self.strategy_runners), len(self.functions), len(self.coroutines)])
|
||||
workers = max(workers, workers_)
|
||||
loop = asyncio.get_running_loop()
|
||||
self.loop = asyncio.get_running_loop()
|
||||
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()]
|
||||
[self.loop.run_in_executor(executor, self.run_strategy, worker) for worker in self.strategy_runners]
|
||||
[self.loop.run_in_executor(executor, self.run_coroutine, coroutine, kwargs) for coroutine,
|
||||
kwargs in self.coroutines.items()]
|
||||
[self.loop.run_in_executor(executor, function, kwargs) for function, kwargs in self.functions.items()]
|
||||
@@ -6,11 +6,11 @@ import pytz
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
|
||||
from .core.config import Config
|
||||
from .core.meta_trader import MetaTrader, CopyTicks, OrderType
|
||||
from .core.models import TradeDeal, TradeOrder
|
||||
from .contrib.backtester.meta_tester import MetaTester
|
||||
from .utils import backoff_decorator
|
||||
from ..core.config import Config
|
||||
from ..core.meta_trader import MetaTrader, CopyTicks, OrderType
|
||||
from ..core.models import TradeDeal, TradeOrder
|
||||
from ..core.meta_backtester import MetaBackTester
|
||||
from .._utils import backoff_decorator
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -27,7 +27,7 @@ class History:
|
||||
mt5 (MetaTrader): MetaTrader instance
|
||||
config (Config): Config instance
|
||||
"""
|
||||
mt5: MetaTrader | MetaTester
|
||||
mt5: MetaTrader | MetaBackTester
|
||||
config: Config
|
||||
|
||||
def __init__(self, *, date_from: datetime | int, date_to: datetime | int, group: str = '', use_utc: bool = True):
|
||||
@@ -42,11 +42,11 @@ class History:
|
||||
group (str): Filter for selecting history by symbols. Defaults to an empty string
|
||||
"""
|
||||
self.config = Config()
|
||||
self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester()
|
||||
self.mt5 = MetaTrader() if self.config.mode != 'backtest' else MetaBackTester()
|
||||
date_from = date_from if isinstance(date_from, datetime) else datetime.fromtimestamp(date_from)
|
||||
date_to = date_to if isinstance(date_to, datetime) else datetime.fromtimestamp(date_to)
|
||||
self.date_from = date_from.astimezone(pytz.utc) if use_utc else date_from
|
||||
self.date_to = date_to.astimezone(pytz.utc) if use_utc else date_to
|
||||
self.date_from = date_from.astimezone(pytz.UTC) if use_utc else date_from
|
||||
self.date_to = date_to.astimezone(pytz.UTC) if use_utc else date_to
|
||||
self.group = group
|
||||
self.deals: tuple[TradeDeal, ...] = ()
|
||||
self.orders: tuple[TradeOrder, ...] = ()
|
||||
@@ -111,12 +111,12 @@ class History:
|
||||
logger.warning(f'Failed to get orders')
|
||||
return tuple()
|
||||
|
||||
def get_orders_by_ticket(self, ticket: int) -> tuple[TradeOrder, ...]:
|
||||
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, ...]:
|
||||
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))
|
||||
@@ -1,15 +1,15 @@
|
||||
from logging import getLogger
|
||||
|
||||
from .core.models import TradeRequest, TradeOrder
|
||||
from .core.constants import TradeAction, OrderTime, OrderFilling
|
||||
from .core.exceptions import OrderError
|
||||
from .utils import backoff_decorator, error_handler
|
||||
from MetaTrader5 import OrderCheckResult, OrderSendResult
|
||||
from ..core.models import TradeRequest, TradeOrder, OrderCheckResult, OrderSendResult
|
||||
from ..core.constants import TradeAction, OrderTime, OrderFilling
|
||||
from ..core.exceptions import OrderError
|
||||
from ..core.base import _Base
|
||||
from .._utils import backoff_decorator, error_handler
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Order(TradeRequest):
|
||||
class Order(_Base, TradeRequest):
|
||||
"""Trade order related functions and properties. Subclass of TradeRequest."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
@@ -25,7 +25,7 @@ class Order(TradeRequest):
|
||||
type_time (OrderTime.DAY): Order time
|
||||
type_filling (OrderFilling.FOK): Order filling
|
||||
"""
|
||||
kwargs = {'action': TradeAction.DEAL, OrderTime.DAY: self.type_time, 'type_filling': OrderFilling.FOK, **kwargs}
|
||||
kwargs = {'action': TradeAction.DEAL, 'type_time': OrderTime.DAY, 'type_filling': OrderFilling.FOK, **kwargs}
|
||||
super().__init__(**kwargs)
|
||||
|
||||
async def orders_total(self):
|
||||
@@ -81,7 +81,7 @@ class Order(TradeRequest):
|
||||
res = await self.mt5.order_check(req)
|
||||
if res is None:
|
||||
raise OrderError(f'Order check failed for {self.symbol}')
|
||||
return res
|
||||
return OrderCheckResult(**res._asdict())
|
||||
|
||||
@backoff_decorator
|
||||
async def send(self) -> OrderSendResult:
|
||||
@@ -96,7 +96,7 @@ class Order(TradeRequest):
|
||||
res = await self.mt5.order_send(self.dict)
|
||||
if res is None:
|
||||
raise OrderError(f'Failed to send order {self.symbol}')
|
||||
return res
|
||||
return OrderSendResult(**res._asdict())
|
||||
|
||||
async def calc_margin(self) -> float | None:
|
||||
"""Return the required margin in the account currency to perform a specified trading operation.
|
||||
@@ -2,13 +2,14 @@
|
||||
import asyncio
|
||||
from logging import getLogger
|
||||
|
||||
from .core.meta_trader import MetaTrader
|
||||
from .core.models import TradePosition, OrderSendResult
|
||||
from .core.constants import OrderType, TradeAction
|
||||
from .core.config import Config
|
||||
from .contrib.backtester.meta_tester import MetaTester
|
||||
from ..core.meta_trader import MetaTrader
|
||||
from ..core.models import TradePosition, OrderSendResult
|
||||
from ..core.constants import OrderType, TradeAction
|
||||
from ..core.config import Config
|
||||
from .._utils import backoff_decorator
|
||||
from ..core.meta_backtester import MetaBackTester
|
||||
from .order import Order
|
||||
from .utils import backoff_decorator
|
||||
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -19,13 +20,13 @@ class Positions:
|
||||
Attributes:
|
||||
mt5 (MetaTrader): MetaTrader instance.
|
||||
"""
|
||||
mt5: MetaTrader | MetaTester
|
||||
mt5: MetaTrader | MetaBackTester
|
||||
positions: tuple[TradePosition, ...]
|
||||
|
||||
def __init__(self):
|
||||
"""Get Open Positions"""
|
||||
self.config = Config()
|
||||
self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester()
|
||||
self.mt5 = MetaTrader() if self.config.mode != 'backtest' else MetaBackTester()
|
||||
self.positions = ()
|
||||
|
||||
@backoff_decorator
|
||||
@@ -68,7 +69,7 @@ class Positions:
|
||||
return tuple(TradePosition(**pos._asdict()) for pos in (positions or ()))
|
||||
|
||||
@staticmethod
|
||||
async def close(ticket: int, symbol: str, price: float, volume: float, order_type: OrderType) -> OrderSendResult:
|
||||
async def close(*, ticket: int, symbol: str, price: float, volume: float, order_type: OrderType) -> OrderSendResult:
|
||||
"""Close an open position for the trading account using the ticket and other parameters.
|
||||
|
||||
Args:
|
||||
@@ -83,14 +84,14 @@ class Positions:
|
||||
return await order.send()
|
||||
|
||||
@staticmethod
|
||||
async def close_by(pos: TradePosition) -> OrderSendResult:
|
||||
async def close_by(*, position: TradePosition) -> OrderSendResult:
|
||||
"""Close an open position for the trading account."""
|
||||
order = Order(position=pos.ticket, symbol=pos.symbol, volume=pos.volume, type=pos.type.opposite,
|
||||
price=pos.price_current, action=TradeAction.DEAL)
|
||||
order = Order(position=position.ticket, symbol=position.symbol, volume=position.volume,
|
||||
type=position.type.opposite, price=position.price_current, action=TradeAction.DEAL)
|
||||
return await order.send()
|
||||
|
||||
@staticmethod
|
||||
async def close_position(position: TradePosition):
|
||||
async def close_position(*, position: TradePosition):
|
||||
"""Close an open position for the trading account. Using a position object."""
|
||||
order = Order(position=position.ticket, symbol=position.symbol, volume=position.volume,
|
||||
type=position.type.opposite, price=position.price_current, action=TradeAction.DEAL)
|
||||
@@ -103,6 +104,6 @@ class Positions:
|
||||
int: Return number of positions closed.
|
||||
"""
|
||||
positions = self.positions or await self.get_positions()
|
||||
results = await asyncio.gather(*(self.close_position(position) for position in positions),
|
||||
results = await asyncio.gather(*(self.close_position(position=position) for position in positions),
|
||||
return_exceptions=True)
|
||||
return len([res for res in results if (isinstance(res, OrderSendResult) and res.retcode == 10009)])
|
||||
@@ -7,23 +7,27 @@ class RAM:
|
||||
account: Account
|
||||
risk_to_reward: float
|
||||
risk: float
|
||||
min_amount: float = 0
|
||||
max_amount: float = 0
|
||||
loss_limit: int = 3
|
||||
open_limit: int = 5
|
||||
min_amount: float
|
||||
max_amount: float
|
||||
loss_limit: int
|
||||
open_limit: int
|
||||
|
||||
def __init__(self, *, risk_to_reward: float = 1, risk: float = 0.01, **kwargs):
|
||||
def __init__(self, **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%
|
||||
risk (float): Percentage of capital to risk per trade 0.01 # 1%
|
||||
kwargs: extra keyword arguments are set as object attributes
|
||||
"""
|
||||
self.risk_to_reward = risk_to_reward
|
||||
self.risk = risk
|
||||
self.account = Account()
|
||||
[setattr(self, key, value) for key, value in kwargs.items()]
|
||||
self.positions = Positions()
|
||||
self.risk_to_reward = kwargs.get('risk_to_reward', 1)
|
||||
self.risk = kwargs.get('risk', 0.01)
|
||||
self.min_amount = kwargs.get('min_amount', 0)
|
||||
self.max_amount = kwargs.get('max_amount', 0)
|
||||
self.loss_limit = kwargs.get('loss_limit', 1)
|
||||
self.open_limit = kwargs.get('open_limit', 1)
|
||||
|
||||
async def get_amount(self) -> float:
|
||||
"""Calculate the amount to risk per trade as a percentage of margin_free.
|
||||
@@ -38,14 +42,14 @@ class RAM:
|
||||
return amount
|
||||
|
||||
async def check_losing_positions(self) -> bool:
|
||||
"""Check if the number of losing positions is greater than or equal the loss limit
|
||||
"""Check if the number of losing positions is greater than the loss limit
|
||||
|
||||
Returns:
|
||||
bool: True if the number of losing positions is less than the loss limit
|
||||
bool: True if the number of losing positions is less than or equal the loss limit
|
||||
"""
|
||||
positions = await Positions().get_positions()
|
||||
positions = await self.positions.get_positions()
|
||||
loosing = [position for position in positions if position.profit < 0]
|
||||
return len(loosing) < self.loss_limit
|
||||
return len(loosing) <= self.loss_limit
|
||||
|
||||
async def check_open_positions(self) -> bool:
|
||||
"""Check if the number of open positions is greater than or equal the loss limit.
|
||||
@@ -53,5 +57,5 @@ class RAM:
|
||||
Returns:
|
||||
bool: True if the number of open positions is less than the open limit
|
||||
"""
|
||||
positions = await Positions().get_positions()
|
||||
return len(positions) < self.open_limit
|
||||
positions = await self.positions.get_positions()
|
||||
return len(positions) <= self.open_limit
|
||||
@@ -4,10 +4,8 @@ from logging import getLogger
|
||||
from typing import Iterable, Literal
|
||||
from asyncio import Lock
|
||||
|
||||
from _typeshed import SupportsWrite, SupportsRead
|
||||
|
||||
from .core.config import Config
|
||||
from .core.models import OrderSendResult
|
||||
from ..core.config import Config
|
||||
from ..core.models import OrderSendResult
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -22,7 +20,7 @@ class Result:
|
||||
"""
|
||||
config: Config
|
||||
|
||||
def __init__(self, result: OrderSendResult, parameters: dict = None, name: str = ''):
|
||||
def __init__(self, *, result: OrderSendResult, parameters: dict = None, name: str = ''):
|
||||
"""
|
||||
Prepare result data
|
||||
Args:
|
||||
@@ -33,7 +31,7 @@ class Result:
|
||||
self.config = Config()
|
||||
self.parameters = parameters or {}
|
||||
self.result = result
|
||||
self.name = name or parameters.get('name', 'Trades')
|
||||
self.name = name or self.parameters.get('name', 'Trades')
|
||||
self.lock = Lock()
|
||||
|
||||
def get_data(self) -> dict:
|
||||
@@ -48,8 +46,10 @@ class Result:
|
||||
trade_record_mode = trade_record_mode or self.config.trade_record_mode
|
||||
if trade_record_mode == 'csv':
|
||||
await self.to_csv()
|
||||
else:
|
||||
elif trade_record_mode == 'json':
|
||||
await self.to_json()
|
||||
else:
|
||||
logger.error(f"Invalid trade record mode: {trade_record_mode}")
|
||||
|
||||
async def to_csv(self):
|
||||
"""Record trade results and associated parameters as a csv file
|
||||
@@ -61,13 +61,13 @@ class Result:
|
||||
file.touch(exist_ok=True) if not file.exists() else ...
|
||||
read_file = file.open('r', newline='')
|
||||
reader: Iterable[dict] = csv.DictReader(read_file)
|
||||
read_file.close()
|
||||
rows: list[dict] = []
|
||||
headers = set()
|
||||
[(rows.append(row), headers.update(row.keys())) for row in reader]
|
||||
rows.append(data)
|
||||
headers.update(data.keys())
|
||||
with file.open('w', newline='') as write_file: # type: SupportsWrite[str]
|
||||
read_file.close()
|
||||
with file.open('w', newline='') as write_file:
|
||||
writer = csv.DictWriter(write_file, fieldnames=headers, restval=None, extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
@@ -93,12 +93,16 @@ class Result:
|
||||
try:
|
||||
file = self.config.records_dir / f"{self.name}.json"
|
||||
data = self.get_data()
|
||||
file.touch(exist_ok=True) if not file.exists() else ...
|
||||
with file.open('r') as fh: # type: SupportsRead[str]
|
||||
if not file.exists():
|
||||
file.touch()
|
||||
with file.open('w') as fh:
|
||||
json.dump([], fh, indent=2)
|
||||
|
||||
with file.open('r') as fh:
|
||||
rows = json.load(fh)
|
||||
rows.append(data)
|
||||
|
||||
with file.open('w') as fh: # type: SupportsWrite[str]
|
||||
with file.open('w') as fh:
|
||||
json.dump(rows, fh, indent=2, skipkeys=True, default=self.serialize)
|
||||
|
||||
except Exception as err:
|
||||
@@ -1,19 +1,24 @@
|
||||
import asyncio
|
||||
from datetime import time, timedelta, datetime
|
||||
from typing import Literal, Callable, Iterable
|
||||
from typing import Literal, Callable, Iterable, NamedTuple
|
||||
from logging import getLogger
|
||||
|
||||
import pytz
|
||||
|
||||
from . import TradePosition
|
||||
from .core.models import OrderSendResult
|
||||
from ..core.models import OrderSendResult, TradePosition
|
||||
from ..core.config import Config
|
||||
from ..core.event_manager import EventManager
|
||||
from .positions import Positions
|
||||
from .core.config import Config
|
||||
from.contrib.backtester.event_manager import EventManager
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Duration(NamedTuple):
|
||||
hours: int
|
||||
minutes: int
|
||||
seconds: int
|
||||
|
||||
|
||||
def delta(obj: time) -> timedelta:
|
||||
"""Get the timedelta of a datetime.time object.
|
||||
|
||||
@@ -73,19 +78,9 @@ class Session:
|
||||
self.config = Config()
|
||||
|
||||
def __contains__(self, item: time):
|
||||
if self.start > self.end:
|
||||
end = timedelta(days=1, hours=self.start.hour, minutes=self.start.minute, seconds=self.start.second,
|
||||
microseconds=self.start.microsecond)
|
||||
start = delta(self.start)
|
||||
if item < self.start and item < self.end:
|
||||
item = timedelta(days=1, hours=item.hour, minutes=item.minute, seconds=item.second,
|
||||
microseconds=item.microsecond)
|
||||
else:
|
||||
item = delta(item)
|
||||
else:
|
||||
start = delta(self.start)
|
||||
end = delta(self.end)
|
||||
return start <= item < end
|
||||
span = (delta(self.end) - delta(self.start)).seconds
|
||||
item_span = (delta(self.end) - delta(item)).seconds
|
||||
return item_span <= span
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.start}<-->{self.end}'
|
||||
@@ -94,7 +89,7 @@ class Session:
|
||||
return f'{self.start}<-->{self.end}'
|
||||
|
||||
def __len__(self):
|
||||
return (delta(self.start) - delta(self.end)).seconds
|
||||
return int((delta(self.end) - delta(self.start)).seconds)
|
||||
|
||||
def in_session(self) -> bool:
|
||||
"""Check if the current time is within the session."""
|
||||
@@ -110,9 +105,15 @@ class Session:
|
||||
"""Call the action specified in on_end or custom_end."""
|
||||
await self.action(action=self.on_end)
|
||||
|
||||
def duration(self) -> Duration:
|
||||
"""Get the duration of the session in seconds."""
|
||||
hours, seconds = divmod(len(self), 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
return Duration(hours=hours, minutes=minutes, seconds=seconds)
|
||||
|
||||
async def close_positions(self, *, positions: tuple[TradePosition, ...]):
|
||||
|
||||
results = asyncio.gather(*(self.positions_manager.close_position(pos) for pos in positions),
|
||||
results = asyncio.gather(*(self.positions_manager.close_position(position=position) for position in positions),
|
||||
return_exceptions=True)
|
||||
closed = pending = 0
|
||||
for result in results:
|
||||
@@ -169,7 +170,7 @@ class Session:
|
||||
"""Get the seconds until the session starts from the current time in seconds."""
|
||||
if self.config.mode == 'backtest':
|
||||
now = datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=pytz.UTC).time()
|
||||
secs = delta(self.start) - delta(now)
|
||||
secs = (delta(self.start) - delta(now)).seconds
|
||||
else:
|
||||
secs = (delta(self.start) - delta(datetime.now(tz=pytz.UTC).time())).seconds
|
||||
return secs
|
||||
@@ -189,6 +190,7 @@ class Sessions:
|
||||
check: Check if the current session has started and if not, wait until it starts.
|
||||
"""
|
||||
sessions: list[Session]
|
||||
current_session: Session | None
|
||||
|
||||
def __init__(self, *, sessions: Iterable[Session]):
|
||||
self.sessions = list(sessions)
|
||||
@@ -196,43 +198,47 @@ class Sessions:
|
||||
self.current_session = None
|
||||
self.config = Config()
|
||||
|
||||
def find(self, obj: time) -> Session | None:
|
||||
"""Find a session that contains a datetime.time object.
|
||||
def find(self, *, moment: time = None) -> Session | None:
|
||||
"""Find a session that contains a datetime.time object, if not found return None.
|
||||
|
||||
Args:
|
||||
obj (datetime.time): A datetime.time object.
|
||||
Keyword Args:
|
||||
moment (datetime.time | None): A datetime.time object. if not provided, the current time is used.
|
||||
|
||||
Returns:
|
||||
Session | None: A Session object or None if not found.
|
||||
"""
|
||||
moment = moment or datetime.now(tz=pytz.UTC).time() if self.config.mode == 'live' else (
|
||||
datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=pytz.UTC).time())
|
||||
for session in self.sessions:
|
||||
if obj in session:
|
||||
if moment in session:
|
||||
return session
|
||||
return None
|
||||
|
||||
def find_next(self, obj: time) -> Session:
|
||||
def find_next(self, *, moment: time = None) -> Session:
|
||||
"""Find the next session that contains a datetime.time object.
|
||||
|
||||
Args:
|
||||
obj (datetime.time): A datetime.time object.
|
||||
moment (datetime.time | None): A datetime.time object, if not provided, the current time is used.
|
||||
|
||||
Returns:
|
||||
Session: A Session object.
|
||||
"""
|
||||
moment = moment or datetime.now(tz=pytz.UTC).time() if self.config.mode == 'live' else (
|
||||
datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=pytz.UTC).time())
|
||||
for session in self.sessions:
|
||||
if delta(obj) < delta(session.start):
|
||||
if delta(moment) < delta(session.start):
|
||||
return session
|
||||
return self.sessions[0]
|
||||
|
||||
def __contains__(self, item: time):
|
||||
return True if self.find(item) is not None else False
|
||||
def __contains__(self, moment: time):
|
||||
return True if self.find(moment=moment) is not None else False
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.check()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.current_session.close()
|
||||
await self.current_session.close() if self.current_session is not None else ...
|
||||
|
||||
async def check(self):
|
||||
"""Check if the current session has started and if not, wait until it starts."""
|
||||
@@ -242,9 +248,9 @@ class Sessions:
|
||||
if self.config.mode == 'backtest':
|
||||
now = datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=pytz.UTC).time()
|
||||
else:
|
||||
now = datetime.now().time()
|
||||
now = datetime.now(tz=pytz.UTC).time()
|
||||
|
||||
next_session = self.find(now)
|
||||
next_session = self.find(moment=now)
|
||||
|
||||
if next_session and self.current_session is None:
|
||||
self.current_session = next_session
|
||||
@@ -255,11 +261,12 @@ class Sessions:
|
||||
await self.current_session.close()
|
||||
self.current_session = next_session
|
||||
await self.current_session.begin()
|
||||
return
|
||||
|
||||
if next_session is None and self.current_session is not None:
|
||||
await self.current_session.close()
|
||||
|
||||
next_session = self.find_next(now)
|
||||
next_session = self.find_next(moment=now)
|
||||
secs = next_session.until() + 10
|
||||
logger.info(f'sleeping for {secs} seconds until next {next_session} session')
|
||||
sleep_func = asyncio.sleep if self.config.mode == 'live' else backtest_sleep
|
||||
@@ -1,3 +0,0 @@
|
||||
from .finger_trap import FingerTrap
|
||||
from .tracker import Tracker
|
||||
# from .finger_trap_back_test import FingerTrapTest, FingerTrapSingleTest
|
||||
@@ -1,30 +0,0 @@
|
||||
from .finger_trap import FingerTrap
|
||||
from ...contrib.backtester.strategy_tester import StrategyTester
|
||||
|
||||
|
||||
class FingerTrapTest(StrategyTester, FingerTrap):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.set_up()
|
||||
|
||||
async def test(self):
|
||||
print(f"Backtesting {self.symbol}")
|
||||
while True:
|
||||
await self.event_manager.acquire()
|
||||
try:
|
||||
await self.event_manager.wait()
|
||||
await self.watch_market()
|
||||
|
||||
if not self.tracker.new:
|
||||
continue
|
||||
|
||||
if self.tracker.order_type is not None:
|
||||
await self.trader.place_trade(order_type=self.tracker.order_type, parameters=self.parameters,
|
||||
sl=self.tracker.sl)
|
||||
await self.sleep(self.tracker.snooze)
|
||||
except Exception as err:
|
||||
print(f"{err} For {self.symbol} in {self.__class__.__name__}.trade")
|
||||
await self.sleep(self.ttf.time)
|
||||
|
||||
finally:
|
||||
self.event_manager.release()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""The base class for creating strategies."""
|
||||
import asyncio
|
||||
from time import time
|
||||
from typing import TypeVar
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import time as dtime
|
||||
from logging import getLogger
|
||||
|
||||
from ..core.meta_trader import MetaTrader
|
||||
from ..core import Config
|
||||
from ..core.meta_backtester import MetaBackTester
|
||||
from ..core.event_manager import EventManager
|
||||
from .sessions import Sessions, Session
|
||||
from .symbol import Symbol as _Symbol
|
||||
|
||||
Symbol = TypeVar("Symbol", bound=_Symbol)
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Strategy(ABC):
|
||||
"""The base class for creating strategies.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the strategy.
|
||||
symbol (Symbol): The Financial Instrument as a Symbol Object
|
||||
parameters (Dict): A dictionary of parameters for the strategy.
|
||||
sessions (Sessions): The sessions to use for the strategy.
|
||||
running (bool): A flag to indicate if the strategy is running.
|
||||
|
||||
Notes:
|
||||
Define the name of a strategy as a class attribute. If not provided, the class name will be used as the name.
|
||||
"""
|
||||
name: str
|
||||
symbol: Symbol
|
||||
sessions: Sessions
|
||||
mt5: MetaTrader | MetaBackTester
|
||||
config: Config
|
||||
running: bool
|
||||
parameters = {}
|
||||
event_manager = EventManager
|
||||
current_session = Session
|
||||
|
||||
def __init__(self, *, symbol: Symbol, params: dict = None, sessions: Sessions = None, name=''):
|
||||
"""Initiate the parameters dict and add name and symbol fields.
|
||||
Use class name as strategy name if name is not provided
|
||||
|
||||
Args:
|
||||
symbol (Symbol): The Financial instrument
|
||||
params (Dict): Trading strategy parameters
|
||||
"""
|
||||
self.parameters = self.parameters | (params or {})
|
||||
self.symbol = symbol
|
||||
self.name = name or self.__class__.__name__
|
||||
self.parameters["symbol"] = symbol.name
|
||||
self.parameters["name"] = self.name
|
||||
self.running = True
|
||||
self.sessions = sessions or Sessions(sessions=[Session(start=0, end=dtime(hour=23, minute=59, second=59))])
|
||||
self.config = Config()
|
||||
self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaBackTester()
|
||||
self.event_manager = EventManager()
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.name}({self.symbol!r})"
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.parameters:
|
||||
return self.parameters[item]
|
||||
raise AttributeError(f'{item} not an attribute of {self.name}')
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if key in self.parameters:
|
||||
self.parameters[key] = value
|
||||
super().__setattr__(key, value)
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.sessions.check()
|
||||
self.current_session = self.sessions.current_session
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
try:
|
||||
await self.current_session.close() if self.current_session else ...
|
||||
self.running = False
|
||||
except Exception as err:
|
||||
logger.error(f"Error: {err}")
|
||||
|
||||
@staticmethod
|
||||
async def live_sleep(*, secs: float):
|
||||
"""Sleep for the needed amount of seconds in between requests to the terminal.
|
||||
computes the accurate amount of time needed to sleep ensuring that the next request is made at the start of
|
||||
a new bar and making cooperative multitasking possible.
|
||||
|
||||
Args:
|
||||
secs (float): The time in seconds. Usually the timeframe you are trading on.
|
||||
"""
|
||||
mod = time() % secs
|
||||
secs = secs - mod if mod != 0 else mod
|
||||
await asyncio.sleep(secs + 0.2)
|
||||
|
||||
async def sleep(self, *, secs: float):
|
||||
"""Sleep for the needed amount of seconds in between requests to the terminal.
|
||||
computes the accurate amount of time needed to sleep ensuring that the next request is made at the start of
|
||||
a new bar and making cooperative multitasking possible.
|
||||
|
||||
Args:
|
||||
secs (float): The time in seconds. Usually the timeframe you are trading on.
|
||||
"""
|
||||
if self.config.mode == 'live':
|
||||
await self.live_sleep(secs=secs)
|
||||
elif self.config.mode == 'backtest':
|
||||
await self.backtest_sleep(secs=secs)
|
||||
|
||||
async def backtest_sleep(self, *, secs: float):
|
||||
_time = self.config.backtest_engine.cursor.time
|
||||
mod = _time % secs
|
||||
secs = secs - mod if mod != 0 else mod
|
||||
|
||||
if self.event_manager.num_main_tasks == 1:
|
||||
self.config.backtest_engine.fast_forward(secs)
|
||||
await self.event_manager.wait()
|
||||
|
||||
elif self.event_manager.num_main_tasks > 1:
|
||||
_time = self.config.backtest_engine.cursor.time + secs
|
||||
while _time > self.config.backtest_engine.cursor.time:
|
||||
await self.event_manager.wait()
|
||||
else:
|
||||
await self.event_manager.wait()
|
||||
|
||||
async def run_strategy(self):
|
||||
"""Run the strategy."""
|
||||
if self.config.mode == 'live':
|
||||
await self.live_strategy()
|
||||
elif self.config.mode == 'backtest':
|
||||
await self.backtest_strategy()
|
||||
|
||||
async def live_strategy(self):
|
||||
"""Run the strategy."""
|
||||
while self.running:
|
||||
async with self as _:
|
||||
await self.sessions.check()
|
||||
await self.trade()
|
||||
|
||||
async def backtest_strategy(self):
|
||||
"""Backtest the strategy."""
|
||||
async with self as _:
|
||||
while self.running:
|
||||
async with self.event_manager.condition:
|
||||
await self.sessions.check()
|
||||
await self.event_manager.wait()
|
||||
await self.trade()
|
||||
|
||||
@abstractmethod
|
||||
async def trade(self):
|
||||
"""Place trades using this method. This is the main method of the strategy.
|
||||
It will be called by the strategy runner.
|
||||
"""
|
||||
raise NotImplementedError("Implement this method in your subclass")
|
||||
|
||||
async def test(self):
|
||||
await self.trade()
|
||||
@@ -3,19 +3,19 @@ import asyncio
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
|
||||
from .core.constants import TimeFrame, CopyTicks
|
||||
from .core.models import SymbolInfo, BookInfo
|
||||
from ..core.constants import TimeFrame, CopyTicks
|
||||
from ..core.base import _Base
|
||||
from ..core.models import SymbolInfo, BookInfo
|
||||
from .._utils import round_off, backoff_decorator
|
||||
from .ticks import Tick
|
||||
from .account import Account
|
||||
from .candle import Candles
|
||||
from .ticks import Ticks
|
||||
from .utils import round_off, backoff_decorator
|
||||
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Symbol(SymbolInfo):
|
||||
class Symbol(_Base, SymbolInfo):
|
||||
"""Main class for handling a financial instrument. A subclass of SymbolInfo it has attributes and methods
|
||||
for working with a financial instrument.
|
||||
|
||||
@@ -36,18 +36,10 @@ class Symbol(SymbolInfo):
|
||||
Args:
|
||||
name (str): Name of the financial instrument
|
||||
"""
|
||||
assert 'name' in kwargs, "Symbol Object Must be initialized with a name"
|
||||
super().__init__(**kwargs)
|
||||
self.account = Account()
|
||||
|
||||
@property
|
||||
def pip(self):
|
||||
"""Returns the pip value of the symbol. This is ten times the point value for forex symbols.
|
||||
|
||||
Returns:
|
||||
float: The pip value of the symbol.
|
||||
"""
|
||||
return self.point * 10
|
||||
|
||||
@backoff_decorator
|
||||
async def info_tick(self, *, name: str = "") -> Tick:
|
||||
"""Get the current price tick of a financial instrument.
|
||||
@@ -152,7 +144,7 @@ class Symbol(SymbolInfo):
|
||||
"""
|
||||
return await self.mt5.market_book_release(self.name)
|
||||
|
||||
def check_volume(self, volume) -> tuple[bool, float]:
|
||||
def check_volume(self, *, volume) -> tuple[bool, float]:
|
||||
"""Check if the volume is within the limits of the symbol. If not, return the nearest limit.
|
||||
|
||||
Args:
|
||||
@@ -167,7 +159,7 @@ class Symbol(SymbolInfo):
|
||||
else:
|
||||
return check, self.volume_min if volume <= self.volume_min else self.volume_max
|
||||
|
||||
def round_off_volume(self, volume: float, round_down: bool = False) -> float:
|
||||
def round_off_volume(self, *, volume: float, round_down: bool = False) -> float:
|
||||
"""Round off the volume to the nearest volume step.
|
||||
|
||||
Args:
|
||||
@@ -179,7 +171,7 @@ class Symbol(SymbolInfo):
|
||||
"""
|
||||
return round_off(value=volume, step=self.volume_step, round_down=round_down)
|
||||
|
||||
async def check_amount(self, amount: float) -> float:
|
||||
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
|
||||
@@ -212,27 +204,19 @@ class Symbol(SymbolInfo):
|
||||
|
||||
Returns:
|
||||
float: Amount in terms of the quote currency
|
||||
|
||||
Raises:
|
||||
ValueError: If conversion is impossible
|
||||
"""
|
||||
try:
|
||||
pair = f'{base}{quote}'
|
||||
if self.account.has_symbol(pair):
|
||||
tick = await self.info_tick(name=pair)
|
||||
if tick is not None:
|
||||
return amount / tick.ask
|
||||
tick = await self.info_tick(name=pair)
|
||||
if tick is not None:
|
||||
return amount / tick.ask
|
||||
|
||||
pair = f'{quote}{base}'
|
||||
if self.account.has_symbol(pair):
|
||||
tick = await self.info_tick(name=pair)
|
||||
if tick is not None:
|
||||
return amount * tick.bid
|
||||
tick = await self.info_tick(name=pair)
|
||||
if tick is not None:
|
||||
return amount * tick.bid
|
||||
except Exception as err:
|
||||
logger.warning(f'Currency conversion failed: Unable to convert {amount} in {quote} to {base}')
|
||||
raise ValueError(f'Currency Conversion Failed: {err}')
|
||||
else:
|
||||
logger.warning(f'Currency conversion failed: Unable to convert {amount} in {quote} to {base}')
|
||||
logger.warning(f'{err}: Currency conversion failed: Unable to convert {amount} in {quote} to {base}')
|
||||
|
||||
@backoff_decorator
|
||||
async def copy_rates_from(self, *, timeframe: TimeFrame,
|
||||
@@ -2,20 +2,22 @@
|
||||
from typing import NamedTuple
|
||||
from logging import getLogger
|
||||
|
||||
from .core.models import TerminalInfo
|
||||
from ..core.models import TerminalInfo
|
||||
from ..core.base import _Base
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
Version = NamedTuple("Version", (('version', str), ('build', int), ('release_date', str)))
|
||||
|
||||
class Terminal(TerminalInfo):
|
||||
|
||||
class Terminal(_Base, TerminalInfo):
|
||||
"""Terminal Class. Get information about the MetaTrader 5 terminal. The class is a subclass of the TerminalInfo
|
||||
class. It inherits all the attributes and methods of the TerminalInfo class and adds some useful methods.
|
||||
|
||||
Notes:
|
||||
Other attributes are defined in the TerminalInfo Class
|
||||
"""
|
||||
|
||||
Version = NamedTuple("Version", (('version', str), ('build', int), ('release_date', str)))
|
||||
version: Version | None = None
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Establish a connection with the MetaTrader 5 terminal. There are three call options. Call without parameters.
|
||||
@@ -26,30 +28,29 @@ class Terminal(TerminalInfo):
|
||||
Returns:
|
||||
bool: True if successful else False
|
||||
"""
|
||||
self.connected = await self.mt5.initialize(**self.config.account_info())
|
||||
|
||||
self.connected = await self.mt5.initialize()
|
||||
if not self.connected:
|
||||
err = await self.mt5.last_error()
|
||||
logger.critical(f'Failed to initialize Terminal. Error Code: {err}')
|
||||
raise SystemExit
|
||||
return self.connected
|
||||
logger.warning(f'Failed to initialize Terminal. Error Code: {err}')
|
||||
info = await self.info()
|
||||
await self.get_version()
|
||||
return bool(self.connected and info and self.version)
|
||||
|
||||
async def version(self):
|
||||
async def get_version(self) -> Version | None:
|
||||
"""Get the MetaTrader 5 terminal version. This method returns the terminal version, build and release date as
|
||||
a tuple of three values
|
||||
|
||||
Returns:
|
||||
Version: version of tuple as Version object
|
||||
|
||||
Raises:
|
||||
ValueError: If the terminal version cannot be obtained
|
||||
"""
|
||||
res = await self.mt5.version()
|
||||
if res is None:
|
||||
raise ValueError('Failed to get terminal version')
|
||||
return self.Version(*res)
|
||||
logger.error('Failed to get terminal version')
|
||||
return None
|
||||
self.version = Version(*res)
|
||||
return self.version
|
||||
|
||||
async def info(self):
|
||||
async def info(self) -> TerminalInfo | None:
|
||||
"""Get the connected MetaTrader 5 client terminal status and settings. gets terminal info in the form of a
|
||||
named tuple structure (namedtuple). Return None in case of an error. The info on the error can be
|
||||
obtained using last_error().
|
||||
@@ -58,7 +59,9 @@ class Terminal(TerminalInfo):
|
||||
Terminal: Terminal status and settings as a terminal object.
|
||||
"""
|
||||
info = await self.mt5.terminal_info()
|
||||
self.set_attributes(**info._asdict())
|
||||
if info:
|
||||
self.set_attributes(**info._asdict())
|
||||
return info
|
||||
|
||||
async def symbols_total(self) -> int:
|
||||
"""Get the number of all financial instruments in the MetaTrader 5 terminal.
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Module for working with price ticks."""
|
||||
|
||||
from typing import TypeVar, Iterable
|
||||
from typing import Iterable, Self
|
||||
import time
|
||||
|
||||
from pandas import DataFrame, Series
|
||||
import pandas_ta as ta
|
||||
import mplfinance as mplt
|
||||
import pandas as pd
|
||||
|
||||
from .core.constants import TickFlag
|
||||
|
||||
Self = TypeVar('Self', bound='Ticks')
|
||||
from ..core.constants import TickFlag
|
||||
|
||||
|
||||
class Tick:
|
||||
@@ -37,11 +36,13 @@ class Tick:
|
||||
Index: int
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize the Tick class. Set attributes from keyword arguments. bid, ask, last, time and volume must be
|
||||
"""Initialize the Tick class. Set attributes from keyword arguments. bid, ask, last and volume must be
|
||||
present"""
|
||||
if not all(key in kwargs for key in ['bid', 'ask', 'last', 'volume', 'time']):
|
||||
if not all(key in kwargs for key in ['bid', 'ask', 'last', 'volume']):
|
||||
raise ValueError("bid, ask, last and volume, time must be present in the keyword arguments")
|
||||
self.Index = kwargs.pop('Index', 0)
|
||||
self.time = kwargs.pop('time', time.monotonic())
|
||||
self.time_msc = int(self.time * 1000)
|
||||
self.set_attributes(**kwargs)
|
||||
|
||||
def __repr__(self):
|
||||
@@ -49,6 +50,30 @@ 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 __eq__(self, other: Self):
|
||||
return self.time == other.time
|
||||
|
||||
def __lt__(self, other: Self):
|
||||
return self.time < other.time
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.time)
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self.__dict__[item]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.__dict__[key] = value
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.__dict__.items())
|
||||
|
||||
def keys(self):
|
||||
return self.__dict__.keys()
|
||||
|
||||
def values(self):
|
||||
return self.__dict__.values()
|
||||
|
||||
def dict(self, exclude: set = None, include: set = None) -> dict:
|
||||
"""
|
||||
Returns a dictionary of the instance attributes.
|
||||
@@ -70,9 +95,6 @@ class Tick:
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
_Ticks = TypeVar('_Ticks', bound='Ticks')
|
||||
|
||||
|
||||
class Ticks:
|
||||
"""Container class for price ticks. Arrange in chronological order. Supports iteration, slicing and assignment"""
|
||||
time: Series
|
||||
@@ -85,7 +107,7 @@ class Ticks:
|
||||
volume_real: Series
|
||||
Index: Series
|
||||
|
||||
def __init__(self, *, data: DataFrame | Iterable, flip=False):
|
||||
def __init__(self, *, data: DataFrame | Iterable | Self, flip=False):
|
||||
"""Initialize the Ticks class. Creates a DataFrame of price ticks from the data argument.
|
||||
|
||||
Args:
|
||||
@@ -162,7 +184,7 @@ class Ticks:
|
||||
"""DataFrame of price ticks arranged in chronological order."""
|
||||
return self._data
|
||||
|
||||
def rename(self, inplace=True, **kwargs) -> _Ticks | None:
|
||||
def rename(self, inplace=True, **kwargs) -> Self | None:
|
||||
"""Rename columns of the candle class.
|
||||
|
||||
Keyword Args:
|
||||
@@ -190,13 +212,13 @@ class Ticks:
|
||||
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,
|
||||
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
|
||||
_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.
|
||||
@@ -206,7 +228,7 @@ class Ticks:
|
||||
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}
|
||||
('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)
|
||||
@@ -7,9 +7,9 @@ import csv
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
from .core.config import Config
|
||||
from .core.meta_trader import MetaTrader
|
||||
from .contrib.backtester.meta_tester import MetaTester
|
||||
from ..core.config import Config
|
||||
from ..core.meta_trader import MetaTrader
|
||||
from ..core.meta_backtester import MetaBackTester
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,7 +23,7 @@ class TradeRecords:
|
||||
from the config
|
||||
"""
|
||||
config: Config
|
||||
mt5: MetaTrader | MetaTester
|
||||
mt5: MetaTrader | MetaBackTester
|
||||
|
||||
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
|
||||
@@ -33,7 +33,7 @@ class TradeRecords:
|
||||
records_dir (Path): Absolute path to directory containing record of placed trades.
|
||||
"""
|
||||
self.config = Config()
|
||||
self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester()
|
||||
self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaBackTester()
|
||||
self.records_dir = records_dir or self.config.records_dir
|
||||
|
||||
async def get_csv_records(self):
|
||||
@@ -63,16 +63,15 @@ class TradeRecords:
|
||||
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()
|
||||
with open(file, mode='r', newline='') as fr:
|
||||
reader: Iterable[dict] | csv.DictReader = csv.DictReader(fr)
|
||||
rows = [row for row in reader]
|
||||
rows = await self.update_rows(rows=rows)
|
||||
|
||||
with open(file, mode='w', newline='') as fw: # type: SupportsWrite[str]
|
||||
writer = csv.DictWriter(fw, fieldnames=reader.fieldnames, extrasaction='ignore', restval=None)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
except Exception as err:
|
||||
logger.error(f'Error: {err}. Unable to read and update csv trade records')
|
||||
|
||||
@@ -82,14 +81,13 @@ class TradeRecords:
|
||||
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()
|
||||
with open(file, mode='r') as fh: # type: SupportsRead[str | bytes]
|
||||
data = json.load(fh)
|
||||
rows = [row for row in data]
|
||||
rows = await self.update_rows(rows=rows)
|
||||
|
||||
with open(file, mode='w') as fh: # type: SupportsWrite[str]
|
||||
json.dump(rows, fh, indent=2)
|
||||
except Exception as err:
|
||||
logger.error(f'Error: {err}. Unable to read and update json trade records')
|
||||
|
||||
@@ -109,7 +107,7 @@ class TradeRecords:
|
||||
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)
|
||||
deals.sort(key=lambda deal: deal.time_msc)
|
||||
deal = deals[-1]
|
||||
row.update(actual_profit=deal.profit, win=deal.profit > 0, closed=True)
|
||||
return row
|
||||
@@ -3,33 +3,40 @@ from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import TypeVar
|
||||
from logging import getLogger
|
||||
from zoneinfo import ZoneInfo
|
||||
import pytz
|
||||
|
||||
|
||||
from ..core.models import OrderType, OrderSendResult, OrderCheckResult
|
||||
from ..core.config import Config
|
||||
from ..core.task_queue import QueueItem
|
||||
from .result import Result
|
||||
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
|
||||
from .result import Result
|
||||
from .core.task_queue import QueueItem
|
||||
|
||||
logger = getLogger(__name__)
|
||||
Symbol = TypeVar("Symbol", bound=_Symbol)
|
||||
|
||||
|
||||
class Trader(ABC):
|
||||
"""Base class for creating a Trader object. Handles the creation of an order and the placing of trades.
|
||||
"""Base class for creating and managing orders.
|
||||
Handles the creation and placing of an order.
|
||||
It is an abstract class and must be subclassed to implement the place_trade method.
|
||||
It has a set of methods that can be used to set the order limits and stop levels for the order.
|
||||
|
||||
Attributes:
|
||||
symbol (Symbol): The financial instrument.
|
||||
ram (RAM): RAM instance
|
||||
order (Order): Trade order
|
||||
parameters (dict): Parameters of the trading strategy used to place the trade
|
||||
|
||||
Class Attributes:
|
||||
config (Config): Config instance.
|
||||
"""
|
||||
config: Config
|
||||
ram: RAM
|
||||
parameters: dict
|
||||
|
||||
def __init__(self, *, symbol: Symbol, ram: RAM = None):
|
||||
"""Initializes the order object and RAM instance
|
||||
@@ -82,47 +89,55 @@ class Trader(ABC):
|
||||
self.symbol.digits)
|
||||
self.order.price = tick.bid
|
||||
|
||||
async def check_order(self) -> bool:
|
||||
async def check_order(self) -> OrderCheckResult | None:
|
||||
"""Check order before sending it to the broker.
|
||||
|
||||
Returns:
|
||||
bool: True if order can go through else false
|
||||
"""
|
||||
check = await self.order.check()
|
||||
if check.retcode != 0:
|
||||
logger.warning(f"Invalid order for {self.symbol} due to {check.comment}")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def send_order(self) -> OrderSendResult:
|
||||
if check is None:
|
||||
logger.warning(f"{self.order.mt5.error}: Order check failed")
|
||||
return check
|
||||
|
||||
if check.retcode != 0:
|
||||
logger.warning(f"Invalid order for due to {check.comment}")
|
||||
else:
|
||||
logger.info(f"Order check passed for {self.symbol}")
|
||||
|
||||
return check
|
||||
|
||||
async def send_order(self) -> OrderSendResult | None:
|
||||
"""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} due to {result.comment}")
|
||||
if result is None:
|
||||
logger.warning(f"{self.order.mt5.error}: Failed to place order.")
|
||||
return result
|
||||
logger.info(f"Placed Trade for {self.symbol}")
|
||||
|
||||
if result.retcode != 10009:
|
||||
logger.warning(f"Unable to place order for due to {result.comment}")
|
||||
return result
|
||||
logger.info("Order placed successfully")
|
||||
return result
|
||||
|
||||
async def record_trade(self, result: OrderSendResult, parameters: dict = None, name: str = '', exclude: set = None):
|
||||
async def record_trade(self, *, result: OrderSendResult, parameters: dict = None, name: str = ''):
|
||||
"""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
|
||||
params = {k: v for k, v in params.items() if k not in (exclude or set())}
|
||||
params = parameters or {}
|
||||
profit = result.profit or await self.order.calc_profit()
|
||||
params["expected_profit"] = profit
|
||||
date = datetime.utcnow()
|
||||
date = date.replace(tzinfo=ZoneInfo("UTC"))
|
||||
date = datetime.now(tz=pytz.UTC)
|
||||
params["date"] = str(date.date())
|
||||
params["time"] = str(date.time())
|
||||
res = Result(result=result, parameters=params, name=name)
|
||||
self.config.task_queue.add(item=QueueItem(res.save, must_complete=True))
|
||||
self.config.task_queue.add(item=QueueItem(res.save), must_complete=True)
|
||||
|
||||
@abstractmethod
|
||||
async def place_trade(self, *args, **kwargs):
|
||||
@@ -1,117 +0,0 @@
|
||||
"""This module contains the Records class, which is used to read and update trade records from csv files."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import csv
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
from .contrib.backtester.meta_tester import MetaTester
|
||||
from .core.config import Config
|
||||
from .core.meta_trader import MetaTrader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Records:
|
||||
"""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() if self.config.mode == 'live' else MetaTester()
|
||||
self.records_dir = records_dir or self.config.records_dir
|
||||
|
||||
async def get_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('.csv'):
|
||||
yield file
|
||||
|
||||
async def read_update(self, file: Path):
|
||||
"""Read and update trade records
|
||||
|
||||
Args:
|
||||
file: Trade record file
|
||||
"""
|
||||
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)
|
||||
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 trade records')
|
||||
|
||||
async def update_row(self, row: dict) -> dict:
|
||||
"""Update a single row of entered trade in the csv 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 file with the actual profit.
|
||||
|
||||
Args:
|
||||
rows: A list of dictionaries from the dictionary writer object of the csv file.
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of dictionaries with the actual profit and win status.
|
||||
"""
|
||||
closed, unclosed = [], []
|
||||
for row in rows:
|
||||
if (row.get('closed', 'FALSE')).title() == 'True':
|
||||
closed.append(row)
|
||||
else:
|
||||
unclosed.append(row)
|
||||
unclosed = await asyncio.gather(*[self.update_row(row) for row in unclosed])
|
||||
return closed + list(unclosed)
|
||||
|
||||
async def update_records(self):
|
||||
"""Update trade records in the records_dir folder."""
|
||||
records = [self.read_update(record) async for record in self.get_records()]
|
||||
await asyncio.gather(*records)
|
||||
|
||||
async def update_record(self, file: Path | str):
|
||||
"""Update a single trade record file."""
|
||||
await self.read_update(file)
|
||||
@@ -1,83 +0,0 @@
|
||||
"""The base class for creating strategies."""
|
||||
import asyncio
|
||||
from time import time
|
||||
from typing import TypeVar
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import time as dtime
|
||||
|
||||
from .core.meta_trader import MetaTrader
|
||||
from .symbol import Symbol as _Symbol
|
||||
from .core import Config
|
||||
from .contrib.backtester.meta_tester import MetaTester
|
||||
from .sessions import Sessions, Session
|
||||
|
||||
Symbol = TypeVar("Symbol", bound=_Symbol)
|
||||
|
||||
|
||||
class Strategy(ABC):
|
||||
"""The base class for creating strategies.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the strategy.
|
||||
symbol (Symbol): The Financial Instrument as a Symbol Object
|
||||
parameters (Dict): A dictionary of parameters for the strategy.
|
||||
sessions (Sessions): The sessions to use for the strategy.
|
||||
|
||||
Notes:
|
||||
Define the name of a strategy as a class attribute. If not provided, the class name will be used as the name.
|
||||
"""
|
||||
name: str
|
||||
symbol: Symbol
|
||||
sessions: Sessions
|
||||
mt5: MetaTrader
|
||||
config: Config
|
||||
parameters = {}
|
||||
|
||||
def __init__(self, *, symbol: Symbol, params: dict = None, sessions: Sessions = None, name=''):
|
||||
"""Initiate the parameters dict and add name and symbol fields.
|
||||
Use class name as strategy name if name is not provided
|
||||
|
||||
Args:
|
||||
symbol (Symbol): The Financial instrument
|
||||
params (Dict): Trading strategy parameters
|
||||
"""
|
||||
self.parameters = self.parameters | (params or {})
|
||||
self.symbol = symbol
|
||||
self.name = name or self.__class__.__name__
|
||||
self.parameters["symbol"] = symbol.name
|
||||
self.parameters["name"] = self.name
|
||||
self.sessions = sessions or Sessions(sessions=[Session(start=0, end=dtime(hour=23, minute=59, second=59))])
|
||||
self.config = Config()
|
||||
self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaTester()
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.name}({self.symbol!r})"
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.parameters:
|
||||
return self.parameters[item]
|
||||
raise AttributeError(f'{item} not an attribute of {self.name}')
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if key in self.__dict__.get('parameters', {}):
|
||||
self.parameters[key] = value
|
||||
super().__setattr__(key, value)
|
||||
|
||||
@staticmethod
|
||||
async def sleep(secs: float):
|
||||
"""Sleep for the needed amount of seconds in between requests to the terminal.
|
||||
computes the accurate amount of time needed to sleep ensuring that the next request is made at the start of
|
||||
a new bar and making cooperative multitasking possible.
|
||||
|
||||
Args:
|
||||
secs (float): The time in seconds. Usually the timeframe you are trading on.
|
||||
"""
|
||||
mod = time() % secs
|
||||
secs = secs - mod if mod != 0 else mod
|
||||
await asyncio.sleep(secs + 0.2)
|
||||
|
||||
@abstractmethod
|
||||
async def trade(self):
|
||||
"""Place trades using this method. This is the main method of the strategy.
|
||||
It will be called by the strategy runner.
|
||||
"""
|
||||
@@ -1,14 +0,0 @@
|
||||
import pytest
|
||||
from aiomql import Config
|
||||
import MetaTrader5
|
||||
|
||||
|
||||
@pytest.fixture(scope='session', autouse=True)
|
||||
def config():
|
||||
config = Config(filename='test.json')
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture(scope='session', autouse=True)
|
||||
def metatrader5():
|
||||
return MetaTrader5
|
||||
@@ -0,0 +1,93 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from aiomql.core import Config
|
||||
from aiomql.core.meta_trader import MetaTrader
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
async def cleanup():
|
||||
try:
|
||||
shutil.rmtree(Path('tests/configs'), ignore_errors=True)
|
||||
Path.unlink(Path('tests/test.json'), missing_ok=True)
|
||||
shutil.rmtree(Path('tests/trade_records'), ignore_errors=True)
|
||||
await close_all_positions()
|
||||
await MetaTrader().shutdown()
|
||||
except Exception as err:
|
||||
logger.error(f"Failed to complete cleanup: {err}")
|
||||
|
||||
|
||||
async def close_all_positions():
|
||||
try:
|
||||
mt = MetaTrader()
|
||||
positions = await mt.positions_get()
|
||||
tasks = []
|
||||
for position in positions:
|
||||
order_type = mt.ORDER_TYPE_BUY if position.type == mt.ORDER_TYPE_SELL else mt.ORDER_TYPE_SELL
|
||||
req = {'action': mt.TRADE_ACTION_DEAL, 'symbol': position.symbol, 'volume': position.volume,
|
||||
'type': order_type, 'position': position.ticket, 'price': position.price_current}
|
||||
tasks.append(mt.order_send(req))
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as err:
|
||||
logger.error(f"Failed to close all positions: {err}")
|
||||
|
||||
|
||||
@pytest.fixture(scope='session', autouse=True)
|
||||
async def config(request):
|
||||
Path('tests/configs').mkdir(exist_ok=True)
|
||||
with open('aiomql.json', 'r') as fh, open('tests/configs/test2.json', 'w') as fh1, open('tests/test.json', 'w') as fh2:
|
||||
data = json.load(fh)
|
||||
json.dump(data, fh1, indent=2)
|
||||
json.dump(data, fh2, indent=2)
|
||||
config = Config(filename='test.json', root='tests')
|
||||
yield config
|
||||
await cleanup()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
async def mt():
|
||||
mt = MetaTrader()
|
||||
await mt.initialize()
|
||||
await mt.login()
|
||||
yield mt
|
||||
await mt.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
async def sell_order(mt):
|
||||
sym = 'BTCUSD'
|
||||
sym_info = await mt.symbol_info(sym)
|
||||
return {'action': mt.TRADE_ACTION_DEAL, 'symbol': sym, 'volume': sym_info.volume_min,
|
||||
'type': mt.ORDER_TYPE_SELL, 'price': sym_info.bid}
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
async def buy_order(mt):
|
||||
sym = 'BTCUSD'
|
||||
sym_info = await mt.symbol_info(sym)
|
||||
dsl = (sym_info.trade_stops_level + sym_info.spread) * 2 * sym_info.point
|
||||
sl = sym_info.ask - dsl
|
||||
tp = sym_info.ask + dsl
|
||||
return {'action': mt.TRADE_ACTION_DEAL, 'symbol': sym, 'volume': sym_info.volume_min,
|
||||
'type': mt.ORDER_TYPE_BUY, 'price': sym_info.ask, 'sl': sl, 'tp': tp}
|
||||
|
||||
@pytest.fixture(scope='class')
|
||||
async def make_orders(mt):
|
||||
sym = 'BTCUSD'
|
||||
sym_info = await mt.symbol_info(sym)
|
||||
dsl = (sym_info.trade_stops_level + sym_info.spread) * 2 * sym_info.point
|
||||
sl = sym_info.ask - dsl
|
||||
tp = sym_info.ask + dsl
|
||||
req = {'action': mt.TRADE_ACTION_DEAL, 'symbol': sym, 'volume': sym_info.volume_min,
|
||||
'type': mt.ORDER_TYPE_BUY, 'price': sym_info.ask, 'sl': sl, 'tp': tp}
|
||||
await mt.order_send(req)
|
||||
req['type'] = mt.ORDER_TYPE_SELL
|
||||
req['price'] = sym_info.bid
|
||||
req['sl'] = sym_info.bid + dsl
|
||||
req['tp'] = sym_info.bid - dsl
|
||||
await mt.order_send(req)
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
asyncio_default_fixture_loop_scope = session
|
||||
addopts = --rootdir=tests --capture=tee-sys --last-failed
|
||||
asyncio_mode = auto
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"login": 31288540,
|
||||
"password": "nwa0#anaEze",
|
||||
"server": "Deriv-Demo",
|
||||
"demo": 5463204,
|
||||
"fin": 24251812,
|
||||
"deriv-demo": 5463204,
|
||||
"deriv-real": 31288540,
|
||||
"mode": "backtest"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
from aiomql.lib.account import Account
|
||||
|
||||
class TestAccount:
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
cls.account = Account()
|
||||
|
||||
@pytest.fixture(scope='class', autouse=True)
|
||||
async def refresh(self):
|
||||
await self.account.refresh()
|
||||
|
||||
async def test_connected(self):
|
||||
assert self.account.connected is True
|
||||
|
||||
async def test_account_info(self):
|
||||
acc_info = await self.account.mt5.account_info()
|
||||
assert acc_info.login == self.account.login
|
||||
assert acc_info.server == self.account.server
|
||||
@@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
from aiomql.core.base import Base
|
||||
|
||||
|
||||
class ChildClass(Base):
|
||||
attr: int
|
||||
attr2: str
|
||||
cls_attr: int = 10
|
||||
|
||||
|
||||
class TestBaseClass:
|
||||
@pytest.fixture
|
||||
def child(self):
|
||||
return ChildClass(attr=1, attr2="test")
|
||||
|
||||
def test_repr(self, child):
|
||||
repr_str = repr(child)
|
||||
assert repr_str.startswith("ChildClass(")
|
||||
assert "attr=1" in repr_str
|
||||
assert "attr2=test" in repr_str
|
||||
|
||||
def test_set_attributes(self, child):
|
||||
child.set_attributes(attr3=3.14, attr2='str')
|
||||
assert child.attr2 == 'str'
|
||||
assert getattr(child, 'attr3', None) is None
|
||||
|
||||
def test_annotations(self, child):
|
||||
annotations = child.annotations
|
||||
assert isinstance(annotations, dict)
|
||||
|
||||
def test_get_dict(self, child):
|
||||
child.set_attributes(attr2='test')
|
||||
result = child.get_dict()
|
||||
assert result["attr"] == 1
|
||||
assert result["attr2"] == "test"
|
||||
|
||||
def test_get_dict_with_exclude(self, child):
|
||||
child.set_attributes(attr2='test')
|
||||
result = child.get_dict(exclude={"attr"})
|
||||
assert "attr" not in result
|
||||
assert result["attr2"] == "test"
|
||||
|
||||
def test_get_dict_with_include(self, child):
|
||||
child.set_attributes(attr3=3.14)
|
||||
result = child.get_dict(include={"attr"})
|
||||
assert result["attr"] == 1
|
||||
assert "attr2" not in result
|
||||
|
||||
def test_class_vars(self, child):
|
||||
class_vars = child.class_vars
|
||||
assert isinstance(class_vars, dict)
|
||||
assert 'cls_attr' in class_vars
|
||||
assert 'attr' not in class_vars
|
||||
|
||||
def test_dict_property(self, child):
|
||||
child.set_attributes(attr2="test")
|
||||
dict_prop = child.dict
|
||||
assert dict_prop["attr"] == 1
|
||||
assert dict_prop["attr2"] == "test"
|
||||
assert dict_prop["cls_attr"] == 10
|
||||
@@ -0,0 +1,106 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
import pandas as pd
|
||||
from aiomql.lib.candle import Candle, Candles
|
||||
from aiomql.core.meta_trader import MetaTrader
|
||||
from aiomql.core.constants import TimeFrame
|
||||
|
||||
|
||||
class TestCandle:
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
cls.bullish_candle = Candle(open=1.3421, high=1.3462, low=1.3405, close=1.3452, time=0, Index=0)
|
||||
cls.bearish_candle = Candle(open=1.3452, high=1.3405, low=1.3462, close=1.3421, time=1, Index=1)
|
||||
|
||||
def test_repr(self):
|
||||
repr_str = repr(self.bearish_candle)
|
||||
assert repr_str.startswith("Candle(")
|
||||
assert "open=" in repr_str
|
||||
assert "high=" in repr_str
|
||||
assert "low=" in repr_str
|
||||
assert "close=" in repr_str
|
||||
|
||||
def test_set_attributes(self):
|
||||
self.bearish_candle.set_attributes(ema=10)
|
||||
assert self.bearish_candle.ema == 10
|
||||
|
||||
def test_compare(self):
|
||||
assert self.bearish_candle > self.bullish_candle
|
||||
assert self.bullish_candle != self.bearish_candle
|
||||
assert self.bullish_candle < self.bearish_candle
|
||||
|
||||
def test_dict(self):
|
||||
self.bearish_candle.set_attributes(ema=10)
|
||||
result = self.bearish_candle.dict(exclude={'time'})
|
||||
result2 = self.bearish_candle.dict(include={'close', 'high'})
|
||||
assert result['open'] == 1.3452
|
||||
assert result['ema'] == 10
|
||||
assert 'time' not in result
|
||||
assert set(result2.keys()) == {'close', 'high'}
|
||||
|
||||
def test_dictionary_properties(self):
|
||||
self.bearish_candle['ema'] = 4
|
||||
assert self.bearish_candle['ema'] == 4
|
||||
|
||||
def test_candle_type(self):
|
||||
assert self.bearish_candle.is_bearish()
|
||||
assert self.bullish_candle.is_bullish()
|
||||
|
||||
|
||||
class TestCandles:
|
||||
@pytest.fixture(scope='class')
|
||||
async def candles(self):
|
||||
mt = MetaTrader()
|
||||
start = datetime(day=5, month=10, year=2023)
|
||||
rates = await mt.copy_rates_from("BTCUSD", mt.TIMEFRAME_H1, start, 200)
|
||||
return Candles(data=rates)
|
||||
|
||||
|
||||
def test_get_series(self, candles):
|
||||
series = candles['open']
|
||||
assert isinstance(series, pd.Series)
|
||||
assert len(series) == 200
|
||||
|
||||
def test_get_candle(self, candles):
|
||||
candle = candles[10]
|
||||
assert isinstance(candle, Candle)
|
||||
assert candle in candles
|
||||
assert candle.Index == 10
|
||||
|
||||
def test_slice(self, candles):
|
||||
sliced = candles[10:15]
|
||||
assert len(sliced) == 5
|
||||
assert isinstance(sliced, Candles)
|
||||
|
||||
def test_setitem(self, candles):
|
||||
new_series = candles.open
|
||||
new_series = new_series * 2
|
||||
candles['double_open'] = new_series
|
||||
assert 'double_open' in candles.data.columns
|
||||
|
||||
def test_getattr(self, candles):
|
||||
open_series = candles.open
|
||||
assert isinstance(open_series, pd.Series)
|
||||
assert open_series.equals(candles.data['open'])
|
||||
|
||||
def test_iter(self, candles):
|
||||
l_5 = candles[-5:]
|
||||
assert all(isinstance(candle, Candle) for candle in l_5)
|
||||
|
||||
def test_timeframe(self, candles):
|
||||
tf = candles.timeframe
|
||||
assert tf == TimeFrame.H1
|
||||
|
||||
def test_ta_and_rename(self, candles):
|
||||
ema = candles.ta.ema(close='open', length=10, append=True)
|
||||
assert 'EMA_10' in candles.data.columns
|
||||
candles.rename(inplace=True, EMA_10='ema')
|
||||
assert 'ema' in candles.data.columns
|
||||
|
||||
def test_ta_lib(self, candles):
|
||||
fas = candles.ta_lib.above(candles.open, candles.close)
|
||||
assert isinstance(fas, pd.Series)
|
||||
candles['fas'] = fas
|
||||
assert 'fas' in candles.data.columns
|
||||
@@ -0,0 +1,29 @@
|
||||
from aiomql.core.config import Config
|
||||
from aiomql.contrib.backtesting import BackTestEngine
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_singleton(self, config):
|
||||
config2 = Config(filename='test.json')
|
||||
assert config is config2
|
||||
|
||||
def test_set_attributes(self, config):
|
||||
config.set_attributes(timeout=5000, record_trades=False)
|
||||
assert config.timeout == 5000
|
||||
assert config.record_trades is False
|
||||
|
||||
def test_backtest_engine(self, config):
|
||||
engine = BackTestEngine()
|
||||
config.backtest_engine = engine
|
||||
assert config.backtest_engine is engine
|
||||
|
||||
def test_account_info(self, config):
|
||||
account_info = config.account_info()
|
||||
assert isinstance(account_info, dict)
|
||||
assert 'login' in account_info
|
||||
assert 'password' in account_info
|
||||
assert 'server' in account_info
|
||||
|
||||
def test_load_config(self, config):
|
||||
config.load_config(file='tests/configs/test2.json')
|
||||
assert config.filename == 'test2.json'
|
||||
@@ -0,0 +1,49 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from aiomql.lib.history import History
|
||||
|
||||
class TestHistory:
|
||||
@pytest.fixture(scope='class', autouse=True)
|
||||
async def init(self, make_orders):
|
||||
await self.history.init()
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
now = datetime.now()
|
||||
cls.start = now.replace(hour=0)
|
||||
cls.end = now.replace(hour=23)
|
||||
history = History(date_from=cls.start, date_to=cls.end)
|
||||
cls.history = history
|
||||
|
||||
async def test_init(self):
|
||||
assert self.history.total_deals > 0
|
||||
assert self.history.total_orders > 0
|
||||
|
||||
async def test_get_deals(self):
|
||||
deals = await self.history.get_deals()
|
||||
assert len(deals) > 0
|
||||
|
||||
async def test_get_deals_by_ticket(self):
|
||||
ticket = self.history.deals[0].order
|
||||
deals = self.history.get_deals_by_ticket(ticket=ticket)
|
||||
assert len(deals) > 0
|
||||
|
||||
async def test_get_deals_by_position(self):
|
||||
position = self.history.deals[0].position_id
|
||||
deals = self.history.get_deals_by_position(position=position)
|
||||
assert len(deals) > 0
|
||||
|
||||
async def test_get_orders(self):
|
||||
orders = await self.history.get_orders()
|
||||
assert len(orders) > 0
|
||||
|
||||
async def test_get_orders_by_ticket(self):
|
||||
ticket = self.history.orders[0].ticket
|
||||
orders = self.history.get_orders_by_ticket(ticket=ticket)
|
||||
assert len(orders) > 0
|
||||
|
||||
async def test_get_orders_by_position(self):
|
||||
position = self.history.orders[0].position_id
|
||||
orders = self.history.get_orders_by_position(position=position)
|
||||
assert len(orders) > 0
|
||||
@@ -1,199 +1,207 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
import pytest_asyncio
|
||||
from aiomql import MetaTrader, TimeFrame, OrderType, CopyTicks
|
||||
import MetaTrader5
|
||||
|
||||
from . import metatrader5
|
||||
from aiomql import MetaTrader
|
||||
|
||||
|
||||
class TestMetaTrader:
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls, metatrader5):
|
||||
tz = pytz.timezone('Etc/UTC')
|
||||
def setup_class(cls):
|
||||
cls.mt = MetaTrader()
|
||||
cls.mt5 = metatrader5
|
||||
cls.symbol = "Volatility 100 Index"
|
||||
now = datetime.now(tz=tz)
|
||||
cls.start = now - timedelta(hours=24)
|
||||
cls.end = now + timedelta(hours=2)
|
||||
cls.mt5 = MetaTrader5
|
||||
cls.symbol = "BTCUSD"
|
||||
now = datetime.now(tz=pytz.UTC)
|
||||
cls.start = now - timedelta(hours=10)
|
||||
cls.end = now + timedelta(hours=1)
|
||||
cls.tf = cls.mt.TIMEFRAME_H1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@classmethod
|
||||
def teardown_class(cls):
|
||||
cls.mt._shutdown()
|
||||
|
||||
async def test_initialize(self):
|
||||
res = await self.mt.initialize()
|
||||
assert res == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login(self):
|
||||
res = await self.mt.login()
|
||||
assert res == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_error(self):
|
||||
res = await self.mt.last_error()
|
||||
assert isinstance(res, tuple)
|
||||
assert res[0] == 1
|
||||
assert res[1] == 'Successful'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert res[1] == 'Success'
|
||||
|
||||
async def test_version(self):
|
||||
res = await self.mt.version()
|
||||
res2 = self.mt5.version()
|
||||
res2 = self.mt5.version()
|
||||
assert res is not None
|
||||
assert res == res2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
async def test_account_info(self):
|
||||
res = await self.mt.account_info()
|
||||
res2 = self.mt5.account_info()
|
||||
assert res is not None
|
||||
assert res == res2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
async def test_terminal_info(self):
|
||||
res = await self.mt.terminal_info()
|
||||
res2 = await self.mt5.terminal_info()
|
||||
res2 = self.mt5.terminal_info()
|
||||
assert res is not None
|
||||
assert res == res2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_symbols_total(self):
|
||||
res = await self.mt.symbols_total()
|
||||
res2 = await self.mt5.symbols_total()
|
||||
res2 = self.mt5.symbols_total()
|
||||
assert isinstance(res, int)
|
||||
assert res
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert res == res2
|
||||
|
||||
async def test_symbols_get(self):
|
||||
res = await self.mt.symbols_get()
|
||||
res2 = await self.mt5.symbols_get()
|
||||
res2 = self.mt5.symbols_get()
|
||||
assert res is not None
|
||||
assert res == res2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert len(res) == len(res2)
|
||||
|
||||
async def test_symbol_info(self):
|
||||
res = await self.mt.symbol_info(self.symbol)
|
||||
res2 = await self.mt5.symbol_info(self.symbol)
|
||||
res2 = self.mt5.symbol_info(self.symbol)
|
||||
assert res is not None
|
||||
assert res == res2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
async def test_symbol_info_tick(self):
|
||||
res = await self.mt.symbol_info_tick(self.symbol)
|
||||
res2 = await self.mt5.symbol_info_tick(self.symbol)
|
||||
res2 = self.mt5.symbol_info_tick(self.symbol)
|
||||
assert res is not None
|
||||
assert res == res2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
async def test_symbol_select(self):
|
||||
res = await self.mt.symbol_select(self.symbol, True)
|
||||
assert res == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
async def test_market_book_add(self):
|
||||
res = await self.mt.market_book_add(self.symbol)
|
||||
assert res == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
async def test_market_book_get(self):
|
||||
res = await self.mt.market_book_get(self.symbol)
|
||||
res2 = await self.mt5.market_book_get(self.symbol)
|
||||
res2 = self.mt5.market_book_get(self.symbol)
|
||||
assert res is not None
|
||||
assert res == res2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
async def test_market_book_release(self):
|
||||
res = await self.mt.market_book_release(self.symbol)
|
||||
assert res == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
async def test_copy_rates_from(self):
|
||||
res = await self.mt.copy_rates_from(self.symbol, self.tf, self.start, 10)
|
||||
assert res is not None
|
||||
assert res.shape[0] == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
async def test_copy_rates_from_pos(self):
|
||||
res = await self.mt.copy_rates_from_pos(self.symbol, self.tf, 0, 10)
|
||||
assert res is not None
|
||||
assert res.shape[0] == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
async def test_copy_rates_range(self):
|
||||
res = await self.mt.copy_rates_range(self.symbol, TimeFrame.M1, datetime.now(), datetime.now())
|
||||
res = await self.mt.copy_rates_range(self.symbol, self.tf, self.start, self.end)
|
||||
assert res is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert res.shape[0] == 10
|
||||
|
||||
async def test_copy_ticks_from(self):
|
||||
res = await self.mt.copy_ticks_from(self.symbol, datetime.now(), 10, CopyTicks.ALL)
|
||||
res = await self.mt.copy_ticks_from(self.symbol, self.start, 10, self.mt.COPY_TICKS_ALL)
|
||||
assert res is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert res.shape[0] == 10
|
||||
|
||||
async def test_copy_ticks_range(self):
|
||||
res = await self.mt.copy_ticks_range(self.symbol, datetime.now(), datetime.now(), CopyTicks.ALL)
|
||||
res = await self.mt.copy_ticks_range(self.symbol, self.start, self.end, self.mt.COPY_TICKS_ALL)
|
||||
res2 = self.mt5.copy_ticks_range(self.symbol, self.start, self.end, self.mt5.COPY_TICKS_ALL)
|
||||
assert res is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert res.shape[0] == res2.shape[0]
|
||||
|
||||
async def test_orders_total(self):
|
||||
res = await self.mt.orders_total()
|
||||
assert isinstance(res, int)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
async def test_orders_get(self):
|
||||
res = await self.mt.orders_get()
|
||||
assert res is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_order_calc_margin(self):
|
||||
res = await self.mt.order_calc_margin(OrderType.BUY, self.symbol, 1.0, 1.0)
|
||||
assert isinstance(res, tuple)
|
||||
assert len(res) == 0
|
||||
|
||||
async def test_order_calc_margin(self, sell_order):
|
||||
price = sell_order['price']
|
||||
volume = sell_order['volume']
|
||||
type_ = sell_order['type']
|
||||
res = await self.mt.order_calc_margin(type_, self.symbol, volume, price)
|
||||
assert isinstance(res, float)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_order_calc_profit(self):
|
||||
res = await self.mt.order_calc_profit(OrderType.BUY, self.symbol, 1.0, 1.0, 1.1)
|
||||
|
||||
async def test_order_calc_profit(self, buy_order):
|
||||
volume = buy_order['volume']
|
||||
price_open = buy_order['price']
|
||||
price_close = buy_order['tp']
|
||||
type_ = buy_order['type']
|
||||
res = await self.mt.order_calc_profit(type_, self.symbol, volume, price_open, price_close)
|
||||
assert isinstance(res, float)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_order_check(self):
|
||||
request = {"action": OrderType.BUY, "symbol": self.symbol, "volume": 1.0, "price": 1.0}
|
||||
res = await self.mt.order_check(request)
|
||||
|
||||
async def test_order_check(self, buy_order):
|
||||
res = await self.mt.order_check(buy_order)
|
||||
assert res is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_order_send(self):
|
||||
request = {"action": OrderType.BUY, "symbol": self.symbol, "volume": 1.0, "price": 1.0}
|
||||
res = await self.mt.order_send(request)
|
||||
assert res.retcode == 0
|
||||
|
||||
async def test_order_send(self, sell_order):
|
||||
res = await self.mt.order_send(sell_order)
|
||||
assert res is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert res.retcode == 10009
|
||||
|
||||
async def test_positions_total(self):
|
||||
res = await self.mt.positions_total()
|
||||
assert isinstance(res, int)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert res >= 0
|
||||
|
||||
async def test_positions_get(self):
|
||||
res = await self.mt.positions_get()
|
||||
assert res is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert isinstance(res, tuple)
|
||||
assert len(res) >= 0
|
||||
|
||||
async def test_history_orders_total(self):
|
||||
res = await self.mt.history_orders_total(datetime.now(), datetime.now())
|
||||
res = await self.mt.history_orders_total(self.start, self.end)
|
||||
assert isinstance(res, int)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert res >= 0
|
||||
|
||||
async def test_history_orders_get(self):
|
||||
res = await self.mt.history_orders_get(datetime.now(), datetime.now())
|
||||
res = await self.mt.history_orders_get(self.start, self.end)
|
||||
assert res is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert isinstance(res, tuple)
|
||||
assert len(res) >= 0
|
||||
|
||||
async def test_history_deals_total(self):
|
||||
res = await self.mt.history_deals_total(datetime.now(), datetime.now())
|
||||
res = await self.mt.history_deals_total(self.start, self.end)
|
||||
assert isinstance(res, int)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
assert res >= 0
|
||||
|
||||
async def test_history_deals_get(self):
|
||||
res = await self.mt.history_deals_get(datetime.now(), datetime.now())
|
||||
res = await self.mt.history_deals_get(self.start, self.end)
|
||||
assert res is not None
|
||||
assert isinstance(res, tuple)
|
||||
assert len(res) >= 0
|
||||
|
||||
|
||||
# sym = order_request['symbol']
|
||||
# sym_info = await self.mt.symbol_info(sym)
|
||||
# dsl = (sym_info.trade_stops_level + sym_info.spread) * 2 * sym_info.point
|
||||
# order_request['volume'] = sym_info.volume_min
|
||||
# order_request['price'] = sym_info.ask
|
||||
# order_request['tp'] = round(sym_info.ask + dsl, sym_info.digits)
|
||||
# order_request['sl'] = round(sym_info.ask - dsl, sym_info.digits)
|
||||
|
||||
# sym = order_request['symbol']
|
||||
# sym_info = await self.mt.symbol_info(sym)
|
||||
# dsl = (sym_info.trade_stops_level + sym_info.spread) * 2 * sym_info.point
|
||||
# order_request['volume'] = sym_info.volume_min
|
||||
# order_request['price'] = sym_info.ask
|
||||
# order_request['tp'] = round(sym_info.ask + dsl, sym_info.digits)
|
||||
# order_request['sl'] = round(sym_info.ask - dsl, sym_info.digits)
|
||||
@@ -0,0 +1,27 @@
|
||||
from aiomql.lib.order import Order
|
||||
|
||||
|
||||
class TestOrder:
|
||||
async def test_check(self, sell_order):
|
||||
order = Order(**sell_order)
|
||||
check = await order.check()
|
||||
assert check.retcode == 0
|
||||
|
||||
async def test_send(self, buy_order):
|
||||
order = Order(**buy_order)
|
||||
send = await order.send()
|
||||
assert send.retcode == 10009
|
||||
|
||||
async def test_margin(self, buy_order):
|
||||
order = Order(**buy_order)
|
||||
margin = await order.calc_margin()
|
||||
assert margin is not None
|
||||
assert margin > 0
|
||||
assert isinstance(margin, float)
|
||||
|
||||
async def test_profit(self, buy_order):
|
||||
order = Order(**buy_order)
|
||||
profit = await order.calc_profit()
|
||||
assert profit is not None
|
||||
assert profit > 0
|
||||
assert isinstance(profit, float)
|
||||
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
|
||||
from aiomql.lib.positions import Positions
|
||||
|
||||
|
||||
class TestPositions:
|
||||
@pytest.fixture(scope='class', autouse=True)
|
||||
async def init(self, make_orders):
|
||||
await self.positions.get_positions()
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
cls.positions = Positions()
|
||||
|
||||
@pytest.mark.order(1)
|
||||
async def test_get_positions(self):
|
||||
await self.positions.get_positions()
|
||||
assert len(self.positions.positions) >= 0
|
||||
|
||||
async def test_get_position_by_ticket(self):
|
||||
ticket = self.positions.positions[0].ticket
|
||||
position = await self.positions.get_position_by_ticket(ticket=ticket)
|
||||
assert position is not None
|
||||
assert position.ticket == ticket
|
||||
|
||||
async def test_get_position_by_symbol(self):
|
||||
symbol = self.positions.positions[0].symbol
|
||||
positions = await self.positions.get_position_by_symbol(symbol=symbol)
|
||||
assert len(positions) >= 0
|
||||
assert positions[0].symbol == symbol
|
||||
@@ -0,0 +1,22 @@
|
||||
from aiomql.lib.ram import RAM
|
||||
|
||||
|
||||
class TestRAM:
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
cls.ram = RAM(min_amount=5, max_amount=10, loss_limit=3, open_limit=5)
|
||||
|
||||
async def test_get_amount(self):
|
||||
res = await self.ram.get_amount()
|
||||
assert self.ram.min_amount <= res <= self.ram.max_amount
|
||||
|
||||
async def test_checks(self, buy_order, sell_order, mt):
|
||||
for i in range(self.ram.open_limit+1):
|
||||
if i % 2 == 0:
|
||||
await mt.order_send(buy_order)
|
||||
else:
|
||||
await mt.order_send(sell_order)
|
||||
res1 = await self.ram.check_losing_positions()
|
||||
res2 = await self.ram.check_open_positions()
|
||||
assert res2 is False
|
||||
assert isinstance(res1, bool)
|
||||
@@ -0,0 +1,44 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from aiomql.lib.result import Result
|
||||
from aiomql.core.models import OrderSendResult
|
||||
|
||||
|
||||
class TestResult:
|
||||
|
||||
@pytest.fixture(scope='class')
|
||||
def parameters(self):
|
||||
return {'name': 'test_trades', 'ema': 20, 'rsi': 14}
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
async def order_results(self, mt, sell_order, buy_order, parameters):
|
||||
res1 = await mt.order_send(sell_order)
|
||||
res2 = await mt.order_send(buy_order)
|
||||
res1 = Result(result=OrderSendResult(**res1._asdict()), parameters=parameters)
|
||||
res2 = Result(result=OrderSendResult(**res2._asdict()), parameters=parameters)
|
||||
return res1, res2
|
||||
|
||||
async def test_get_data(self, order_results):
|
||||
res1, res2 = order_results
|
||||
data1 = res1.get_data()
|
||||
data2 = res2.get_data()
|
||||
assert data1['actual_profit'] == data2['actual_profit'] == 0
|
||||
assert data1['closed'] == data2['closed'] == False
|
||||
assert data1['win'] == data2['win'] == False
|
||||
|
||||
|
||||
async def test_csv(self, order_results):
|
||||
res1, res2 = order_results
|
||||
await asyncio.gather(res1.save(), res2.save())
|
||||
assert res1.config.records_dir.exists()
|
||||
record = res1.config.records_dir / f"{res1.name}.csv"
|
||||
assert record.exists()
|
||||
|
||||
async def test_json(self, order_results):
|
||||
res1, res2 = order_results
|
||||
await asyncio.gather(res1.save(trade_record_mode='json'), res2.save(trade_record_mode='json'))
|
||||
assert res1.config.records_dir.exists()
|
||||
record = res1.config.records_dir / f"{res1.name}.json"
|
||||
assert record.exists()
|
||||
@@ -0,0 +1,68 @@
|
||||
from datetime import datetime, time
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
|
||||
from aiomql.lib.sessions import Session, Sessions, delta
|
||||
|
||||
|
||||
class TestSessions:
|
||||
@pytest.fixture(scope='class')
|
||||
def make_sessions(self, make_session):
|
||||
london, all_day, over_night = make_session
|
||||
return Sessions(sessions=[london, all_day, over_night])
|
||||
|
||||
@pytest.fixture(scope='class')
|
||||
def make_session(self):
|
||||
end = time(hour=16, minute=59, second=59, microsecond=999_999, tzinfo=pytz.UTC)
|
||||
london = Session(start=8, end=end, name='London', on_end='close_all')
|
||||
start, end = time(hour=0, tzinfo=pytz.UTC), time(hour=23, minute=59, second=59, tzinfo=pytz.UTC)
|
||||
all_day = Session(start=start, end=end, name='AllDay', on_end='close_all')
|
||||
end = time(hour=6, minute=59, second=59, microsecond=999_999, tzinfo=pytz.UTC)
|
||||
over_night = Session(start=18, end=end, name='OverNight', on_end='close_all')
|
||||
return london, all_day, over_night
|
||||
|
||||
def test_session_attributes(self, make_session):
|
||||
london, all_day, over_night = make_session
|
||||
period = over_night.duration()
|
||||
assert london.name == 'London'
|
||||
assert london.start == time(hour=8, tzinfo=pytz.UTC)
|
||||
assert london.end.hour == 16
|
||||
assert period.hours == 12
|
||||
assert period.minutes == period.seconds == 59
|
||||
|
||||
def test_session_intervals(self, make_session):
|
||||
london, all_day, over_night = make_session
|
||||
two_am = time(hour=2, tzinfo=pytz.UTC)
|
||||
noon = time(hour=12, tzinfo=pytz.UTC)
|
||||
now = datetime.now(pytz.UTC).time()
|
||||
hours_till_london_starts = (delta(london.start) - delta(now)).seconds // 3600
|
||||
assert hours_till_london_starts == london.until() // 3600
|
||||
assert two_am in over_night
|
||||
assert noon in london
|
||||
assert two_am not in london
|
||||
assert noon not in over_night
|
||||
# all_day session is always open
|
||||
assert all_day.in_session()
|
||||
|
||||
async def test_sessions(self, make_session):
|
||||
london, all_day, over_night = make_session
|
||||
sessions = Sessions(sessions=[london, over_night])
|
||||
now = time(hour=21, tzinfo=pytz.UTC)
|
||||
noon = time(hour=12, tzinfo=pytz.UTC)
|
||||
mid_nite = time(hour=0, tzinfo=pytz.UTC)
|
||||
next_sess = sessions.find_next(moment=now)
|
||||
noon_sess = sessions.find(moment=noon)
|
||||
no_sess = sessions.find(moment=time(hour=17, tzinfo=pytz.UTC))
|
||||
mid_nite_sess = sessions.find(moment=mid_nite)
|
||||
current_sess = sessions.find(moment=now)
|
||||
assert current_sess.name == 'OverNight'
|
||||
assert noon_sess.name == 'London'
|
||||
assert no_sess is None
|
||||
assert next_sess.name == 'London'
|
||||
assert mid_nite_sess.name == 'OverNight'
|
||||
current = datetime.now(pytz.UTC).time()
|
||||
if current.hour not in (7, 17):
|
||||
await sessions.check()
|
||||
assert sessions.current_session is not None
|
||||
assert sessions.current_session.name in ('London', 'OverNight')
|
||||
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from aiomql.lib.symbol import Symbol
|
||||
from aiomql.lib.candle import Candles
|
||||
from aiomql.lib.ticks import Ticks
|
||||
|
||||
|
||||
class TestSymbol:
|
||||
@pytest.fixture(scope='class', autouse=True)
|
||||
async def btc(self):
|
||||
symbol = Symbol(name='BTCUSD')
|
||||
select = getattr(symbol, 'select', False)
|
||||
if select is False:
|
||||
await symbol.init()
|
||||
return symbol
|
||||
|
||||
async def test_symbol_attributes(self, btc):
|
||||
assert btc.name == 'BTCUSD'
|
||||
assert btc.select is True
|
||||
assert btc.tick is not None
|
||||
|
||||
async def test_volume(self, btc):
|
||||
volume = btc.volume_min - btc.volume_step
|
||||
success, volume = btc.check_volume(volume=volume)
|
||||
assert success is False
|
||||
volume = btc.volume_min + btc.volume_step * 2
|
||||
success, volume = btc.check_volume(volume=volume)
|
||||
assert success is True
|
||||
volume = btc.volume_min + btc.volume_step * 2.5
|
||||
volume = btc.round_off_volume(volume=volume, round_down=True)
|
||||
assert volume == btc.volume_min + btc.volume_step * 2
|
||||
|
||||
async def test_rates(self, btc):
|
||||
start = datetime(year=2023, month=10, day=5)
|
||||
end = start + timedelta(hours=9)
|
||||
rates_from = await btc.copy_rates_from(timeframe=btc.mt5.TIMEFRAME_H1, date_from=start, count=10)
|
||||
assert isinstance(rates_from, Candles)
|
||||
assert len(rates_from) == 10
|
||||
rates_from_pos = await btc.copy_rates_from_pos(timeframe=btc.mt5.TIMEFRAME_H1, count=10, start_position=0)
|
||||
assert isinstance(rates_from_pos, Candles)
|
||||
assert len(rates_from_pos) == 10
|
||||
rates_range = await btc.copy_rates_range(timeframe=btc.mt5.TIMEFRAME_H1, date_from=start, date_to=end)
|
||||
assert isinstance(rates_range, Candles)
|
||||
assert len(rates_range) == 10
|
||||
ticks_from = await btc.copy_ticks_from(date_from=start, count=10)
|
||||
assert isinstance(ticks_from, Ticks)
|
||||
assert len(ticks_from) == 10
|
||||
end = start + timedelta(seconds=20)
|
||||
ticks_from_pos = await btc.copy_ticks_range(date_from=start, date_to=end)
|
||||
assert isinstance(ticks_from_pos, Ticks)
|
||||
assert len(ticks_from_pos) >= 10
|
||||
@@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
|
||||
from aiomql.lib.terminal import Terminal
|
||||
|
||||
|
||||
class TestTerminal:
|
||||
@pytest.fixture(scope='class', autouse=True)
|
||||
async def init_terminal(self):
|
||||
terminal = Terminal()
|
||||
init = await terminal.initialize()
|
||||
return init, terminal
|
||||
|
||||
async def test_terminal(self, init_terminal):
|
||||
init, terminal = init_terminal
|
||||
assert init is True
|
||||
assert terminal.connected is True
|
||||
assert terminal.version is not None
|
||||
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime
|
||||
|
||||
from aiomql.lib.ticks import Ticks, Tick
|
||||
from pandas import Series
|
||||
|
||||
class TestTicks:
|
||||
async def test_tick(self, mt):
|
||||
btc_tick = await mt.symbol_info_tick("BTCUSD")
|
||||
btc_tick = Tick(**btc_tick._asdict())
|
||||
tick_dict = btc_tick.dict(include={'ask', 'bid', 'time', 'volume'})
|
||||
assert isinstance(btc_tick, Tick)
|
||||
assert isinstance(tick_dict, dict)
|
||||
assert 'ask' in tick_dict
|
||||
assert 'bid' in tick_dict
|
||||
assert 'volume_real' not in tick_dict
|
||||
|
||||
async def test_ticks(self, mt):
|
||||
start = datetime(year=2023, month=10, day=5)
|
||||
ticks = await mt.copy_ticks_from("BTCUSD", start, 10, mt.COPY_TICKS_ALL)
|
||||
ticks = Ticks(data=ticks)
|
||||
assert isinstance(ticks, Ticks)
|
||||
assert len(ticks) == 10
|
||||
assert isinstance(ticks[0], Tick)
|
||||
bids = ticks['bid']
|
||||
assert len(bids) == 10
|
||||
assert isinstance(bids, Series)
|
||||
Reference in New Issue
Block a user