minor bug fix

This commit is contained in:
Ichinga Samuel
2023-11-14 03:24:47 +01:00
parent 2462a02be1
commit 0dad24f38e
21 changed files with 893 additions and 309 deletions
+2
View File
@@ -11,6 +11,7 @@ from .core.constants import TimeFrame
logger = getLogger(__name__)
class Candle:
"""A class representing bars from the MetaTrader 5 terminal as a customized class analogous to Japanese Candlesticks.
You can subclass this class for added customization.
@@ -45,6 +46,7 @@ class Candle:
self.time = kwargs.pop('time', 0)
self.Index = kwargs.pop('Index', 0)
self.set_attributes(**kwargs)
def __repr__(self):
keys = reprlib.repr(', '.join('%s=%s' % (i, j) for i, j in self.__dict__.items()))[1:-1]
return '%(class)s(%(args)s)' % {'class': self.__class__.__name__, 'args': keys}
+3
View File
@@ -0,0 +1,3 @@
from .strategies import *
from .traders import *
from .symbols import *
+1
View File
@@ -0,0 +1 @@
from .finger_trap import FingerTrap
+210
View File
@@ -0,0 +1,210 @@
import asyncio
import logging
from typing import Literal
from dataclasses import dataclass
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
logger = logging.getLogger(__name__)
@dataclass
class Entry:
"""
Entry class for FingerTrap strategy. Will be used to store entry conditions and other entry related data.
Attributes:
bearish (bool): True if the market is bearish
bullish (bool): True if the market is bullish
ranging (bool): True if the market is ranging
snooze (float): Time to wait before checking for entry conditions
trend (str): The current trend of the market
new (bool): True if the last candle is new
order_type (OrderType): The type of order to place
"""
bearish: bool = False
bullish: bool = False
ranging: bool = True
trending: bool = False
trend: Literal["ranging", "bullish", "bearish"] = "ranging"
snooze: float = 0
last_trend_time: float = 0
last_entry_time: float = 0
new: bool = True
order_type: OrderType | None = None
def update(self, **kwargs):
fields = self.__dict__
for key in kwargs:
if key in fields:
setattr(self, key, kwargs[key])
match self.trend:
case "ranging":
self.ranging = True
self.trending = self.bullish = self.bearish = False
case "bullish":
self.ranging = self.bearish = False
self.bullish = self.trending = True
case "bearish":
self.ranging = self.bullish = False
self.bearish = self.trending = True
class FingerTrap(Strategy):
trend_time_frame: TimeFrame
entry_time_frame: TimeFrame
trend: int
fast_period: int
slow_period: int
entry_period: int
parameters: dict
prices: Candles
name = "FingerTrap"
interval: TimeFrame
entry_candles_count: int
trend_candles_count: int
def __init__(
self,
*,
symbol: Symbol,
params: dict | None = None,
trader: Trader = None,
sessions: Sessions = None,
):
super().__init__(symbol=symbol, params=params, sessions=sessions)
self.trend = self.parameters.get("trend", 3)
self.fast_period = self.parameters.setdefault("fast_period", 8)
self.slow_period = self.parameters.setdefault("slow_period", 34)
self.entry_time_frame = self.parameters.setdefault(
"entry_time_frame", TimeFrame.M5
)
self.trend_time_frame = self.parameters.setdefault(
"trend_time_frame", TimeFrame.H1
)
self.trader = trader or Trader(symbol=self.symbol)
self.entry: Entry = Entry(snooze=self.trend_time_frame.time)
self.entry_period = self.parameters.setdefault("entry_period", 8)
self.trend_candles_count = self.parameters.setdefault(
"trend_candles_count", 86400 // self.trend_time_frame.time
)
self.trend_candles_count = max(self.trend_candles_count, self.slow_period)
self.entry_candles_count = self.trend_candles_count * (
self.trend_time_frame.time // self.entry_time_frame.time
)
self.entry_candles_count = max(self.entry_candles_count, self.entry_period)
async def check_trend(self):
try:
candles = await self.symbol.copy_rates_from_pos(
timeframe=self.trend_time_frame, count=self.trend_candles_count
)
current = candles[-1]
if current.time > self.entry.last_trend_time:
self.entry.update(new=True, last_trend_time=current.time)
else:
self.entry.update(new=False)
return
candles.ta.ema(length=self.slow_period, append=True, fillna=0)
candles.ta.ema(length=self.fast_period, append=True, fillna=0)
candles.rename(
inplace=True,
**{
f"EMA_{self.fast_period}": "fast",
f"EMA_{self.slow_period}": "slow",
},
)
# Compute
candles["fast_A_slow"] = candles.ta_lib.above(candles.fast, candles.slow)
candles["fast_B_slow"] = candles.ta_lib.below(candles.fast, candles.slow)
candles["close_A_fast"] = candles.ta_lib.above(candles.close, candles.fast)
candles["close_B_fast"] = candles.ta_lib.below(candles.close, candles.fast)
trend = candles[-self.trend : -1]
if all(
(c.is_bullish() and c.fast_A_slow and c.close_A_fast) for c in trend
):
self.entry.update(trend="bullish")
elif all(
c.is_bearish() and c.fast_B_slow and c.close_B_fast for c in trend
):
self.entry.update(trend="bearish")
else:
self.entry.update(trend="ranging", snooze=self.trend_time_frame.time)
except Exception as exe:
logger.error(f"{exe}. Error in {self.__class__.__name__}.check_trend")
async def confirm_trend(self):
try:
candles = await self.symbol.copy_rates_from_pos(
timeframe=self.entry_time_frame, count=self.entry_candles_count
)
current = candles[-1]
if current.time > self.entry.last_entry_time:
self.entry.update(new=True, last_entry_time=current.time)
else:
self.entry.update(new=False)
return
candles.ta.ema(length=self.entry_period, append=True, fillna=0)
candles.rename(**{f"EMA_{self.entry_period}": "ema"})
candles["close_A_ema"] = candles.ta_lib.above(candles.close, candles.ema)
candles["close_B_ema"] = candles.ta_lib.below(candles.close, candles.ema)
candles["close_XA_ema"] = candles.ta_lib.cross(candles.close, candles.ema)
candles["close_XB_ema"] = candles.ta_lib.cross(
candles.close, candles.ema, above=False
)
if self.entry.bullish and current.close_XA_ema:
self.entry.update(
snooze=self.entry_time_frame.time, order_type=OrderType.BUY
)
elif self.entry.bearish and current.close_XB_ema:
self.entry.update(
snooze=self.entry_time_frame.time, order_type=OrderType.SELL
)
else:
self.entry.update(snooze=self.entry_time_frame.time, order_type=None)
except Exception as exe:
logger.error(f"{exe} Error in {self.__class__.__name__}.confirm_trend")
async def watch_market(self):
await self.check_trend()
if not self.entry.ranging:
await self.confirm_trend()
async def trade(self):
logger.info(f"Trading {self.symbol}")
async with self.sessions as sess:
while True:
await sess.check()
try:
await self.watch_market()
if not self.entry.new:
await asyncio.sleep(2)
continue
if self.entry.order_type is None:
await self.sleep(self.entry.snooze)
continue
await self.trader.place_trade(
order_type=self.entry.order_type, params=self.parameters
)
await self.sleep(self.entry.snooze)
except Exception as err:
logger.error(
f"Error: {err}\t Symbol: {self.symbol} in {self.__class__.__name__}.trade"
)
await self.sleep(self.trend_time_frame.time)
continue
+1
View File
@@ -0,0 +1 @@
from .forex_symbol import ForexSymbol
+30
View File
@@ -0,0 +1,30 @@
from ...symbol import Symbol
from ...core.exceptions import VolumeError
class CryptoSymbol(Symbol):
"""Subclass of Symbol for Crypto/Fiat Symbols. Handles the computation of volume based on the amount to risk."""
async def compute_volume(self, *, amount: float, points, use_limits=False) -> float:
"""Compute volume given an amount to risk and target pips. Round the computed volume to the nearest step.
Args:
amount (float): Amount to risk. Given in terms of the account currency.
points (float): Target pips.
use_limits (bool): If True, the computed volume checked against the maximum and minimum volume.
Returns:
float: volume
Raises:
VolumeError: If the computed volume is less than the minimum volume or greater than the maximum volume.
"""
if self.currency_profit != self.account.currency:
amount = await self.convert_currency(amount=amount, base=self.currency_profit, quote=self.account.currency)
volume = amount / (self.point * points * self.trade_contract_size)
volume = self.round_off_volume(volume)
if self.check_volume(volume)[0]:
return volume
if use_limits:
return self.check_volume(volume)[1]
raise VolumeError(f'Incorrect Volume. Computed Volume outside the range of permitted volumes')
+32
View File
@@ -0,0 +1,32 @@
from ...symbol import Symbol
from ...core.exceptions import VolumeError
class ForexSymbol(Symbol):
"""Subclass of Symbol for Forex Symbols. Handles the conversion of currency and the computation of stop loss,
take profit and volume.
"""
async def compute_volume(self, *, amount: float, pips, use_limits=False) -> float:
"""Compute volume given an amount to risk and target pips. Round the computed volume to the nearest step.
Args:
amount (float): Amount to risk. Given in terms of the account currency.
pips (float): Target pips.
use_limits (bool): If True, the computed volume checked against the maximum and minimum volume.
Returns:
float: volume
Raises:
VolumeError: If the computed volume is less than the minimum volume or greater than the maximum volume.
"""
if self.currency_profit != self.account.currency:
amount = await self.convert_currency(amount=amount, base=self.currency_profit, quote=self.account.currency)
volume = amount / (self.pip * pips * self.trade_contract_size)
volume = self.round_off_volume(volume)
if self.check_volume(volume)[0]:
return volume
if use_limits:
return self.check_volume(volume)[1]
raise VolumeError(f'Incorrect Volume. Computed Volume outside the range of permitted volumes')
View File
+3 -25
View File
@@ -1,6 +1,5 @@
"""Risk Assessment and Management"""
from .account import Account
from .symbol import Symbol
class RAM:
@@ -8,24 +7,20 @@ class RAM:
risk_to_reward: float
risk: float
amount: float
pips: float
volume: float
def __init__(self, *, risk_to_reward: float = 1, risk: float = 0.01, amount: float = 0, pips: float = 0, volume=0):
def __init__(self, *, risk_to_reward: float = 1, risk: float = 0.01, amount: float = 0, **kwargs):
"""Initialize Risk Assessment and Management with the provided keyword arguments.
Keyword Args:
risk_to_reward (float): Risk to reward ratio. Defaults to 1
risk (float): Percentage of account balance to risk per trade 0.01 # 1%
amount (float): Amount to risk per trade in terms of account currency 0
pips (float): Target pips to risk
volume (float): Volume to trade 0
kwargs: extra keyword arguments are set as object attributes
"""
self.risk_to_reward = risk_to_reward
self.risk = risk
self.amount = amount
self.pips = pips
self.volume = volume
[setattr(self, key, value) for key, value in kwargs.items()]
async def get_amount(self, risk: float = 0) -> float:
"""Calculate the amount to risk per trade as a percentage of equity.
@@ -39,20 +34,3 @@ class RAM:
await self.account.refresh()
risk = risk or self.risk
return self.account.equity * risk
async def get_volume(self, *, symbol: Symbol, pips: float = 0, amount: float = 0) -> float:
"""Calculate the volume to trade. if pips is not provided, the pips attribute is used.
If the amount attribute or amount argument is zero, the amount is calculated using the get_amount method based
on the risk.
Keyword Args:
symbol (Symbol): Financial instrument
pips (float): Target pips. Defaults to zero.
amount (float): Amount to risk per trade. Defaults to zero.
Returns:
float: Volume to trade
"""
pips = pips or self.pips
amount = amount or self.amount or await self.get_amount()
return await symbol.compute_volume(amount=amount, pips=pips)
+5 -9
View File
@@ -19,12 +19,6 @@ def delta(obj: time):
return timedelta(hours=obj.hour, minutes=obj.minute, seconds=obj.second, microseconds=obj.microsecond)
def seconds(start: time, end: time) -> set[int]:
if start > end:
return set(range(delta(start).seconds, 86400)) | set(range(0, delta(end).seconds))
return set(range(delta(start).seconds, delta(end).seconds))
class Session:
"""A session is a time period between two datetime.time objects specified in utc.
@@ -36,7 +30,6 @@ class Session:
custom_start (Callable): A custom function to call when the session starts. Default is None.
custom_end (Callable): A custom function to call when the session ends. Default is None.
name (str): A name for the session. Default is a combination of start and end.
seconds (set[int]): A set of seconds between start and end.
Methods:
begin: Call the action specified in on_start or custom_start.
@@ -69,10 +62,13 @@ class Session:
self.custom_start = custom_start
self.custom_end = custom_end
self.name = name or f'{self.start} - {self.end}'
self.seconds = seconds(self.start, self.end)
def __contains__(self, item: time):
return delta(item).seconds in self.seconds
if self.start > self.end:
m1 = time(hour=23, minute=59, second=59, microsecond=9999)
m2 = time(hour=0)
return self.start <= item <= m1 or m2 <= item < self.end
return self.start <= item < self.end
def __str__(self):
return f'{self.start}-->{self.name}-->{self.end}' if self.name else f'{self.start}-->{self.end}'
+41 -19
View File
@@ -79,7 +79,7 @@ class Symbol(SymbolInfo):
Raises:
ValueError: If request was unsuccessful and None was returned
"""
info = await self.mt5.symbol_info(self.name)
if info:
self.set_attributes(**info._asdict())
@@ -96,6 +96,7 @@ class Symbol(SymbolInfo):
if await self.symbol_select():
await self.book_add()
await self.info()
await self.info_tick()
return True
logger.warning(f'Unable to initialized symbol {self}')
return False
@@ -137,6 +138,15 @@ class Symbol(SymbolInfo):
return await self.mt5.market_book_release(self.name)
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:
volume (float): Volume to check
Returns: tuple[bool, float]: Returns a tuple of a boolean and a float. The boolean indicates if the volume is
within the limits of the symbol. The float is the volume to use if the volume is not within the limits of the
symbol.
"""
check = self.volume_min <= volume <= self.volume_max
if check:
return check, volume
@@ -145,25 +155,36 @@ class Symbol(SymbolInfo):
else:
return check, self.volume_max
def round_off_volume(self, volume):
def round_off_volume(self, volume) -> float:
"""Round off the volume to the nearest volume step.
Args:
volume (float): Volume to round off
Returns:
float: Rounded off volume
"""
step = ceil(abs(log10(self.volume_step)))
return round(volume, step)
async def compute_volume(self, *, amount: float, pips: float, use_limits: bool = False) -> float:
"""Computes the volume of a trade based on the amount and the number of pips to target.
async def compute_volume(self, *args, **kwargs) -> float:
"""Computes the volume required for a trade usually based on the amount and any other keyword arguments.
This is a dummy method that returns the minimum volume of the symbol. It is meant to be overridden by a subclass
that implements the computation of volume.
Args:
amount (float): Amount to risk in the trade
pips (float): Number of pips to target
use_limits (bool): If True, the computed volume is rounded to the nearest step and checked against
Keyword Args:
use_limits (bool): round up or round down the computed volume to the nearest volume limit i.e volume_min
or volume_max
Returns:
float: Returns the volume of the trade
"""
return self.volume_min
async def convert_currency(self, *, amount: float, base: str, quote: str) -> float:
"""Convert from one currency to the other. Alias for currency_conversion"""
return await self.currency_conversion(amount=amount, base=base, quote=quote)
async def currency_conversion(self, *, amount: float, base: str, quote: str) -> float:
"""Convert from one currency to the other.
@@ -173,7 +194,7 @@ class Symbol(SymbolInfo):
quote: The quote currency of the pair
Returns:
float: Amount in terms of the base currency or None if it failed to convert
float: Amount in terms of the base currency
Raises:
ValueError: If conversion is impossible
@@ -189,8 +210,7 @@ class Symbol(SymbolInfo):
if self.account.has_symbol(pair):
tick = await self.info_tick(name=pair)
if tick is not None:
amount = amount * tick.bid
return amount
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}')
@@ -201,11 +221,11 @@ class Symbol(SymbolInfo):
"""
Get bars from the MetaTrader 5 terminal starting from the specified date.
Args:
timeframe (TimeFrame): Timeframe the bars are requested for. Set by a value from the TimeFrame enumeration. Required unnamed parameter.
Args: timeframe (TimeFrame): Timeframe the bars are requested for. Set by a value from the TimeFrame
enumeration. Required unnamed parameter.
date_from (datetime | int): Date of opening of the first bar from the requested sample. Set by the 'datetime' object or as a number
of seconds elapsed since 1970.01.01. Required unnamed parameter.
date_from (datetime | int): Date of opening of the first bar from the requested sample. Set by the
'datetime' object or as a number of seconds elapsed since 1970.01.01. Required unnamed parameter.
count (int): Number of bars to receive. Required unnamed parameter.
@@ -220,7 +240,7 @@ class Symbol(SymbolInfo):
return Candles(data=rates)
raise ValueError(f'Could not get rates for {self.name}')
async def copy_rates_from_pos(self, *,timeframe: TimeFrame, count: int = 500, start_position: int = 0) -> Candles:
async def copy_rates_from_pos(self, *, timeframe: TimeFrame, count: int = 500, start_position: int = 0) -> Candles:
"""Get bars from the MetaTrader 5 terminal starting from the specified index.
Args:
@@ -267,7 +287,8 @@ class Symbol(SymbolInfo):
return Candles(data=rates)
raise ValueError(f'Could not get rates for {self.name}')
async def copy_ticks_from(self, *, date_from: datetime | int, count: int = 100, flags: CopyTicks = CopyTicks.ALL) -> Ticks:
async def copy_ticks_from(self, *, date_from: datetime | int, count: int = 100,
flags: CopyTicks = CopyTicks.ALL) -> Ticks:
"""
Get ticks from the MetaTrader 5 terminal starting from the specified date.
@@ -289,7 +310,8 @@ class Symbol(SymbolInfo):
return Ticks(data=ticks)
raise ValueError(f'Could not get ticks for {self.name}')
async def copy_ticks_range(self, *, date_from: datetime | int, date_to: datetime | int, flags: CopyTicks = CopyTicks.ALL) -> Ticks:
async def copy_ticks_range(self, *, date_from: datetime | int, date_to: datetime | int,
flags: CopyTicks = CopyTicks.ALL) -> Ticks:
"""Get ticks for the specified date range from the MetaTrader 5 terminal.
Args:
@@ -298,7 +320,7 @@ class Symbol(SymbolInfo):
date_to: Date, up to which the bars are requested. Set by the 'datetime' object or as a number of seconds elapsed since 1970.01.01. Bars
with the open time <= date_to are returned. Required unnamed parameter.
flags (CopyTicks):
Returns:
+63 -40
View File
@@ -8,7 +8,7 @@ from zoneinfo import ZoneInfo
from .order import Order
from .symbol import Symbol as _Symbol
from .ram import RAM
from .core.models import OrderType
from .core.models import OrderType, OrderSendResult
from .core.config import Config
from .utils import dict_to_string
from .result import Result
@@ -43,34 +43,43 @@ class Trader:
self.symbol = symbol
self.order = Order(symbol=symbol.name)
self.ram = ram or RAM()
self.params = {}
async def create_order(self, *, order_type: OrderType, **kwargs):
"""Complete the order object with the required values. Creates a simple order.
Uses the ram instance to set the volume.
Args:
order_type (OrderType): Type of order
kwargs: keyword arguments as required for the specific trader
"""
# check if pips is passed in as a keyword argument, if not use the pips attribute of the ram instance
pips = kwargs.get('pips', 0) or self.ram.pips
self.order.volume = kwargs.get('volume', self.ram.volume) or await self.ram.get_volume(symbol=self.symbol,
pips=pips)
points = kwargs.get('points', self.symbol.trade_stops_level+self.symbol.spread)
self.order.volume = await self.symbol.compute_volume()
self.order.type = order_type
await self.set_order_limits(pips=pips)
await self.set_trade_stop_levels(points=points)
async def set_order_limits(self, pips: float):
"""Sets the stop loss and take profit for the order.
This method uses pips as defined for forex instruments.
"""Sets the stop loss and take profit for the order. This method uses pips as defined for forex instruments.
Args:
pips: Target pips
"""
# use passed in pips and the pip value of the symbol to calculate the stop loss and take profit.
# this is sure to work for forex instruments.
pips = pips * self.symbol.pip
sl, tp = pips, pips * self.ram.risk_to_reward
tick = await self.symbol.info_tick()
if self.order.type == OrderType.BUY:
self.order.sl, self.order.tp = tick.ask - sl, tick.ask + tp
self.order.price = tick.ask
elif self.order.type == OrderType.SELL:
self.order.sl, self.order.tp = tick.bid + sl, tick.bid - tp
self.order.price = tick.bid
else:
raise ValueError(f"Invalid order type: {self.order.type}")
async def set_trade_stop_levels(self, *, points):
"""Set the stop loss and take profit levels of the order based on the points."""
points = points * self.symbol.point
sl, tp = points, points * self.ram.risk_to_reward
tick = await self.symbol.info_tick()
if self.order.type == OrderType.BUY:
self.order.sl, self.order.tp = tick.ask - sl, tick.ask + tp
self.order.price = tick.ask
@@ -78,6 +87,46 @@ class Trader:
self.order.sl, self.order.tp = tick.bid + sl, tick.bid - tp
self.order.price = tick.bid
async def check_order(self) -> bool:
"""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"Symbol: {self.order.symbol}\nResult:\n{dict_to_string(check.get_dict(include={'comment', 'retcode'}), multi=True)}")
return False
return True
async def send_order(self):
result = await self.order.send()
if result.retcode != 10009:
logger.warning(
f"Symbol: {self.order.symbol}\nResult:\n{dict_to_string(result.get_dict(include={'comment', 'retcode'}), multi=True)}")
return
logger.info(f"Symbol: {self.order.symbol}\nOrder: {dict_to_string(result.dict, multi=True)}\n")
await self.record_trade(result)
async def record_trade(self, result: OrderSendResult):
"""
Record the trade in a csv file.
Args:
result (OrderSendResult): Result of the order send
"""
if result.retcode != 10009 or not self.config.record_trades:
return
profit = await self.order.calc_profit()
params = self.params
params['expected_profit'] = profit
date = datetime.utcnow()
date = date.replace(tzinfo=ZoneInfo('UTC'))
params['date'] = date
params['time'] = date.timestamp()
res = Result(result=result, parameters=params)
await res.save_csv()
async def place_trade(self, order_type: OrderType, params: dict = None, **kwargs):
"""Places a trade based on the order_type.
@@ -88,35 +137,9 @@ class Trader:
"""
try:
await self.create_order(order_type=order_type, **kwargs)
# Check the order before placing it
check = await self.order.check()
if check.retcode != 0:
logger.warning(
f"Symbol: {self.order.symbol}\nResult:\n{dict_to_string(check.get_dict(include={'comment', 'retcode'}), multi=True)}")
if not await self.check_order():
return
# check expected profit
profit = await self.order.calc_profit()
# Send the order.
result = await self.order.send()
if result.retcode != 10009:
logger.warning(
f"Symbol: {self.order.symbol}\nResult:\n{dict_to_string(result.get_dict(include={'comment', 'retcode'}), multi=True)}")
return
logger.info(f"Symbol: {self.order.symbol}\nOrder: {dict_to_string(result.dict, multi=True)}\n")
# save trade result and passed in parameters
if result.retcode == 10009 and self.config.record_trades:
params = params or {}
params['expected_profit'] = profit
date = datetime.utcnow()
date = date.replace(tzinfo=ZoneInfo('UTC'))
params['date'] = date
params['time'] = date.timestamp()
res = Result(result=result, parameters=params)
await res.save_csv()
self.params |= params or {}
await self.send_order()
except Exception as err:
logger.error(f"{err}. Symbol: {self.order.symbol}\n {self.__class__.__name__}.place_trade")
+1
View File
@@ -1,4 +1,5 @@
"""Utility functions for aiomql."""
def dict_to_string(data: dict, multi=False) -> str:
"""Convert a dict to a string. Use for logging.