Update tests and docs across core, lib, and contrib modules

This commit is contained in:
Ichinga Samuel
2026-02-16 10:20:49 +01:00
parent 9ecc2bb7f7
commit 844e39194b
493 changed files with 48196 additions and 22486 deletions
+1
View File
@@ -82,3 +82,4 @@ terminals/
backtesting/
trade_records/
plots/
db/
@@ -1,17 +0,0 @@
{
"balance": 635.28,
"profit": 0,
"equity": 635.28,
"margin": 0.0,
"margin_free": 635.28,
"margin_level": 0,
"wins": 29,
"losses": 40,
"total": 69,
"win_percentage": 42.03,
"win": 847.84,
"loss": -562.56,
"net_profit": 285.28,
"profit_factor": 1.51,
"profitability": 81.51
}
-1
View File
@@ -1 +0,0 @@
{"balance": 100, "profit": -11.76, "equity": 88.24, "margin": 88.87000000000005, "margin_free": -0.6300000000000523, "margin_level": 99.29109935861365}
-8
View File
@@ -1,8 +0,0 @@
{
"balance": 131.67,
"profit": 0,
"equity": 131.67,
"margin": 0.0,
"margin_free": 131.67,
"margin_level": 0
}
-8
View File
@@ -1,8 +0,0 @@
{
"balance": 590.45,
"profit": 0,
"equity": 590.45,
"margin": 0.0,
"margin_free": 590.45,
"margin_level": 0
}
-1
View File
@@ -1 +0,0 @@
{"balance": 0, "profit": -0.53, "equity": -0.53, "margin": 2.03, "margin_free": -2.5599999999999996, "margin_level": -26.108374384236456}
@@ -1,17 +0,0 @@
{
"balance": 367056.24,
"profit": 0,
"equity": 367056.24,
"margin": 0.0,
"margin_free": 367056.24,
"margin_level": 0,
"wins": 374,
"losses": 394,
"total": 768,
"win_percentage": 48.7,
"win": 946684.28,
"loss": -580378.04,
"net_profit": 366306.24,
"profit_factor": 1.63,
"profitability": 48840.83
}
@@ -1,17 +0,0 @@
{
"balance": 1251.1,
"profit": 0,
"equity": 1251.1,
"margin": 0.0,
"margin_free": 1251.1,
"margin_level": 0,
"wins": 29,
"losses": 31,
"total": 60,
"win_percentage": 48.33,
"win": 1530.54,
"loss": -1029.44,
"net_profit": 501.1,
"profit_factor": 1.49,
"profitability": 66.81
}
+3 -2
View File
@@ -8,7 +8,7 @@ logging.basicConfig(level=logging.INFO)
def x_bot():
syms = ["LTCUSD", "ETHUSD", "SOLUSD", "BTCUSD"]
syms = ["LTCUSD", "ETHUSD", "SOLUSD", "BTCUSD", "ADAUSD"]
symbols = [ForexSymbol(name=sym) for sym in syms]
strategies = [EMAXOver(symbol=symbol) for symbol in symbols]
bot = Bot()
@@ -18,4 +18,5 @@ def x_bot():
bot.execute()
x_bot()
if __name__ == '__main__':
x_bot()
+1 -1
View File
@@ -14,7 +14,7 @@ class EMAXOver(Strategy):
# default parameters for the strategy
# they are set as attributes. You can override them in the constructor via the params argument.
parameters = {'ttf': TimeFrame.M10, 'tcc': 3000, 'fast_ema': 34, 'slow_ema': 55, 'interval': TimeFrame.M5,
'timeout': 120}
'timeout': 120, "macd": 87, "sma": 90}
def __init__(self, *, symbol: ForexSymbol, params: dict | None = None, trader: Trader = None,
sessions: Sessions = None, name: str = "EMAXOver"):
+1 -1
View File
@@ -1 +1 @@
from .track import close_after
from .track import close_after, hedge_position, track_hedges
+25
View File
@@ -1,3 +1,4 @@
import asyncio
from logging import getLogger
from datetime import datetime
@@ -16,3 +17,27 @@ async def close_after(open_pos: OpenPosition, /, *, duration: int, start: float
_, res = await open_pos.close_position()
if res.retcode == 10009:
logger.info("%s, %d closed", pos.symbol, pos.ticket)
async def hedge_position(pos: OpenPosition, /, *, hedge_amount: float = -2, close_hedge_amount: float = 0,
order_params: dict = None):
is_open = await pos.update_position()
position = pos.position
if not (is_open and pos.is_hedged is False and position.profit < 0):
return
if position.profit <= hedge_amount:
ok, order = await pos.hedge_position(order_params=order_params)
if not ok:
logger.error("Could not hedge %s:%d", pos.symbol, pos.ticket)
async def track_hedges(pos: OpenPosition, close_hedge_amount: float = -10):
res = await pos.update_position()
if not res:
return
hedges = list(pos.hedges.values())
await asyncio.gather(*[hedge.update_position() for hedge in hedges], return_exceptions=True)
hedges = [hedge for hedge in hedges if hedge.is_open]
await asyncio.gather(*[hedge.close_position() for hedge in hedges if hedge.position.profit <= close_hedge_amount],
return_exceptions=True)
+25 -7
View File
@@ -1,8 +1,8 @@
from logging import getLogger
from datetime import datetime
from aiomql import Trader, OrderType, OpenPosition, Positions, PositionTracker, Store
from aiomql import Trader, OrderType, OpenPosition, Positions, PositionTracker, Store, exit_at_profit, round_off
from ..trackers import close_after
from ..trackers import close_after, track_hedges, hedge_position
logger = getLogger(__name__)
@@ -25,7 +25,7 @@ class TestTrader(Trader):
"""
try:
self.parameters |= parameters or {}
volume = volume or self.symbol.volume_min
volume = volume or self.symbol.volume_min * 20
await self.create_order_no_stops(order_type=order_type, volume=volume)
if not await self.check_order():
return
@@ -33,10 +33,28 @@ class TestTrader(Trader):
res = await self.send_order()
if res is not None and res.retcode == 10009:
position = await self.positions.get_position_by_ticket(ticket=res.order)
open_position = OpenPosition(ticket=res.order, symbol=self.symbol, position=position)
kwargs = {"duration": 40, "start": datetime.now().timestamp()}
ca = PositionTracker(close_after, **kwargs)
open_position.add_tracker(tracker=ca)
open_position = OpenPosition(ticket=res.order, symbol=self.symbol, position=position,
close_hedges_on_close=True, close_stacks_on_close=True)
kwargs = {"duration": 3600, "start": datetime.now().timestamp()}
PositionTracker(open_position, hedge_position)
PositionTracker(open_position, track_hedges)
PositionTracker(open_position, close_after, function_params=kwargs)
PositionTracker(open_position, exit_at_profit, function_params={"tp": 10, "sl": -12})
price_to_hedge = await open_position.profit_to_price(profit=-10)
price_to_stack = await open_position.profit_to_price(profit=5)
price_to_stack = round_off(price_to_stack, self.symbol.digits)
price_to_hedge = round_off(price_to_hedge, self.symbol.digits)
# await open_position.stack_order(price=price_to_stack, open_pos_params={"close_stacks_on_close": True})
# await open_position.hedge_order(price=price_to_hedge,
# open_pos_params={"close_hedges_on_close": True})
price_to_hedge2 = await open_position.profit_to_price(profit=-8)
price_to_hedge2 = round_off(price_to_hedge2, self.symbol.digits)
price_to_stack2 = await open_position.profit_to_price(profit=7)
price_to_stack2 = round_off(price_to_stack2, self.symbol.digits)
await open_position.hedge_order(price=price_to_hedge2,
open_pos_params={"close_hedges_on_close": True})
await open_position.stack_order(price=price_to_stack2,
open_pos_params={"close_stacks_on_close": True})
await self.record_trade(result=res, parameters=self.parameters)
except Exception as err:
logger.error(f"{err} in {self.__class__.__name__}.place_trade for {self.symbol.name}")
@@ -0,0 +1,17 @@
import asyncio
from aiomql import ResultDB, Result, TradeRecords
async def update_sql_records():
tr = TradeRecords()
# await tr.update_sql_records()
# await tr.update_csv_records()
await tr.update_json_records()
def to_csv():
ResultDB.dump_to_csv()
if __name__ == "__main__":
asyncio.run(update_sql_records())
# to_csv()
-28
View File
@@ -1,28 +0,0 @@
import logging
from datetime import datetime, UTC
from aiomql.lib.backtester import BackTester
from aiomql.core import Config
from aiomql.contrib.strategies import FingerTrap, Chaos
from aiomql.contrib.symbols import ForexSymbol
from aiomql.core.backtesting import BackTestEngine
def back_tester():
Config(mode="backtest")
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
syms = ["Volatility 75 Index", "Volatility 100 Index", "Volatility 25 Index", "Volatility 10 Index"]
symbols = [ForexSymbol(name=sym) for sym in syms]
strategies = [FingerTrap(symbol=symbol) for symbol in symbols]
start = datetime(2024, 1, 1, tzinfo=UTC)
stop_time = datetime(2024, 12, 2, tzinfo=UTC)
end = datetime(2024, 5, 7, tzinfo=UTC)
back_test_engine = BackTestEngine(start=start, end=end, speed=3600,
close_open_positions_on_exit=True, assign_to_config=True, preload=True,
account_info={"balance": 750})
backtester = BackTester(backtest_engine=back_test_engine)
backtester.add_strategies(strategies=strategies)
backtester.execute()
back_tester()
-27
View File
@@ -1,27 +0,0 @@
import logging
import asyncio
from aiomql.lib.bot import Bot
from aiomql.contrib.strategies import FingerTrap, Chaos
from aiomql.contrib.symbols import ForexSymbol
def sample_bot():
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
syms = ["Volatility 75 Index", "Volatility 100 Index", "Volatility 50 Index"]
symbols = [ForexSymbol(name=sym) for sym in syms]
strategies = [Chaos(symbol=symbol) for symbol in symbols]
bot = Bot()
bot.executor.timeout = 10
bot.add_coroutine(coroutine=sleep_run)
bot.add_strategies(strategies=strategies)
bot.execute()
async def sleep_run():
while True:
print("Sleeping for 5 seconds")
await asyncio.sleep(5)
print("Hello World")
sample_bot()
-4
View File
@@ -1,4 +0,0 @@
slow_ema,fast_ema,order,htf,actual_profit,symbol,date,closed,name,price,deal,ltf,bid,win,ask,lcc,volume,expected_profit,hcc
20,8,8218315320,TIMEFRAME_M2,0,Volatility 75 Index,2025-01-16 11:49:50.421946,False,Chaos,96536.19,8126025355,TIMEFRAME_M1,96536.19,False,96562.33,100,0.001,0,100
20,8,8218315356,TIMEFRAME_M2,0,Volatility 50 Index,2025-01-16 11:49:54.394496,False,Chaos,272.9174,8126025389,TIMEFRAME_M1,272.9174,False,272.9584,100,4.0,0,100
20,8,8218315338,TIMEFRAME_M2,0,Volatility 100 Index,2025-01-16 11:49:54.395497,False,Chaos,1939.81,8126025382,TIMEFRAME_M1,1939.27,False,1939.81,100,0.5,0,100
1 slow_ema fast_ema order htf actual_profit symbol date closed name price deal ltf bid win ask lcc volume expected_profit hcc
2 20 8 8218315320 TIMEFRAME_M2 0 Volatility 75 Index 2025-01-16 11:49:50.421946 False Chaos 96536.19 8126025355 TIMEFRAME_M1 96536.19 False 96562.33 100 0.001 0 100
3 20 8 8218315356 TIMEFRAME_M2 0 Volatility 50 Index 2025-01-16 11:49:54.394496 False Chaos 272.9174 8126025389 TIMEFRAME_M1 272.9174 False 272.9584 100 4.0 0 100
4 20 8 8218315338 TIMEFRAME_M2 0 Volatility 100 Index 2025-01-16 11:49:54.395497 False Chaos 1939.81 8126025382 TIMEFRAME_M1 1939.27 False 1939.81 100 0.5 0 100
+3
View File
@@ -31,5 +31,8 @@ description = "Asynchronous MetaTrader5 library and Algorithmic Trading Framewor
[dependency-groups]
dev = [
"jupyter>=1.1.1",
"pandas-stubs>=3.0.0.260204",
"pytest>=8.4.1",
"pytest-asyncio>=1.3.0",
"ta-lib>=0.6.8",
]
-1
View File
@@ -1,5 +1,4 @@
from .strategies import *
from .candle_patterns import *
from .symbols import *
from .utils import *
from .traders import *
@@ -1 +0,0 @@
from .fractals import *
@@ -1,177 +0,0 @@
from aiomql import Candle, Candles
from ...utils.change import percentage_difference
def is_bullish_fractal(candles: Candles) -> tuple[bool, Candle|None]:
"""Check if the candles form a five-candle bullish fractal"""
if len(candles) < 5:
return False, None
# find the two candles by the left and right of the middle candle
# from left to right; oldest to most recent
first_left, second_left = candles[-5], candles[-4]
first_right, second_right = candles[-2], candles[-1]
center = candles[-3]
if not ((first_left.low > second_left.low > center.low < first_right.low < second_right.low)
and center.is_bearish() and first_right.is_bullish()):
return False, None
return True, center
def is_half_bullish_fractal(candles: Candles) -> tuple[bool, Candle|None]:
"""Check for a partially formed bullish fractal"""
if len(candles) < 4:
return False, None
# find the two candles by the left and right of the current candle
# from left to right; oldest to most recent
first_left, second_left = candles[-4], candles[-3]
first_right = candles[-1]
center = candles[-3]
if not ((first_left.low > second_left.low > center.low < first_right.low) and center.is_bearish() and first_right.is_bullish()):
return False, None
return True, center
def is_half_bearish_fractal(candles: Candles) -> tuple[bool, Candle|None]:
if len(candles) < 4:
return False, None
# find the two candles by the left and right of the current candle
# from left to right; oldest to most recent
first_left, second_left = candles[-4], candles[-3]
first_right = candles[-1]
center = candles[-2]
if not ((first_left.high < second_left.high < center.high > first_right.high) and center.is_bullish() and first_right.is_bearish()):
return False, None
return True, center
def is_bearish_fractal(candles: Candles) -> tuple[bool, Candle|None]:
"""Check if the candles form a bearish fractal"""
if len(candles) < 5:
return False, None
# find the two candles by the left and right of the middle candle
# from left to right; oldest to most recent
first_left, second_left = candles[-5], candles[-4]
first_right, second_right = candles[-2], candles[-1]
center = candles[-3]
if not ((first_left.high < second_left.high < center.high > first_right.high > second_right.high) and
center.is_bullish() and first_right.is_bearish()):
return False, None
return True, center
def is_double_bullish_fractal(candles: Candles, tolerance: float = 1) -> tuple[bool, Candle|None]:
"""Check if the candles form a double bullish fractal"""
if len(candles) < 4:
return False, None
# find the two candles on the left and right
# from left to right; oldest to most recent
left, right = candles[-4], candles[-1]
center_left, center_right = candles[-3], candles[-2]
if not (percentage_difference(center_left.low, center_right.low) <= tolerance):
return False, None
if not (center_left.is_bearish() and center_right.is_bullish()):
return False, None
if not (left.low > center_left.low and center_right.low < right.low):
return False, None
return True, min(center_left, center_right, key=lambda c: c.low)
def is_double_bearish_fractal(candles: Candles, tolerance: float = 1) -> tuple[bool, Candle|None]:
"""Check if the candles form a double bearish fractal"""
if len(candles) < 4:
return False, None
# find the two candles on the left and right
# from left to right; oldest to most recent
left, right = candles[-4], candles[-1]
center_left, center_right = candles[-3], candles[-2]
if not (percentage_difference(center_left.high, center_right.high) <= tolerance):
return False, None
if not (center_left.is_bullish() and center_right.is_bearish()):
return False, None
if not (left.high < center_left.high and center_right.high > right.high):
return False, None
return True, max(center_left, center_right, key=lambda c: c.high)
def find_bullish_fractal(candles: Candles, swing_number: int = 1, min_price: float = None) -> tuple[Candle, Candles] | None:
"""Find a bullish fractal pattern"""
fractal_candles: Candles
swing_candle: Candle | None = None
min_price: float = min_price or candles[-1].low
for c in reversed(candles):
fractal_candles = candles[c.Index: c.Index + 5]
ok, candle = is_bullish_fractal(fractal_candles)
if ok and ((swing_candle is not None and candle.low < swing_candle.low) or swing_candle is None) and candle.low < min_price:
swing_number -= 1
swing_candle = candle
if swing_number <= 0:
return swing_candle, fractal_candles
continue
return None
def find_bearish_fractal(candles: Candles, swing_number: int = 1, max_price: float = None) -> tuple[Candle, Candles] | None:
"""Find a bearish fractal pattern"""
fractal_candles: Candles
swing_candle: Candle | None = None
max_price: float = max_price or candles[-1].high
for c in reversed(candles):
fractal_candles = candles[c.Index: c.Index + 5]
ok, candle = is_bearish_fractal(fractal_candles)
if (ok and ((swing_candle is not None and candle.high > swing_candle.high) or swing_candle is None) and
candle.high > max_price):
swing_number -= 1
swing_candle = candle
if swing_number <= 0:
return swing_candle, fractal_candles
continue
return None
def find_double_bearish_fractal(candles: Candles, swing_number: int = 1,
tolerance: float = 1, max_price: float = None) -> tuple[Candle, Candles] | None:
"""Find double bearish Fractals"""
fractal_candles: Candles
swing_candle: Candle | None = None
max_price: float = max_price or candles[-1].high
for c in reversed(candles):
fractal_candles = candles[c.Index: c.Index + 4]
ok, candle = is_double_bearish_fractal(fractal_candles, tolerance=tolerance)
if (ok and ((swing_candle is not None and candle.high > swing_candle.high) or swing_candle is None)
and candle.high > max_price):
swing_number -= 1
swing_candle = candle
if swing_number <= 0:
return swing_candle, fractal_candles
continue
return None
def find_double_bullish_fractal(candles: Candles, swing_number: int = 1,
tolerance: float = 1, min_price: float = None) -> tuple[Candle, Candles] | None:
"""Find double bullish Fractals"""
fractal_candles: Candles
swing_candle: Candle | None = None
min_price: float = min_price or candles[-1].low
for c in reversed(candles):
fractal_candles = candles[c.Index: c.Index + 4]
ok, candle = is_double_bullish_fractal(fractal_candles, tolerance=tolerance)
if (ok and ((swing_candle is not None and candle.low < swing_candle.low) or swing_candle is None)
and candle.low < min_price):
swing_number -= 1
swing_candle = candle
if swing_number <= 0:
return swing_candle, fractal_candles
continue
return None
@@ -1,2 +1 @@
from .finger_trap import FingerTrap
from .chaos import Chaos
@@ -1,111 +0,0 @@
import logging
from ..symbols import ForexSymbol
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 ..utils import Tracker
logger = logging.getLogger(__name__)
class FingerTrap(Strategy):
ttf: TimeFrame
etf: TimeFrame
fast_ema: int
slow_ema: int
entry_ema: int
ecc: int
tcc: int
trader: Trader
tracker: Tracker
# The default parameters for the strategy. You can override these in the constructor.
# via the `params` argument.
parameters = {"fast_ema": 8, "slow_ema": 20, "etf": TimeFrame.M5, "ttf": TimeFrame.H1,
"entry_ema": 5, "tcc": 720, "ecc": 8640}
def __init__(self, *, symbol: ForexSymbol, params: dict | None = None, trader: Trader = None, sessions: Sessions = None,
name: str = "FingerTrap"):
super().__init__(symbol=symbol, params=params, sessions=sessions, name=name)
self.trader = trader or SimpleTrader(symbol=self.symbol)
self.tracker: Tracker = Tracker(snooze=self.ttf.seconds)
async def check_trend(self):
try:
candles: Candles = await self.symbol.copy_rates_from_pos(timeframe=self.ttf, count=self.tcc)
if (current := candles[-1]) and current.time < self.tracker.trend_time:
self.tracker.update(new=False, order_type=None)
return
self.tracker.update(new=True, trend_time=current.time, last_trend_price=current.close)
candles.ta.ema(length=self.slow_ema, append=True, fillna=0)
candles.ta.ema(length=self.fast_ema, append=True, fillna=0)
candles.rename(inplace=True, **{f"EMA_{self.fast_ema}": "fast", f"EMA_{self.slow_ema}": "slow"})
fas = candles.ta_lib.above(candles.fast, candles.slow)
fbs = candles.ta_lib.below(candles.fast, candles.slow)
caf = candles.ta_lib.above(candles.close, candles.fast)
cbf = candles.ta_lib.below(candles.close, candles.fast)
if fas.iloc[-1] and caf.iloc[-1]:
self.tracker.update(trend="bullish")
elif fbs.iloc[-1] and cbf.iloc[-1]:
self.tracker.update(trend="bearish")
else:
self.tracker.update(trend="ranging", snooze=self.ttf.seconds, order_type=None)
except Exception as err:
logger.error(f"{err} for {self.symbol} in {self.__class__.__name__}.check_trend")
self.tracker.update(snooze=self.ttf.seconds, order_type=None)
async def confirm_trend(self):
try:
candles = await self.symbol.copy_rates_from_pos(timeframe=self.etf, count=self.ecc)
if (current := candles[-1]) and current.time < self.tracker.entry_time:
self.tracker.update(new=False, order_type=None)
return
self.tracker.update(new=True, entry_time=current.time, last_entry_price=current.close)
candles.ta.ema(length=self.entry_ema, append=True)
candles.rename(**{f"EMA_{self.entry_ema}": "ema"})
candles["cae"] = candles.ta_lib.cross(candles.close, candles.ema)
candles["cbe"] = candles.ta_lib.cross(candles.close, candles.ema, above=False)
current = candles[-1]
if self.tracker.bullish and current.cae:
sl = find_bullish_fractal(candles).low
self.tracker.update(snooze=self.ttf.seconds, order_type=OrderType.BUY, sl=sl)
elif self.tracker.bearish and current.cbe:
sl = find_bearish_fractal(candles).high
self.tracker.update(snooze=self.ttf.seconds, order_type=OrderType.SELL, sl=sl)
else:
self.tracker.update(snooze=self.etf.seconds, order_type=None)
except Exception as err:
logger.error(f"{err} for {self.symbol} in {self.__class__.__name__}.confirm_trend")
self.tracker.update(snooze=self.etf.seconds, order_type=None)
async def watch_market(self):
await self.check_trend()
if self.tracker.ranging is False:
await self.confirm_trend()
async def trade(self):
try:
await self.watch_market()
if self.tracker.new is False:
await self.sleep(secs=5)
elif self.tracker.order_type is None:
await self.sleep(secs=self.tracker.snooze)
else:
await self.trader.place_trade(order_type=self.tracker.order_type, parameters=self.parameters,
sl=self.tracker.sl)
await self.sleep(secs=self.tracker.snooze)
except Exception as err:
logger.error(f"{err} For {self.symbol} in {self.__class__.__name__}.trade")
await self.sleep(secs=self.ttf.seconds)
+118 -20
View File
@@ -1,52 +1,150 @@
"""Forex symbol module for handling forex trading instruments.
This module provides the ForexSymbol class, a specialized subclass of Symbol
designed for forex trading. It includes forex-specific calculations for pips,
points, and volume computations based on price movements and stop-loss levels.
Example:
Basic usage of ForexSymbol::
from aiomql.contrib.symbols import ForexSymbol
# Create a forex symbol instance
eurusd = ForexSymbol(name="EURUSD")
await eurusd.initialize()
# Get the pip value
pip_value = eurusd.pip
# Compute volume based on risk amount and stop loss
volume = eurusd.compute_volume_sl(
amount=100.0,
price=1.1000,
sl=1.0950
)
"""
from ...lib.symbol import Symbol
class ForexSymbol(Symbol):
"""Subclass of Symbol for Forex Symbols. Handles the conversion of currency and the computation of stop loss,
take profit and volume.
"""Subclass of Symbol for forex trading instruments.
This class extends the base Symbol class with forex-specific functionality,
including pip value calculations and volume computations based on points
or stop-loss levels. It handles the conversion of currency and the
computation of stop loss, take profit, and volume for forex trades.
Attributes:
tick (Tick): Price tick object for the instrument, inherited from Symbol.
account (Account): Account object associated with the symbol, inherited from Symbol.
Note:
All monetary amounts should be in the account's base currency unless
otherwise specified.
"""
@property
def pip(self):
"""Returns the pip value of the symbol. This is ten times the point value for forex symbols.
def pip(self) -> float:
"""Get the pip value of the forex symbol.
For forex symbols, a pip is defined as ten times the point value.
This is the standard convention where most forex pairs have a pip
as the fourth decimal place (or second for JPY pairs).
Returns:
float: The pip value of the symbol.
float: The pip value of the symbol, calculated as point * 10.
Example:
>>> eurusd = ForexSymbol(name="EURUSD")
>>> await eurusd.initialize()
>>> pip_value = eurusd.pip # Returns 0.0001 for EURUSD
"""
return self.point * 10
def compute_points(self, *, amount: float, volume: float) -> float:
"""Compute the number of points required for a trade. Given the amount and the volume of the trade.
"""Compute the number of points required for a trade.
Calculates how many points of price movement are needed to achieve
a specified profit or loss amount given a particular trade volume.
Args:
amount (float): Amount to trade
volume (float): Volume to trade
amount (float): The monetary amount (profit/loss) to achieve,
in the account's base currency.
volume (float): The trade volume in lots.
Returns:
float: The number of points of price movement required.
Example:
>>> eurusd = ForexSymbol(name="EURUSD")
>>> await eurusd.initialize()
>>> # How many points for $50 profit with 0.1 lots
>>> points = eurusd.compute_points(amount=50.0, volume=0.1)
"""
points = amount / (volume * self.point * self.trade_contract_size)
return points
async def compute_volume_points(self, *, amount: float, points: float, round_down: bool = False) -> float:
"""Compute the volume required for a trade. Given the amount and the number of points.
def compute_volume_points(self, *, amount: float, points: float, round_down: bool = False) -> float:
"""Compute the volume required for a trade based on points.
Calculates the appropriate trade volume to risk a specified amount
over a given number of points of price movement.
Args:
amount (float): Amount to trade
points (float): Number of points
round_down: round down the computed volume to the nearest step default True
amount (float): The monetary amount to risk, in the account's
base currency.
points (float): The number of points of price movement
(e.g., stop loss distance in points).
round_down (bool): If True, round down the computed volume to
the nearest volume step. If False, round to the nearest
step. Defaults to False.
Returns:
float: The computed volume, rounded to the symbol's volume step.
Example:
>>> eurusd = ForexSymbol(name="EURUSD")
>>> await eurusd.initialize()
>>> # Volume to risk $100 over 500 points
>>> volume = eurusd.compute_volume_points(
... amount=100.0,
... points=500,
... round_down=True
... )
"""
volume = amount / (self.point * points * self.trade_contract_size)
return self.round_off_volume(volume=volume, round_down=round_down)
async def compute_volume_sl(self, *, amount: float, price: float, sl: float, round_down: bool = False) -> float:
"""Compute the volume required for a trade. Given the amount, the price and the stop loss.
def compute_volume_sl(self, *, amount: float, price: float, sl: float, round_down: bool = False) -> float:
"""Compute the volume required for a trade based on stop loss.
Calculates the appropriate trade volume to risk a specified amount
given the entry price and stop loss level. This is useful for
position sizing based on a fixed monetary risk.
Args:
amount (float): Amount to trade
price (float): The price of the trade
sl (float): The stop loss of the trade
round_down (bool): round down the computed volume to the nearest step default to False
amount (float): The monetary amount to risk if stop loss is hit,
in the account's base currency.
price (float): The entry price of the trade.
sl (float): The stop loss price level.
round_down (bool): If True, round down the computed volume to
the nearest volume step. If False, round to the nearest
step. Defaults to False.
Returns:
float: The volume required for the trade
float: The computed volume, rounded to the symbol's volume step.
Example:
>>> eurusd = ForexSymbol(name="EURUSD")
>>> await eurusd.initialize()
>>> # Volume to risk $100 with entry at 1.1000 and SL at 1.0950
>>> volume = eurusd.compute_volume_sl(
... amount=100.0,
... price=1.1000,
... sl=1.0950,
... round_down=True
... )
"""
volume = amount / (abs(price - sl) * self.trade_contract_size)
return self.round_off_volume(volume=volume, round_down=round_down)
+26 -5
View File
@@ -1,5 +1,26 @@
from .position_tracker import PositionTracker
from .open_position import OpenPosition
from .positions_tracker import OpenPositionsTracker
from .positions_tracker import OpenPositionsTracker
from .position_tracking_functions import *
"""Position tracking and management for open trades.
This package provides classes and functions for tracking open trading positions,
including support for hedging, stacking, trailing stops, and custom tracking
strategies.
Modules:
open_position: OpenPosition class for managing individual positions.
position_trackers: PositionTracker and OpenPositionsTracker classes.
position_tracking_functions: Pre-built tracking functions (exit_at_price,
extend_take_profit, exit_at_checkpoint).
Classes:
OpenPosition: Manages an open trading position with full tracking capabilities.
PendingOrder: Represents a pending order associated with an open position.
PositionTracker: Wraps a tracking function for execution on a position.
OpenPositionsTracker: Manages and tracks all open positions.
Functions:
exit_at_price: Exit a trade when profit reaches a target or stop.
extend_take_profit: Dynamically extend take profit as price moves favorably.
exit_at_checkpoint: Trail-based exit strategy using checkpoints.
"""
from .position_trackers import PositionTracker, OpenPositionsTracker
from .open_position import OpenPosition, PendingOrder
from .position_tracking_functions import exit_at_profit, extend_take_profit
+480 -87
View File
@@ -1,32 +1,102 @@
"""Open position management with tracking, hedging, and stacking capabilities.
This module provides classes for managing open trading positions, including
support for hedging, stacking, pending orders, and custom tracking functions.
Classes:
PendingOrder: Represents a pending order associated with an open position.
OpenPosition: Manages an open trading position with full tracking capabilities.
"""
from dataclasses import dataclass, field
from typing import ClassVar, Self
from logging import getLogger
import asyncio
from ...lib import Symbol, Positions, Order
from ...core.models import TradePosition, TradeAction, OrderSendResult
from ...core.constants import OrderType
from ...core.config import Config
from ...utils.change import percentage_increase, percentage_decrease
from .position_tracker import PositionTracker
from ...utils.price_utils import increase_value_by_pct, decrease_value_by_pct
from .position_trackers import PositionTracker
logger = getLogger()
@dataclass
class PendingOrder:
"""Represents a pending order associated with an open position.
Pending orders can be hedges (opposite direction) or stacks (same direction)
that are placed but not yet filled.
Attributes:
order: The OrderSendResult containing order details and status.
is_hedge: True if this is a hedging order (opposite direction).
is_stack: True if this is a stacking order (same direction).
open_pos_params: Additional parameters to apply when creating the
OpenPosition once the pending order is filled.
"""
order: OrderSendResult
is_hedge: bool = False
is_stack: bool = False
open_pos_params: dict = field(default_factory=dict)
@dataclass
class OpenPosition:
"""Manages an open trading position with tracking, hedging, and stacking.
Provides comprehensive position management including stop loss/take profit
modification, position closing, hedging (opening opposite positions), and
stacking (adding to existing positions). Supports custom tracking functions
via the PositionTracker system.
Attributes:
symbol: The Symbol instance for this position's trading instrument.
ticket: Unique ticket number identifying this position.
position: The TradePosition model with current position data.
is_open: Whether the position is currently open.
is_hedged: Whether this position has active hedge positions.
is_stacked: Whether this position has active stack positions.
is_a_stack: Whether this position is itself a stack of another position.
is_a_hedge: Whether this position is itself a hedge of another position.
hedge: Reference to the parent position if this is a hedge.
stack: Reference to the parent position if this is a stack.
pending_orders: Dictionary of pending orders keyed by order ticket.
hedges: Dictionary of hedge positions keyed by ticket.
stacks: Dictionary of stack positions keyed by ticket.
close_pending_orders_on_close: Cancel pending orders when position closes.
remove_from_state_on_close: Remove from state tracking when closed.
auto_track_closed: Automatically add tracker for detecting closed positions.
close_hedges_on_close: Close all hedge positions when this closes.
close_stacks_on_close: Close all stack positions when this closes.
positions: Class-level Positions handler shared by all instances.
state_key: Key for storing tracked positions in state.
archive_key: Key for storing archived (closed) positions in state.
config: Class-level configuration shared by all instances.
"""
symbol: Symbol
ticket: int
position: TradePosition
is_open: bool = True
use_checkpoint: bool = False
checkpoint: float = None
is_hedged: bool = False
is_stacked: bool = False
is_a_stack: bool = False
is_a_hedge: bool = False
hedge: Self | None = None
pending_hedge: OrderSendResult | None = None
stack: Self | None = None
pending_orders: dict[int, PendingOrder] = field(default_factory=dict)
hedges: dict[int, "OpenPosition"] = field(default_factory=dict)
stacks: dict[int, "OpenPosition"] = field(default_factory=dict)
close_pending_orders_on_close: bool = True
remove_from_state_on_close: bool = True
auto_track_closed: bool = True
close_hedges_on_close: bool = False
close_stacks_on_close: bool = False
_trackers: dict[str, PositionTracker] = field(default_factory=dict)
positions: ClassVar[Positions]
state_key: ClassVar[str] = "tracked_positions"
archive_key: ClassVar[str] = "archived_positions"
config: ClassVar[Config]
def __new__(cls, *args, **kwargs):
@@ -38,84 +108,174 @@ class OpenPosition:
def __post_init__(self):
self.config.state.setdefault(self.state_key, {}).setdefault(self.ticket, self)
if self.auto_track_closed:
PositionTracker(self, self.remove_closed, name="remove_closed_tracker", rank=1)
PositionTracker(self, self.check_pending_orders, name="pending_orders_tracker", rank=2)
def add_tracker(self, *, tracker: PositionTracker, number: int = None, name: str = ""):
number = number or len(self._trackers) + 1
tracker.set_position(self, number)
self._trackers[name or tracker.function.__name__] = tracker
def add_tracker(self, *, tracker: PositionTracker, name: str = None, rank: int = None):
"""Add a tracker to this position.
Args:
tracker: The PositionTracker instance to add.
name: Name identifier for the tracker. Defaults to tracker's name.
rank: Execution priority (lower executes first). Defaults to
tracker's rank or next available rank.
"""
tracker.rank = rank if rank is not None else (tracker.rank if tracker.rank is not None else len(self._trackers) + 1)
self._trackers[name] = tracker
@property
def trackers(self):
for tracker in sorted(self._trackers, key=lambda key: self._trackers[key].number):
"""Yield all trackers in execution order (by rank).
Yields:
PositionTracker: Each tracker in order of ascending rank.
"""
for tracker in sorted(self._trackers, key=lambda key: self._trackers[key].rank):
yield self._trackers[tracker]
async def remove_closed(self):
@staticmethod
async def remove_closed(self, /):
"""
A static method to remove all closed positions, it accepts an instance of this class.
Handle position closure cleanup.
Checks if the position is closed and performs cleanup operations
including closing pending orders, hedges, stacks, and removing
from state tracking. This is typically used as an automatic tracker.
"""
try:
await self.update_position()
if not self.is_open:
if self.is_open:
return
if self.close_pending_orders_on_close:
await self.close_pending_orders()
if self.close_hedges_on_close:
await self.close_hedges()
if self.close_stacks_on_close:
await self.close_stacks()
if self.remove_from_state_on_close:
self.remove_from_state()
await self.close_pending_order()
except Exception as exe:
logger.error("%s: Unable to remove closed position from state", exe)
async def close_pending_order(self) -> tuple[bool, OrderSendResult | None]:
logger.error("%s: Error occurred while removing closed position from state", exe)
async def close_pending_orders(self) -> tuple[tuple[bool, PendingOrder], ...]:
"""Close all pending orders associated with this position.
Returns:
Tuple of (success, PendingOrder) tuples for each order, or None
if an error occurred. Exceptions during individual cancellations
are filtered out.
"""
try:
if self.pending_hedge:
res = await Order.cancel_order(order=self.pending_hedge.order, symbol=self.symbol.name)
if res.retcode != 10009:
logger.critical("%s: Unable to cancel pending order", res.comment)
return False, res
self.pending_hedge = None
return True, res
pending_orders: list[PendingOrder] = list(self.pending_orders.values())
ord_res = await asyncio.gather(*[self.close_pending_order(pending_order=pending_order)
for pending_order in pending_orders], return_exceptions=True)
return tuple(res for res in ord_res if isinstance(res, tuple) and res[0] is True)
except Exception as exe:
logger.error("%s: Unable to cancel pending order", exe)
return False, None
logger.error("%s: Error occurred while closing pending orders for %s:%d in %s.close_pending_orders",
exe, self.symbol.name, self.ticket, self.__class__.__name__)
return ()
async def close_pending_order(self, *, pending_order: PendingOrder) -> tuple[bool, PendingOrder]:
"""Cancel a specific pending order.
Args:
pending_order: The PendingOrder to cancel.
Returns:
Tuple of (success, updated PendingOrder) or (False, None) on error.
"""
pending_order_ticket = pending_order.order.order
res = await Order.cancel_order(order=pending_order_ticket, symbol=pending_order.order.request.symbol)
if res.retcode != 10009:
cpo = await Order.get_history_order_by_ticket(ticket=pending_order_ticket) # check if order has been deleted already
res.comment = f"{res.comment}: Order already canceled)"
if cpo is None:
logger.critical("%s: Unable to cancel pending order %d for %s:%d in %s.close_pending_order",
res.comment, pending_order_ticket, self.symbol.name, self.ticket, self.__class__.__name__)
return False, pending_order
pending_order.order = res
self.pending_orders.pop(pending_order_ticket, None)
return True, pending_order
async def update_position(self) -> bool:
pos = await self.positions.get_position_by_ticket(ticket=self.ticket)
if pos is not None:
self.position = pos
self.is_open = True
else:
self.is_open = False
await self.close_pending_order()
return self.is_open
"""Refresh position data from the broker.
Queries the broker for current position data and updates the
is_open status.
Returns:
True if the position is still open, False if closed.
"""
try:
pos = await self.positions.get_position_by_ticket(ticket=self.ticket)
if pos is not None:
self.position = pos
self.is_open = True
else:
self.is_open = False
return self.is_open
except Exception as exe:
logger.critical("%s: Error occurred while updating position for %s:%d in %s.update_position",
exe, self.symbol.name, self.ticket, self.__class__.__name__)
return self.is_open
async def modify_stops(self, *, sl: float = None, tp: float = None,
use_stop_levels=False) -> tuple[bool, OrderSendResult | None]:
use_stop_levels: bool = False) -> tuple[bool, OrderSendResult | None]:
"""Modify the stop loss and/or take profit levels.
Args:
sl: New stop loss price. Defaults to None (no change).
tp: New take profit price. Defaults to None (no change).
use_stop_levels: If True, validate and adjust stops against
broker's minimum stop level requirements. Defaults to False.
Returns:
Tuple of (success, OrderSendResult). On failure, result may
contain error details.
"""
try:
tick = await self.symbol.info_tick()
if sl is not None and use_stop_levels is True:
min_stops_value = (self.symbol.trade_stops_level + self.symbol.spread) * self.symbol.point
if self.position.type == OrderType.BUY:
if OrderType(self.position.type).is_long:
sl = min(sl, tick.ask - min_stops_value)
elif self.position.type == OrderType.SELL:
elif OrderType(self.position.type).is_short:
sl = max(sl, tick.bid + min_stops_value)
else:
raise TypeError("Invalid OrderType %s: In %s.modify_stops", self.position.type, self.__class__.__name__)
logger.critical("Invalid OrderType cannot modify stops for %d:%s In %s.modify_stops",
self.ticket, self.symbol.name,self.__class__.__name__)
return False, None
elif sl is not None and use_stop_levels is False:
sl = sl or self.position.sl
# modify take_profit
if tp is not None and use_stop_levels is True:
min_stops_value = (self.symbol.trade_stops_level + self.symbol.spread) * self.symbol.point
if self.position.type == OrderType.BUY:
if OrderType(self.position.type).is_long:
tp = max(tp, tick.ask + min_stops_value)
elif self.position.type == OrderType.SELL:
elif OrderType(self.position.type).is_short():
tp = min(tp, tick.bid - min_stops_value)
else:
raise TypeError("Invalid OrderType %s: In %s.modify_stops", self.position.type, self.__class__.__name__)
logger.critical("Invalid OrderType cannot modify stops for %d:%s In %s.modify_stops",
self.ticket, self.symbol.name,self.__class__.__name__)
return False, None
elif tp is not None and use_stop_levels is False:
tp = tp or self.position.tp
# send order
order = Order(position=self.ticket, sl=sl, tp=tp, action=TradeAction.SLTP)
res = await order.send()
if res and res.retcode == 10009:
if res.retcode == 10009:
await self.update_position()
return True, res
else:
comment = res.comment if res else ""
logger.critical("Unable to modify stops for %d:%s In %s.modify_stops: %s", self.ticket,
self.symbol.name,self.__class__.__name__, comment)
return False, res
except Exception as exe:
logger.error("%s: Error occurred in %s.modify_stops for %s:%d",
@@ -123,94 +283,327 @@ class OpenPosition:
return False, None
def remove_from_state(self):
"""Remove this position from state tracking and archive it.
Moves the position from the active tracked positions to the
archived positions in the state.
"""
try:
self.config.state.get(self.state_key, {}).pop(self.ticket, None)
pos = self.config.state.get(self.state_key, {}).pop(self.ticket, None)
self.config.state.setdefault(self.archive_key, {}).setdefault(self.ticket, pos) if pos else ...
except KeyError as err:
logger.error("%s: Unable to remove closed position from state in", err)
logger.error("%s: Unable to remove closed position from state in %s", err, self.__class__.__name__)
async def close_position(self, remove_from_state: bool = True) -> tuple[bool, OrderSendResult | None]:
async def close_position(self) -> tuple[bool, OrderSendResult | None]:
"""Close this position.
Attempts to close the position and performs cleanup based on
configuration (closing pending orders, hedges, stacks, etc.).
Returns:
Tuple of (success, OrderSendResult). On failure, result
contains error details.
"""
try:
res = await self.positions.close_position(position=self.position)
if res.retcode == 10009:
self.is_open = False
if remove_from_state:
self.remove_from_state()
return True, res
else:
return False, res
if not res[0]:
logger.critical("Unable to close position for %d:%s In %s.close_position: %s", self.ticket,
self.symbol.name, self.__class__.__name__, res[1].comment)
return res
self.is_open = False
closures = []
if self.close_pending_orders_on_close:
closures.append(self.close_pending_orders())
if self.close_hedges_on_close:
closures.append(self.close_hedges())
if self.close_stacks_on_close:
closures.append(self.close_stacks())
await asyncio.gather(*closures, return_exceptions=True)
if self.remove_from_state_on_close:
self.remove_from_state()
return res
except Exception as exe:
logger.error("%s: Error occurred in %s.close_position for %s:%d", exe, self.__class__.__name__,
self.symbol.name, self.ticket)
return False, None
async def close_hedges(self):
"""Close all hedge positions associated with this position.
Attempts to close all positions in the hedges dictionary
concurrently. Exceptions are logged but not re-raised.
"""
try:
hedges = tuple(self.hedges.values())
await asyncio.gather(*[hedge.close_position() for hedge in hedges], return_exceptions=True)
except Exception as exe:
logger.error("%s: Error occurred in %s.close_hedges for %s:%d", exe, self.__class__.__name__,
self.symbol.name, self.ticket)
async def close_stacks(self):
"""Close all stack positions associated with this position.
Attempts to close all positions in the stacks dictionary
concurrently. Exceptions are logged but not re-raised.
"""
try:
stacks = tuple(self.stacks.values())
await asyncio.gather(*[stack.close_position() for stack in stacks], return_exceptions=True)
except Exception as exe:
logger.error("%s: Error occurred in %s.close_stacks for %s:%d", exe, self.__class__.__name__,
self.symbol.name, self.ticket)
async def check_pending_order(self, *, pending_order: PendingOrder):
"""Check if a pending order has been filled and create position.
If the pending order is now a position, creates a new OpenPosition
and adds it to the appropriate collection (hedges or stacks).
Args:
pending_order: The PendingOrder to check.
"""
try:
pos = await self.positions.get_position_by_ticket(ticket=pending_order.order.order)
if pos is None:
return
if pending_order.is_hedge:
self.hedges[pos.ticket] = OpenPosition(symbol=self.symbol, position=pos, ticket=pos.ticket,
is_a_hedge=True, hedge=self, **pending_order.open_pos_params)
self.pending_orders.pop(pending_order.order.order)
elif pending_order.is_stack:
self.stacks[pos.ticket] = OpenPosition(symbol=self.symbol, position=pos, ticket=pos.ticket,
is_a_stack=True, stack=self, **pending_order.open_pos_params)
self.pending_orders.pop(pending_order.order.order)
else:
logger.warning("Unexpected pending order (neither hedge nor stack) for %s:%d",
self.symbol.name, pending_order.order.order)
except Exception as exe:
logger.error("%s: Error occurred in %s.check_pending_hedge_order for %s:%d", exe, self.__class__.__name__,
self.symbol.name, self.ticket)
@staticmethod
async def check_pending_order(self):
await self.update_position()
if self.is_open and self.is_hedged and self.pending_hedge is not None:
pos = await self.positions.get_position_by_ticket(ticket=self.pending_hedge.order)
if pos is not None:
self.pending_hedge = None
self.hedge = OpenPosition(symbol=self.symbol, position=pos, ticket=pos.ticket,
is_a_hedge=True, hedge=self)
async def check_pending_orders(self, /):
"""Check if any pending orders have been filled and create positions.
Iterates through all pending orders and checks if they have been filled.
If a pending order is now a position, creates a new OpenPosition
and adds it to the appropriate collection (hedges or stacks).
"""
try:
orders = list(self.pending_orders.values())
await asyncio.gather(*[self.check_pending_order(pending_order=pending_order) for pending_order in orders], return_exceptions=True)
except Exception as exe:
logger.error("%s: Error occurred in %s.check_pending_orders for %s:%d", exe, self.__class__.__name__,
self.symbol.name, self.ticket)
async def track(self):
"""Execute all registered trackers on this position.
Iterates through all trackers in rank order and executes them.
Exceptions are logged but do not stop subsequent trackers.
"""
try:
for tracker in self.trackers:
await tracker()
except Exception as exe:
logger.error("%s: Error occurred in track method of Open Position for %d:%s",
exe, self.symbol.name, self.ticket)
logger.error("%s: Error occurred in %s.track for %s:%d", exe, self.__class__.__name__, self.symbol.name, self.ticket)
async def profit_to_price(self, *, profit):
async def profit_to_price(self, *, profit: float) -> float:
"""Calculate the price level that would yield a specific profit.
Uses the broker's profit calculation to determine what price
the position would need to reach to achieve the target profit.
Args:
profit: Target profit in account currency.
Returns:
The price level at which the position would have the target profit.
"""
action = self.position.type
volume = self.position.volume
price_open = self.position.price_open
price_close = percentage_increase(price_open, 50) if action == 0 else percentage_decrease(price_open, 50)
price_close = increase_value_by_pct(price_open, 50) if self.position.type.is_long else decrease_value_by_pct(price_open, 50)
half_profit = await Order.mt5.order_calc_profit(symbol=self.symbol.name, action=action, volume=volume,
price_open=price_open, price_close=price_close)
rate = profit / half_profit * 50
rate = percentage_increase(price_open, rate) if action == 0 else percentage_decrease(price_open, rate)
rate = increase_value_by_pct(price_open, rate) if self.position.type.is_long else decrease_value_by_pct(price_open, rate)
return rate
async def hedge_order(self, price, **order_params) -> tuple[bool, OrderSendResult | None]:
async def hedge_order(self, *, price: float, order_params: dict = None,
open_pos_params: dict = None) -> tuple[bool, OrderSendResult | None]:
"""Place a pending hedge order at a specified price.
Creates a pending order in the opposite direction that will become
a hedge position when filled.
Args:
price: The price at which to place the pending order.
order_params: Optional dictionary of order parameters to override
defaults (type, volume, action, symbol, comment).
open_pos_params: Optional parameters to apply to the OpenPosition
created when the order is filled.
Returns:
Tuple of (success, OrderSendResult).
"""
try:
order_type = OrderType.SELL_STOP if self.position.type == OrderType.BUY else OrderType.BUY_STOP
volume = order_params.get("volume", self.position.volume)
order = Order(action=TradeAction.PENDING, symbol=self.symbol.name, type=order_type, price=price, volume=volume)
order_params = order_params or {}
open_pos_params = open_pos_params or {}
order_type = order_params.pop("type", OrderType.SELL_STOP if self.position.type.is_long else OrderType.BUY_STOP)
volume = order_params.pop("volume", self.position.volume)
action = order_params.pop("action", TradeAction.PENDING)
symbol = order_params.pop("symbol", self.symbol.name)
comment = order_params.pop("comment", f"Hedge_{self.symbol.name}:{self.ticket}")
order = Order(action=action, symbol=symbol, type=order_type, price=price, volume=volume, comment=comment, **order_params)
res = await order.send()
if res.retcode != 10009:
logger.critical("Unable to send pending order for %d:%s In %s.hedge_order: %s, %f", self.ticket,
self.symbol.name, self.__class__.__name__, res.comment, price)
return False, res
self.pending_hedge = res
pending_order = PendingOrder(order=res, is_hedge=True, open_pos_params=open_pos_params)
self.pending_orders[res.order] = pending_order
self.is_hedged = True
self.add_tracker(tracker=PositionTracker(self.check_pending_order), number=0, name="check_pending_order")
return True, res
except Exception as exe:
logger.error("%s: Error occurred in hedge_order method of Open Position for %d:%s", exe, self.symbol.name, self.ticket)
logger.error("%s: Error occurred in %s.hedge_order for %s:%d", exe, self.__class__.__name__, self.symbol.name, self.ticket)
return False, None
async def stack_order(self, *, price: float, order_params: dict = None,
open_pos_params: dict = None) -> tuple[bool, OrderSendResult | None]:
"""Place a pending stack order at a specified price.
Creates a pending order in the same direction that will become
a stack position when filled, adding to the current position.
Args:
price: The price at which to place the pending order.
order_params: Optional dictionary of order parameters to override
defaults (type, volume, action, symbol, comment).
open_pos_params: Optional parameters to apply to the OpenPosition
created when the order is filled.
Returns:
Tuple of (success, OrderSendResult).
"""
try:
order_params = order_params or {}
open_pos_params = open_pos_params or {}
order_type = order_params.pop("type", OrderType.BUY_STOP if self.position.type.is_long else OrderType.SELL_STOP)
volume = order_params.pop("volume", self.position.volume)
action = order_params.pop("action", TradeAction.PENDING)
symbol = order_params.pop("symbol", self.symbol.name)
comment = order_params.pop("comment", f"Stack_{self.symbol.name}:{self.ticket}")
order = Order(action=action, symbol=symbol, type=order_type, price=price, volume=volume, comment=comment, **order_params)
res = await order.send()
if res.retcode != 10009:
logger.critical("Unable to send pending order for %d:%s In %s.stack_order: %s", self.ticket,
self.symbol.name, self.__class__.__name__, res.comment)
return False, res
pending_order = PendingOrder(order=res, is_stack=True, open_pos_params=open_pos_params)
self.pending_orders[res.order] = pending_order
self.is_stacked = True
return True, res
except Exception as exe:
logger.error("%s: Error occurred in %s.hedge_order for %s:%d", exe, self.__class__.__name__, self.symbol.name, self.ticket)
return False, None
def update(self, **kwargs):
"""Update position attributes.
Args:
**kwargs: Attribute name-value pairs to update.
"""
[setattr(self, attr, value) for attr, value in kwargs.items()]
async def hedge_position(self, *, hedge_params: dict = None) -> tuple[bool, Self]:
async def hedge_position(self, *, order_params: dict = None,
open_pos_params: dict = None) -> tuple[bool, OrderSendResult | None]:
"""Immediately open a hedge position.
Opens a position in the opposite direction at current market price
to hedge the current position.
Args:
order_params: Optional dictionary of order parameters to override
defaults (volume, type, symbol, price, comment).
open_pos_params: Optional parameters to apply to the created
OpenPosition.
Returns:
Tuple of (success, OpenPosition) for the created hedge.
"""
try:
# hedge_params for customizing the hedge
hedge_params = hedge_params or {}
volume = hedge_params.get("volume", self.position.volume)
order_type = hedge_params.get("type") or OrderType(self.position.type).opposite
if (price := hedge_params.get("price", None)) is None:
tick = await self.symbol.info_tick()
price = (tick.ask if order_type == OrderType.BUY else tick.bid)
comment = hedge_params.get("comment", f"Hedge_{self.ticket}")
hedge_order = Order(type=order_type, symbol=self.symbol.name, volume=volume, price=price, comment=comment)
order_params = order_params or {}
open_pos_params = open_pos_params or {}
volume = order_params.pop("volume", self.position.volume)
order_type = order_params.pop("type", OrderType(self.position.type).opposite)
symbol = order_params.pop("symbol", self.symbol.name)
if (price := order_params.pop("price", None)) is None:
tick = await self.symbol.info_tick(name=symbol)
price = (tick.ask if order_type.is_long else tick.bid)
comment = order_params.pop("comment", f"Hedge:{self.symbol.name}:{self.ticket}")
hedge_order = Order(type=order_type, symbol=symbol, volume=volume, price=price, comment=comment, **order_params)
res = await hedge_order.send()
if res.retcode != 10009:
return False, None
self.is_hedged = True
logger.critical("Unable to send hedge order for %d:%s In %s.hedge_position: %s", self.ticket,
self.symbol.name, self.__class__.__name__, res.comment)
return False, res
hedge_pos = await self.positions.get_position_by_ticket(ticket=res.order)
self.hedge = OpenPosition(symbol=self.symbol, ticket=res.order, position=hedge_pos,
hedge=self, is_a_hedge=True)
return True, self.hedge
if hedge_pos is None:
logger.critical("Position not found for %d:%s In %s.hedge_position",
self.ticket, self.symbol.name, self.__class__.__name__)
return False, res
hedge = OpenPosition(symbol=symbol, ticket=res.order, position=hedge_pos, hedge=self, is_a_hedge=True, **open_pos_params)
self.hedges[res.order] = hedge
self.is_hedged = True
return True, res
except Exception as exe:
logger.error("%s: Error occurred in %s.hedge_position for %s:%d", exe, self.__class__.__name__,
self.symbol.name, self.ticket)
return False, None
async def stack_position(self, *, order_params: dict = None,
open_pos_params: dict = None) -> tuple[bool, OrderSendResult | None]:
"""Immediately open a stack position.
Opens a position in the same direction at current market price
to add to the current position.
Args:
order_params: Optional dictionary of order parameters to override
defaults (volume, type, symbol, price, comment).
open_pos_params: Optional parameters to apply to the created
OpenPosition.
Returns:
Tuple of (success, OpenPosition) for the created stack.
"""
try:
order_params = order_params or {}
open_pos_params = open_pos_params or {}
volume = order_params.pop("volume", self.position.volume)
order_type = order_params.pop("type", self.position.type)
symbol = order_params.pop("symbol", self.symbol.name)
if (price := order_params.pop("price", None)) is None:
tick = await self.symbol.info_tick(name=symbol)
price = (tick.ask if order_type == OrderType.BUY else tick.bid)
comment = order_params.pop("comment", f"Stack:{self.symbol.name}:{self.ticket}")
stack_order = Order(type=order_type, symbol=symbol, volume=volume, price=price, comment=comment, **order_params)
res = await stack_order.send()
if res.retcode != 10009:
logger.critical("Unable to send stack order for %d:%s In %s.stack_position: %s", self.ticket,
self.symbol.name, self.__class__.__name__, res.comment)
return False, res
stack_pos = await self.positions.get_position_by_ticket(ticket=res.order)
if stack_pos is None:
logger.critical("Position not found for %d:%s In %s.stack_position",
self.ticket, self.symbol.name, self.__class__.__name__)
return False, res
stack = OpenPosition(symbol=symbol, ticket=res.order, position=stack_pos, stack=self, is_a_stack=True, **open_pos_params)
self.is_stacked = True
self.stacks[res.order] = stack
return True, res
except Exception as exe:
logger.error("%s: Error occurred in %s.stack_position for %s:%d", exe, self.__class__.__name__,
self.symbol.name, self.ticket)
return False, None
@@ -1,33 +0,0 @@
from collections.abc import Callable
from typing import Any, TypeVar
from logging import getLogger
logger = getLogger(__name__)
OpenPosition = TypeVar('OpenPosition')
class PositionTracker:
params: dict[str, Any]
function: Callable
number: int
open_position: OpenPosition
def __init__(self, function: Callable, /, **kwargs) -> None:
self.function = function
self.kwargs = kwargs
self.number = 0
async def __call__(self, **kwargs):
try:
kwargs = self.kwargs if not kwargs else (self.kwargs | kwargs)
await self.function(self.open_position, **kwargs)
except Exception as exe:
logger.error("%s: Error occurred in %s for %s:%d", exe, self.function.__name__,
self.open_position.symbol.name, self.open_position.ticket)
def set_position(self, open_position: "OpenPosition", number: int = None):
self.open_position = open_position
if number is not None:
self.number = number
self.open_position = open_position
@@ -0,0 +1,183 @@
"""Position trackers for monitoring and managing open trading positions.
This module provides classes for tracking open positions and executing
tracking functions at regular intervals.
Classes:
PositionTracker: Wraps a tracking function to be executed on an open position.
OpenPositionsTracker: Manages and tracks all open positions in the trading system.
"""
import asyncio
from collections.abc import Callable
from typing import Any, TypeVar, ClassVar
from logging import getLogger
import logging
from ...core import Config, State, sleep
from ...lib import Positions
logger = getLogger(__name__)
OpenPosition = TypeVar('OpenPosition')
class PositionTracker:
"""Wraps a tracking function to be executed on an open position.
A PositionTracker binds a callable tracking function to an OpenPosition
instance, allowing it to be executed with specified parameters during
position tracking cycles.
Attributes:
params: Dictionary of parameters to pass to the tracking function.
function: The callable tracking function to execute.
name: Name identifier for this tracker.
rank: Execution priority (lower numbers execute first).
open_position: The OpenPosition instance this tracker is bound to.
"""
params: dict[str, Any]
function: Callable
name: str
rank: int | None
open_position: OpenPosition
def __init__(self, open_position: OpenPosition, function: Callable, /, rank: int = None,
name: str = None, function_params: dict[str, Any] = None) -> None:
"""Initialize a PositionTracker.
Args:
open_position: The OpenPosition instance to track.
function: The async callable to execute during tracking. Should
accept the OpenPosition as its first argument.
rank: Execution priority. Lower numbers execute first. Defaults to None.
name: Name identifier for this tracker. Defaults to the function name.
function_params: Dictionary of keyword arguments to pass to the
function on each call. Defaults to None.
"""
self.function = function
self.params = function_params or {}
self.rank = rank
self.open_position = open_position
self.name = name or function.__name__
self.set_tracker()
async def __call__(self, **kwargs):
"""Execute the tracking function asynchronously.
Calls the bound tracking function with the open position and
any configured or provided keyword arguments.
Args:
**kwargs: Additional keyword arguments to pass to the function.
These are merged with the configured params, with kwargs
taking precedence.
"""
try:
kwargs = self.params | kwargs
await self.function(self.open_position, **kwargs)
except Exception as exe:
logger.error("%s: Error occurred in %s for %s:%d", exe, self.function.__name__,
self.open_position.symbol.name, self.open_position.ticket)
def set_tracker(self, name: str = None, rank: int = None):
"""Register this tracker with its open position.
Adds this tracker to the open position's tracker collection,
optionally updating the name and rank.
Args:
name: Override name for the tracker. Defaults to the current name.
rank: Override rank for the tracker. Defaults to the current rank.
"""
self.open_position.add_tracker(tracker=self, name=name or self.name, rank=rank or self.rank)
class OpenPositionsTracker:
"""Manages and tracks all open positions in the trading system.
Runs a continuous loop that executes all trackers on all tracked
positions at a specified interval. Supports automatic cleanup of
closed positions and optional state persistence.
Attributes:
config: Shared configuration instance.
positions: Positions handler for querying position data.
state: State manager for persisting tracked positions.
interval: Time between tracking cycles in seconds.
state_key: Key used to store tracked positions in state.
autocommit: Whether to automatically commit state changes.
auto_remove_closed: Whether to automatically remove closed positions.
"""
config: ClassVar[Config]
positions: ClassVar[Positions]
state: ClassVar[State]
def __new__(cls, *args, **kwargs):
if not hasattr(cls, "config"):
cls.config = Config()
if not hasattr(cls, "positions"):
cls.positions = Positions()
if not hasattr(cls, "state"):
cls.state = State()
return super().__new__(cls)
def __init__(self, interval: int = 10, state_key: str = "tracked_positions",
autocommit: bool = False, auto_remove_closed: bool = False):
"""Initialize the OpenPositionsTracker.
Args:
interval: Time between tracking cycles in seconds. Defaults to 10.
state_key: Key used to store tracked positions in the state
dictionary. Defaults to "tracked_positions".
autocommit: If True, automatically commit state changes after
each tracking cycle. Defaults to False.
auto_remove_closed: If True, automatically remove closed positions
from tracking after each cycle. Defaults to False.
"""
self.interval = interval
self.state_key = state_key
self.autocommit = autocommit
self.auto_remove_closed = auto_remove_closed
async def track(self):
"""Main tracking loop that monitors all open positions.
Continuously executes all trackers on all tracked positions at
the configured interval. Optionally removes closed positions and
commits state changes.
Note:
This method runs until config.shutdown is True. The connection
is automatically closed when the loop exits.
"""
conn = self.config.state.conn
while not self.config.shutdown:
try:
await sleep(self.interval)
tracked_positions = self.state.get("tracked_positions", {})
await asyncio.gather(*(pos.track() for pos in tracked_positions.values()), return_exceptions=True)
if self.auto_remove_closed:
await self.remove_closed_positions(tracked_positions)
if self.autocommit:
await self.state.acommit(conn=conn, close=False)
except Exception as exe:
logger.error("%s: Error occurred in %s.track", exe, self.__class__.__name__)
conn.close()
async def remove_closed_positions(self, tracked_positions: dict):
"""Remove closed positions from the tracked positions.
Queries the broker for all current positions and removes any
tracked positions that are no longer open.
Args:
tracked_positions: Dictionary mapping ticket numbers to
OpenPosition instances.
"""
all_pos = await self.positions.get_positions()
all_pos = {pos.ticket for pos in all_pos}
self.state[self.state_key] = {pos.ticket: pos for pos in tracked_positions.values() if pos.ticket in all_pos}
@@ -1,31 +1,77 @@
"""Position tracking functions for managing open positions.
This module provides pre-built tracking functions that can be used with
the PositionTracker class to implement common trading strategies like
trailing stops, take-profit extension, and checkpoint-based exits.
Functions:
exit_at_price: Exit a trade when profit reaches a specified target or stop.
extend_take_profit: Dynamically extend take profit as price moves favorably.
exit_at_checkpoint: Trail-based exit strategy using checkpoints.
"""
from logging import getLogger
from .open_position import OpenPosition
from ...utils.change import extend_interval_by_percentage, get_percentage_position, percentage_position
from ...utils.price_utils import get_price_in_range_pct, extend_range_by_pct
logger = getLogger(__name__)
async def exit_at_price(pos: OpenPosition, /, tp: float = None, sl: float = None):
"""Exit a trade at a particular price"""
async def exit_at_profit(pos: OpenPosition, /, tp: float = None, sl: float = None):
"""Exit a trade when profit reaches a specified target or stop loss.
Closes the position if the current profit meets or exceeds the take profit
target, or falls to or below the stop loss threshold.
Args:
pos: The open position to monitor and potentially close.
tp: Take profit target in account currency. Position closes if
profit >= tp. Defaults to None (no take profit check).
sl: Stop loss threshold in account currency. Position closes if
profit <= sl. Defaults to None (no stop loss check).
Note:
At least one of tp or sl should be provided for this function
to have any effect.
"""
if not await pos.update_position():
return
if (tp is not None and pos.position.profit >= tp) or (sl is not None and pos.position.profit <= sl):
ok, res = await pos.close_position()
if not ok:
logger.warning("Unable to close %s:%d due to %s in exit_at_price", pos.symbol, pos.ticket, res.comment)
comment = res.comment if res is not None else ""
logger.warning("Unable to close %s:%d due to %s in exit_at_price", pos.symbol.name, pos.ticket, comment)
else:
logger.info("Closed %s:%d in exit_at_price with profit %s", pos.symbol.name, pos.ticket, pos.position.profit)
async def extend_take_profit(pos: OpenPosition, /, increase: float = 20, start: float = 80,
use_stop_levels: bool = True):
"""Dynamically extend take profit as price moves favorably.
When the current price reaches a specified percentage of the distance
to take profit, extends the take profit by the given percentage.
Args:
pos: The open position to manage.
increase: Percentage to extend the take profit distance by.
Defaults to 20.
start: Percentage of the distance to TP at which to trigger
extension. Defaults to 80 (extend when 80% to TP).
use_stop_levels: Whether to validate against broker's minimum
stop levels. Defaults to True.
Note:
Only applies when position is in profit. Does nothing if position
is closed or in loss.
"""
is_open = await pos.update_position()
if is_open is False or pos.position.profit < 0:
return
position = pos.position
if percentage_position(position.price_open, position.tp, position.price_current) >= start:
new_tp = extend_interval_by_percentage(position.price_open, position.tp, increase)
if get_price_in_range_pct(position.price_open, position.tp, position.price_current) >= start:
new_tp = extend_range_by_pct(position.price_open, position.tp, increase)
ok, res = await pos.modify_stops(tp=new_tp, use_stop_levels=use_stop_levels)
if ok:
logger.info("%s:%d take_profit extended by extend_take_profit", position.symbol, position.ticket)
@@ -33,54 +79,3 @@ async def extend_take_profit(pos: OpenPosition, /, increase: float = 20, start:
logger.warning("Unable to extend take profit of %s:%d due to %s in extend_take_profit",
position.symbol, position.ticket, res.comment)
async def extend_stop_loss(pos: OpenPosition, /, increase: float = 20, start: float = 80,
use_stop_levels: bool = True):
is_open = await pos.update_position()
if is_open is False or pos.position.profit > 0:
return
position = pos.position
if percentage_position(position.price_open, position.sl, position.price_current) >= start:
new_sl = extend_interval_by_percentage(position.price_open, position.sl, increase)
ok, res = await pos.modify_stops(sl=new_sl, use_stop_levels=use_stop_levels)
if ok:
logger.info("%s:%d extend_stop_loss extended by extend_stop_loss",
position.symbol, position.ticket)
else:
logger.warning("Unable to extend stop loss of %s:%d due to %s in extend_stop_loss",
position.symbol, position.ticket, res.comment)
async def exit_at_checkpoint(pos: OpenPosition, /, start: float = 80, trail: float = 15):
is_open = await pos.update_position()
if is_open is False:
return
position = pos.position
if percentage_position(position.price_open, position.tp, position.price_current) >= start:
new_checkpoint = get_percentage_position(position.price_open, position.price_current, 100-trail)
change_checkpoint = False
if position.type.long and new_checkpoint > (pos.checkpoint or position.price_open):
change_checkpoint = True
elif position.type.short and new_checkpoint < (pos.checkpoint or position.price_open):
change_checkpoint = True
if change_checkpoint:
pos.checkpoint = new_checkpoint
pos.use_checkpoint = True
await pos.modify_stops(sl=new_checkpoint, use_stop_levels=True)
logger.info("New checkpoint created for %s:%d at %f:%f",
position.symbol, position.ticket, new_checkpoint, position.profit)
close = False
if position.type.long and position.price_current <= pos.checkpoint and pos.use_checkpoint:
close = True
elif position.type.short and position.price_current >= pos.checkpoint and pos.use_checkpoint:
close = True
if close:
ok, res = await pos.close_position()
if not ok:
logger.warning("Unable to close %s:%d due to %s in checkpoint", position.symbol,
position.ticket, res.comment)
else:
logger.info("Closed %s:%d in checkpoint at %f:%f",
position.symbol, position.ticket, position.price_current, position.profit)
@@ -1,51 +0,0 @@
import asyncio
from logging import getLogger
from typing import ClassVar
from ...core import Config, State, sleep
from ...lib import Positions
logger = getLogger(__name__)
class OpenPositionsTracker:
config: ClassVar[Config]
positions: ClassVar[Positions]
state: ClassVar[State]
def __new__(cls, *args, **kwargs):
if not hasattr(cls, "config"):
cls.config = Config()
if not hasattr(cls, "positions"):
cls.positions = Positions()
if not hasattr(cls, "state"):
cls.state = State()
return super().__new__(cls)
def __init__(self, interval: int = 10, state_key: str = "tracked_positions", autocommit=False):
self.interval = interval
self.state_key = state_key
self.autocommit = autocommit
async def track(self):
conn = self.config.state.conn
while not self.config.shutdown:
try:
await sleep(self.interval)
tracked_positions = self.state.get("tracked_positions", {})
await asyncio.gather(*(pos.track() for pos in tracked_positions.values()))
await self.remove_closed_positions(tracked_positions)
if self.autocommit:
await self.state.acommit(conn=conn, close=False)
except Exception as exe:
logger.error("%s: Error occurred in main position tracker", exe)
conn.close()
async def remove_closed_positions(self, tracked_positions):
all_pos = await self.positions.get_positions()
all_pos = {pos.ticket for pos in all_pos}
self.state[self.state_key] = {pos.ticket: pos for pos in tracked_positions.values() if pos.ticket in all_pos}
+1
View File
@@ -12,3 +12,4 @@ from .utils import *
from .db import DB
from .state import State
from .store import Store
from .sync import *
+2 -2
View File
@@ -291,7 +291,7 @@ types = (
)
class BaseMeta(type):
class MetaBase(type):
def __new__(mcs, cls_name, bases, cls_dict):
defaults: dict = getattr(MetaTrader5, "__dict__", {})
callables = {f"_{key}": value for key in core_mt5_functions if (value := defaults.get(key, None)) is not None}
@@ -303,7 +303,7 @@ class BaseMeta(type):
return super().__new__(mcs, cls_name, bases, cls_dict)
class MetaCore(metaclass=BaseMeta):
class MetaCore(metaclass=MetaBase):
TIMEFRAME_M1: int
TIMEFRAME_M2: int
TIMEFRAME_M3: int
+98 -25
View File
@@ -1,37 +1,100 @@
from functools import cache
"""Base classes for data structure handling in the aiomql package.
This module provides the foundational base classes that other data structure
classes inherit from. These classes provide common functionality for attribute
management, dictionary conversion, and integration with the MetaTrader terminal.
Classes:
Base: A base class providing attribute handling and dictionary conversion.
_Base: Extended base class with MetaTrader and Config integration.
Example:
Creating a custom data class::
from aiomql.core.base import Base
class MyData(Base):
name: str
value: float
data = MyData(name='example', value=42.0)
print(data.dict) # {'name': 'example', 'value': 42.0}
"""
import enum
from typing import Literal
from logging import getLogger
from functools import cache
from .config import Config
from .meta_trader import MetaTrader
from .sync.meta_trader import MetaTrader as MetaTraderSync
from .meta_backtester import MetaBackTester
logger = getLogger(__name__)
class BaseMeta(type):
def __getattr__(cls, item):
if item in ("config", "mt5"):
cls._setup()
return super().__getattribute__(item)
def __call__(cls, *args, **kwargs):
inst = super().__call__(*args, **kwargs)
inst.__class__._setup()
return inst
def _setup(cls):
if 'config' not in cls.__dict__:
cls.config = Config()
if 'mt5' not in cls.__dict__:
cls.mt5 = (MetaTrader() if cls.__dict__.get("mode", "") != "sync" else MetaTraderSync()) if cls.config.mode != "backtest" else MetaBackTester()
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.
"""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, including automatic attribute setting, dictionary conversion, and
attribute filtering capabilities.
Attributes:
exclude (set[str]): A set of attributes to be excluded when retrieving attributes
using the get_dict and dict method.
include (set [str]): A set of attributes to be included when retrieving attributes
using the get_dict and dict method.
exclude (set[str]): Attributes to exclude when converting to dict.
Defaults to internal attributes like 'mt5', 'config', etc.
include (set[str]): Attributes to always include when converting to dict.
Takes precedence over exclude.
Example:
>>> class MyClass(Base):
... name: str
... value: int
>>> obj = MyClass(name='test', value=100)
>>> obj.dict
{'name': 'test', 'value': 100}
"""
exclude: set[str] = {"mt5", "config", "exclude", "include", "annotations", "class_vars", "dict", "_instance"}
exclude: set[str] = {"mt5", "config", "exclude", "include", "annotations", "class_vars", "dict", "_instance", "mode"}
include: set[str] = {}
def __init__(self, **kwargs):
"""
Initialize a new instance of the Base class
"""Initializes a new instance of the Base class.
Args:
**kwargs: Set instance attributes with keyword arguments. Only if they are annotated on the class body.
**kwargs: Keyword arguments to set as instance attributes.
Only attributes that are annotated on the class body
will be set.
"""
self.set_attributes(**kwargs)
def __repr__(self):
"""Returns a string representation of the instance.
Shows up to 3 attributes at the start and 1 at the end if there
are more than 3 attributes. Only includes simple types (int, float,
str) and enums.
Returns:
str: A formatted string representation of the instance.
"""
kv = [
(k, v)
for k, v in self.__dict__.items()
@@ -127,25 +190,35 @@ class Base:
}
class _Base(Base, metaclass=BaseMeta):
"""Extended base class with MetaTrader and Config integration.
class _Base(Base):
"""Base class that provides access to the MetaTrader and Config classes as well as the MetaBackTester class for
backtesting mode.
Provides automatic access to the MetaTrader terminal and configuration
settings. Automatically switches between MetaTrader and MetaBackTester
based on the configured mode.
Attributes:
mt5 (MetaTrader | MetaBackTester): The MetaTrader interface. Uses
MetaBackTester when in backtest mode.
config (Config): The global configuration instance.
Note:
The mt5 attribute is excluded from serialization via __getstate__
to prevent issues when pickling instances.
"""
mt5: MetaTrader | MetaBackTester
mt5: MetaTrader | MetaBackTester | MetaTraderSync
config: Config
def __new__(cls, *args, **kwargs):
if not hasattr(cls, 'config'):
cls.config = Config()
if not hasattr(cls, 'mt5'):
cls.mt5 = MetaTrader() if cls.config.mode != "backtest" else MetaBackTester()
return super().__new__(cls)
mode: Literal["async", "sync"] = "async"
def __getstate__(self):
"""Prepares instance state for pickling.
Removes the mt5 attribute to avoid serialization issues with
the MetaTrader connection.
Returns:
dict: The instance state without the mt5 attribute.
"""
state = self.__dict__.copy()
state.pop("mt5", None)
return state
def __init__(self, **kwargs):
super().__init__(**kwargs)
+203 -56
View File
@@ -1,9 +1,39 @@
"""Configuration module for the aiomql trading library.
This module provides the Config class for managing all configuration settings
for the aiomql package. Settings can be loaded from a JSON configuration file
or set programmatically.
By default, the config class looks for a file named aiomql.json. This can be
changed by setting the filename attribute to the desired file name. The root
directory of the project can be set by passing the root argument to the
load_config method or during object instantiation. If not provided it is
assumed to be the current working directory. All directories and files are
assumed to be relative to the root directory.
Example:
Basic usage::
from aiomql import Config
# Load config from default aiomql.json file
config = Config()
# Or specify a custom config file
config = Config(config_file="/path/to/config.json")
# Access configuration values
print(config.login)
print(config.server)
"""
import os
import json
from pathlib import Path
from typing import Literal, TypeVar, Self
from logging import getLogger
from threading import Lock
from functools import cached_property
from .task_queue import TaskQueue
from .state import State
@@ -16,6 +46,63 @@ BackTestController = TypeVar("BackTestController")
class Config:
"""A singleton class for handling configuration settings for the aiomql package.
This class manages all configuration settings for the aiomql trading library.
It implements the singleton pattern to ensure consistent configuration across
the application. Settings can be loaded from a JSON config file or set
programmatically.
Attributes:
login (int): The MetaTrader account login number.
password (str): The MetaTrader account password.
server (str): The MetaTrader account server name.
path (str | Path): The path to the MetaTrader terminal executable.
timeout (int): The timeout for terminal connection in milliseconds.
Defaults to 60000.
config_file (str | Path): The absolute path to the configuration file.
filename (str): The name of the config file to search for.
Defaults to 'aiomql.json'.
root (Path): The root directory of the project. All relative paths
are resolved from this directory.
trade_record_mode (Literal["csv", "json", "sql"]): The format for
recording trades. Defaults to 'sql'.
record_trades (bool): Whether to record trades. Defaults to True.
records_dir (Path): The directory to store trade records.
records_dir_name (str): The name of the trade records directory.
Defaults to 'trade_records'.
backtest_dir (Path): The directory to store backtest results.
backtest_dir_name (str): The name of the backtest directory.
Defaults to 'backtesting'.
plots_dir (Path): The directory to store plot files.
plots_dir_name (str): The name of the plots directory.
Defaults to 'plots'.
db_dir_name (str): The name of the database directory.
Defaults to 'db'.
db_name (str | Path): The name or path of the SQLite database file.
state (State): A singleton key-value store for persistent state data.
store (Store): A key-value database store for general data persistence.
task_queue (TaskQueue): The TaskQueue object for handling background tasks.
bot (Bot): The bot instance associated with this configuration.
backtest_controller (BackTestController): The backtest controller instance.
mode (Literal["backtest", "live"]): The trading mode. Defaults to 'live'.
use_terminal_for_backtesting (bool): Whether to use the terminal for
backtesting. Defaults to True.
shutdown (bool): A signal to gracefully shut down the bot.
Defaults to False.
force_shutdown (bool): A signal to forcefully shut down the bot.
Defaults to False.
stop_trading (bool): A signal to stop opening new trades.
Defaults to False.
db_commit_interval (float): The interval in seconds for database commits.
Defaults to 30.
auto_commit (bool): Whether to auto-commit database changes.
Defaults to False.
flush_state (bool): Whether to flush state data on initialization.
Defaults to False.
lock (Lock): A threading lock for thread-safe operations.
"""
login: int
trade_record_mode: Literal["csv", "json", "sql"]
password: str
@@ -33,8 +120,9 @@ class Config:
backtest_dir: Path
records_dir_name: str
plots_dir_name: str
backtest_dir_name: str #Todo: add to docs
db_name: str
backtest_dir_name: str
db_dir_name: str
db_name: str | Path
task_queue: TaskQueue
_backtest_engine: BackTestEngine
bot: Bot
@@ -49,11 +137,13 @@ class Config:
flush_state: bool
stop_trading: bool
lock: Lock
auto_commit_state: bool
_defaults = {
"timeout": 60000,
"record_trades": True,
"records_dir_name": "trade_records",
"backtest_dir_name": "backtesting",
"db_dir_name": "db",
"config_file": None,
"trade_record_mode": "sql",
"mode": "live",
@@ -71,7 +161,8 @@ class Config:
"db_commit_interval": 30,
"auto_commit": False,
"flush_state": False,
"stop_trading": False
"stop_trading": False,
"auto_commit_state": True
}
def __new__(cls, *args, **kwargs):
@@ -87,7 +178,20 @@ class Config:
return cls._instance
def __init__(self, **kwargs):
"""Initialize the Config object. The root directory can be set here or in the load_config method."""
"""Initializes the Config object.
The root directory can be set here or in the load_config method.
If the config has already been initialized and no root or config_file
is provided, only the additional kwargs will be set as attributes.
Args:
**kwargs: Configuration attributes to set. Common options include:
- root (str | Path): The root directory of the project.
- config_file (str | Path): Path to the configuration file.
- login (int): MetaTrader account login number.
- password (str): MetaTrader account password.
- server (str): MetaTrader server name.
"""
root = kwargs.pop("root", None)
config_file = kwargs.pop("config_file", None)
if self.root is None or root is not None or config_file is not None:
@@ -125,6 +229,14 @@ class Config:
[setattr(self, key, value) for key, value in kwargs.items()]
def find_config_file(self):
"""Searches for the configuration file in the project directory tree.
Starts from the current working directory and searches up through
parent directories until the root directory is reached.
Returns:
Path | None: The path to the config file if found, None otherwise.
"""
try:
current = Path.cwd()
current = os.path.commonpath([current, self.root])
@@ -146,6 +258,15 @@ class Config:
return None
def set_root(self, root: str | Path = None):
"""Sets the root directory for the project.
If a root path is provided, it is resolved and created if it doesn't
exist. If no root is provided, the current working directory is used.
Args:
root: The path to set as the project root directory.
Defaults to None (uses current working directory).
"""
try:
if root is not None:
root = Path(root).resolve()
@@ -162,13 +283,23 @@ class Config:
self.root = Path.cwd()
def load_config(self, *, config_file: str | Path = None, filename: str = None, root: str | Path = None, **kwargs) -> Self:
"""Load configuration settings from a file and reset the config object.
"""Loads configuration settings from a file and initializes the config object.
This method sets up the project root, locates and loads the config file,
initializes the database connections, and sets all configuration attributes.
Args:
config_file (str | Path): The absolute path to the config file.
filename (str): The name of the file to load if file path is not specified. If not provided aiomql.json is used
root (str): The root directory of the project.
**kwargs: Additional keyword arguments to be set on the config object.
config_file: The absolute path to the config file. If provided and
exists, this file is used directly.
filename: The name of the file to search for if config_file is not
specified or doesn't exist. Defaults to 'aiomql.json'.
root: The root directory of the project. All relative paths are
resolved from this directory.
**kwargs: Additional configuration attributes to set. These override
values loaded from the config file.
Returns:
Self: The Config instance for method chaining.
"""
self.set_root(root=root)
@@ -188,14 +319,17 @@ class Config:
logger.debug("No Config File Found")
file_config = {}
else:
fh = open(self.config_file, mode="r")
file_config = json.load(fh)
# print(file_config)
fh.close()
file_config = {}
with open(self.config_file, mode="r") as fh:
file_config = json.load(fh)
data = file_config | kwargs
self.set_attributes(**data)
self.db_name = self.db_name or (f"db_{self.login}.sqlite3" if self.login else "db.sqlite3")
db_name = self.db_name or (f"db_{self.login}.sqlite3" if self.login else "db.sqlite3")
if not Path(db_name).exists():
db_name = self.root / self.db_dir_name / db_name
db_name.parent.mkdir(parents=True, exist_ok=True)
self.db_name = str(db_name)
os.environ["DB_NAME"] = self.db_name
self.init_state()
self.init_store()
@@ -209,45 +343,92 @@ class Config:
@property
def state(self):
"""Returns the State instance for persistent key-value storage.
Lazily initializes the state if it hasn't been created yet.
Returns:
State: The singleton State instance.
"""
if not hasattr(self, "_state"):
self.init_state()
return self._state
@state.setter
def state(self, value: State):
"""Sets the State instance.
Args:
value: The State instance to set.
"""
self._state = value
@property
def store(self):
"""Returns the Store instance for persistent key-value storage.
Lazily initializes the store if it hasn't been created yet.
Returns:
Store: The Store instance.
"""
if not hasattr(self, "_store"):
self.init_store()
return self._store
@store.setter
def store(self, value: Store):
"""Sets the Store instance.
Args:
value: The Store instance to set.
"""
self._store = value
def init_state(self):
self.state = State(db_name=self.root / self.db_name, flush=self.flush_state)
"""Initializes the State instance with the configured database."""
self.state = State(db_name=self.db_name, flush=self.flush_state, autocommit=self.auto_commit_state)
def init_store(self):
self.store = Store(db_name=self.root / self.db_name, flush=self.flush_state)
"""Initializes the Store instance with the configured database."""
self.store = Store(db_name=self.db_name, flush=self.flush_state, autocommit=self.auto_commit_state)
@property
@cached_property
def records_dir(self):
rec_dir = self.root / self.records_dir_name or 'trade_records'
"""Returns the directory path for storing trade records.
Creates the directory if it doesn't exist.
Returns:
Path: The path to the trade records directory.
"""
rec_dir = self.root / self.records_dir_name
rec_dir.mkdir(parents=True, exist_ok=True) if rec_dir.exists() is False else ...
return rec_dir
@property
@cached_property
def backtest_dir(self) -> Path:
b_dir = self.root / self.backtest_dir_name or 'backtesting'
"""Returns the directory path for storing backtest results.
Creates the directory if it doesn't exist.
Returns:
Path: The path to the backtest results directory.
"""
b_dir = self.root / self.backtest_dir_name
b_dir.mkdir(parents=True, exist_ok=True) if b_dir.exists() is False else ...
return b_dir
@property
@cached_property
def plots_dir(self):
p_dir = self.root / self.plots_dir_name or "plots"
"""Returns the directory path for storing plot files.
Creates the directory if it doesn't exist.
Returns:
Path: The path to the plots directory.
"""
p_dir = self.root / self.plots_dir_name
p_dir.mkdir(parents=True, exist_ok=True) if p_dir.exists() is False else ...
return p_dir
@@ -259,37 +440,3 @@ class Config:
dict[str, int | str]: A dictionary of login details
"""
return {"login": self.login, "password": self.password, "server": self.server}
Config.__doc__ = """A class for handling configuration settings for the aiomql package.
Attributes:
login (int): The account login number
trade_record_mode (Literal["csv", "json"]): The mode for recording trades
password (str): The account password
server (str): The account server
path (str | Path): The path to the terminal
timeout (int): The timeout argument for the terminal
filename (str): The filename of the config file
config_file (Path): The config file path
state (State): A key-value database
root (Path): The root directory of the project
record_trades (bool): To record trades or not. Default is True
records_dir (Path): The directory to store trade records, relative to the root directory
backtest_dir (Path): The directory to store backtest results, relative to the root directory
task_queue (TaskQueue): The TaskQueue object for handling background tasks
_backtest_engine (BackTestEngine): The backtest engine object
bot (Bot): The bot object
_instance (Self): The instance of the Config class
mode (Literal["backtest", "live"]): The trading mode, either backtest or live, default is live
use_terminal_for_backtesting (bool): Use the terminal for backtesting, default is True
shutdown (bool): A signal to shut down the terminal, default is False
force_shutdown (bool): A signal to force shut down the terminal, default is False
Notes:
By default, the config class looks for a file named aiomql.json. This can be changed by setting the filename
attribute to the desired file name. The root directory of the project can be set by passing the root argument
to the load_config method or during object instantiation. If not provided it is assumed to be the current working
directory. All directories and files are assumed to be relative to the root directory except when an absolute path
is provided, this includes the config file, the records_dir and the backtest_dir attributes.
The root directory is used to locate the config file and to set the records_dir and backtest_dir attributes.
"""
+52 -12
View File
@@ -1,23 +1,63 @@
"""MetaTrader5 constants as Python IntEnum types.
This module provides MetaTrader5 constants as IntEnum types with Pythonic
class names and nice string representation. Each enum wraps the corresponding
MT5 constants for type safety and better IDE support.
Example:
Using order filling constants::
from aiomql import OrderFilling
fok = OrderFilling.FOK
print(fok) # "ORDER_FILLING_FOK"
# Use in trade requests
request = {'type_filling': OrderFilling.FOK}
Classes:
TradeAction: Trade request action types (DEAL, PENDING, SLTP, etc.)
OrderFilling: Order filling policies (FOK, IOC, RETURN)
OrderTime: Order time in force policies (GTC, DAY, SPECIFIED)
OrderType: Order types (BUY, SELL, LIMIT, STOP, etc.)
TimeFrame: Chart timeframes (M1 through MN1)
PositionType: Position direction (BUY, SELL)
DealType: Deal types (BUY, SELL, BALANCE, etc.)
TradeRetcode: Trade operation return codes
"""
from enum import IntEnum, IntFlag
import MetaTrader5 as mt5
"""
MetaTrader5 constants as IntEnum types with Python style class names and nice string representation
Examples:
>>> from aiomql import OrderFilling
>>> fok = OrderFilling.FOK
>>> print(fok)
"ORDER_FILLING_FOK"
"""
class Repr:
"""Mixin class for custom string representation of enum values.
Provides a __str__ method that formats enum values with their
class name prefix, matching MetaTrader5 constant naming convention.
Attributes:
__enum_name__ (str): The prefix to use in string representation.
name (str): The enum member name (provided by IntEnum).
Example:
>>> class MyEnum(Repr, IntEnum):
... __enum_name__ = "MY_ENUM"
... VALUE = 1
>>> print(MyEnum.VALUE)
MY_ENUM_VALUE
"""
__enum_name__ = ""
name: str
def __str__(self):
"""Returns the full constant name with prefix.
Returns:
str: The formatted constant name (e.g., "ORDER_FILLING_FOK").
"""
return f"{self.__enum_name__}_{self.name}"
@@ -124,11 +164,11 @@ class OrderType(Repr, IntEnum):
return OrderType(_type)
@property
def long(self):
def is_long(self):
return self in [0, 2, 4, 6]
@property
def short(self):
def is_short(self):
return self in [1, 3, 5, 7]
class BookType(Repr, IntEnum):
+424 -33
View File
@@ -1,127 +1,324 @@
"""Database ORM module for SQLite operations with dataclass support.
This module provides the DB class, a base class for ORM-style database
operations. Classes that inherit from DB and are decorated with @dataclass
automatically get table creation, CRUD operations, and serialization.
The DB class maps Python types to SQLite types and uses dataclass fields
to define table columns.
Example:
Creating a model class::
from dataclasses import dataclass
from aiomql.core.db import DB
@dataclass
class TradeRecord(DB):
symbol: str
volume: float
profit: float
# Save a record
record = TradeRecord(symbol='EURUSD', volume=0.1, profit=50.0)
record.save()
# Query records
trades = TradeRecord.filter(symbol='EURUSD')
"""
import os
import sqlite3
import re
from logging import getLogger
from dataclasses import Field, fields, asdict, MISSING, is_dataclass
from typing import ClassVar
from functools import cached_property
from .config import Config
logger = getLogger(__name__)
class DB:
"""A base class for ORM-style database operations with SQLite.
Designed to be used with @dataclass decorator. Provides automatic
table creation based on dataclass fields, and CRUD operations.
Attributes:
_table (ClassVar[str]): The database table name. Defaults to
lowercase class name.
_initialized (ClassVar[bool]): Whether the database has been
initialized for this class.
TYPES (ClassVar[dict]): Mapping of Python types to SQLite types.
config (ClassVar[Config]): The global configuration instance.
db_name (ClassVar[str]): The database file name.
Example:
>>> @dataclass
... class User(DB):
... name: str
... age: int
>>> user = User(name='John', age=30)
>>> user.save()
"""
_table: ClassVar[str] = ""
_initialized: ClassVar[bool] = False
TYPES: ClassVar[dict] = {str: "TEXT", float: "REAL", int: "INTEGER", bool: "BOOLEAN", None: "NULL", bytes: "BLOB"}
config: ClassVar[Config]
conn: sqlite3.Connection
cursor: sqlite3.Cursor
db_name: ClassVar[str]
def __new__(cls, *args, **kwargs):
"""Creates a new instance and initializes the Config.
Returns:
DB: A new instance of the class.
"""
cls.config = Config()
if not cls._initialized:
cls.init_db()
return super().__new__(cls)
def __post_init__(self):
self.init_db()
# def __post_init__(self):
# """Called after dataclass __init__ to initialize the database."""
# self.init_db()
def init_db(self):
db_name = getattr(self.config, "db_name", os.getenv("DB_NAME", "db.sqlite3"))
self.conn = sqlite3.connect(db_name)
self.conn.row_factory = self.dict_factory()
self.cursor = self.conn.cursor()
self.create_table(self.conn)
@cached_property
def pk(self):
"""Returns the primary key field name and value.
Searches through the dataclass fields to find the one marked with
the 'PRIMARY KEY' metadata flag.
Returns:
tuple[str, Any]: A tuple containing the primary key field name
and its current value.
Raises:
StopIteration: If no field is marked with PRIMARY KEY metadata.
"""
name = next((f.name for f in fields(self) if f.metadata.get("PRIMARY KEY")))
return name, getattr(self, name)
@classmethod
def init_db(cls):
"""Initializes the database connection and creates the table.
Sets up the SQLite connection with a custom row factory and
creates the table if it doesn't exist.
"""
cls.config = Config()
cls.db_name = getattr(cls.config, "db_name", os.getenv("DB_NAME", "db.sqlite3"))
conn = sqlite3.connect(cls.db_name)
cls.create_table(conn)
conn.close()
@classmethod
def get_connection(cls):
db_name = os.getenv("DB_NAME", "db.sqlite3")
conn = sqlite3.connect(db_name)
"""Gets a new database connection.
Initializes the database if not already done, then creates and
returns a new SQLite connection with the custom row factory.
Returns:
sqlite3.Connection: A new database connection with the
class's dict_factory set as the row factory.
"""
if not cls._initialized:
cls.init_db()
cls._initialized = True
conn = sqlite3.connect(cls.db_name)
conn.row_factory = cls.dict_factory()
return conn
@classmethod
def create_table(cls, conn: sqlite3.Connection):
"""Creates the database table if it doesn't exist.
Uses the dataclass fields to determine column definitions.
Args:
conn: The database connection to use.
"""
try:
if not is_dataclass(cls):
return
cls._table = cls._table or cls.__name__.lower()
columns = cls.get_columns()
q = f"""CREATE TABLE IF NOT EXISTS '{cls._table}' ({columns})"""
conn.execute(f"""CREATE TABLE IF NOT EXISTS '{cls._table}' ({columns})""")
conn.execute(f"""CREATE TABLE IF NOT EXISTS'{cls._table}' ({columns})""")
conn.commit()
cls._initialized = True
except Exception as e:
logger.error("%s: Failed to create table", e)
@classmethod
def dict_factory(cls):
"""Creates a row factory that returns class instances.
Returns:
Callable: A factory function for SQLite row conversion.
"""
def dict_factory(cursor, row):
cols = [column[0] for column in cursor.description]
kw = {key: value for key, value in zip(cols, row)}
print(type(cls), cls.__name__)
return cls(**kw)
return dict_factory
@staticmethod
def sanitize(identifier):
"""Sanitizes a SQL identifier to prevent injection.
Args:
identifier: The table or column name to sanitize.
Returns:
str: The sanitized identifier wrapped in quotes.
Raises:
ValueError: If the identifier contains invalid characters.
"""
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", identifier):
return f'"{identifier}"'
raise ValueError("Invalid table name")
@classmethod
def types(cls, key):
"""Maps a Python type to its SQLite equivalent.
Args:
key: The Python type to map.
Returns:
str: The corresponding SQLite type. Defaults to TEXT.
"""
return cls.TYPES.get(key, "TEXT")
@staticmethod
def get_default(col: Field):
if not isinstance(col.default, type(MISSING)):
return f"DEFAULT {col.default}"
elif not isinstance(col.default_factory, type(MISSING)):
return f"DEFAULT {col.default_factory()}"
else:
"""Gets the DEFAULT clause for a dataclass field.
Args:
col: The dataclass Field to get the default for.
Returns:
str: The DEFAULT SQL clause, or empty string if no default.
"""
if isinstance(col.default, type(MISSING)) and isinstance(col.default_factory, type(MISSING)):
return ""
if not isinstance(col.default, type(MISSING)):
return f"DEFAULT {col.default!r}"
if not isinstance(col.default_factory, type(MISSING)):
return f"DEFAULT {col.default_factory()!r}"
return ""
@staticmethod
def get_metadata(col: Field):
"""Extracts SQL metadata from a dataclass field.
Args:
col: The dataclass Field to extract metadata from.
Returns:
str: SQL constraints from field metadata (e.g., PRIMARY KEY).
"""
return f"{' '.join(meta for meta, value in col.metadata.items() if value)}"
@classmethod
def get_columns(cls):
"""Generates the column definitions for table creation.
Returns:
str: Comma-separated column definitions for CREATE TABLE.
"""
cols = fields(cls)
cols = [f"'{col.name}' {cls.types(col.type)} {cls.get_metadata(col)} {cls.get_default(col)}" for col in
cols]
return ",".join(cols)
def commit(self):
self.conn.commit()
def close(self):
self.conn.close()
def asdict(self):
"""Converts the instance to a dictionary.
Returns:
dict: The dataclass fields as a dictionary.
"""
return asdict(self)
def get_data(self):
"""Returns the instance data for saving.
Returns:
dict: The instance data as a dictionary.
"""
return self.asdict()
@classmethod
def clear(cls):
"""Deletes all records from the table.
Removes all rows from the model's table. The table structure
remains intact.
Note:
This operation is irreversible. Use with caution.
"""
conn = cls.get_connection()
conn.execute(f"DELETE FROM {cls._table}")
conn.commit()
conn.close()
def save(self, commit=True):
data = self.get_data()
def save(self, commit: bool = True, update: bool = False, data: dict = None, conn: sqlite3.Connection = None):
"""Saves the current instance to the database.
Inserts a new record or updates an existing one based on the
update parameter. Uses parameterized queries for safety.
Args:
commit: If True, commits the transaction and closes the
connection. Defaults to True.
update: If True, performs an UPDATE using the primary key.
If False, performs an INSERT. Defaults to False.
data: Dictionary of field-value pairs to save. If None,
uses get_data() to retrieve instance data.
conn: An existing database connection to use. If None,
creates a new connection.
Example:
>>> record = TradeRecord(symbol='EURUSD', volume=0.1)
>>> record.save() # Insert new record
>>> record.volume = 0.2
>>> record.save(update=True) # Update existing record
"""
conn = conn or self.get_connection()
data = data or self.get_data()
columns = ", ".join(self.sanitize(key) for key in data.keys())
placeholders = ", ".join(["?"] * len(data))
values = tuple(data.values())
table = self.sanitize(self._table)
query = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
self.cursor.execute(query, values)
if update:
pk, pk_value = self.pk
update_columns = ", ".join(f"{self.sanitize(key)} = ?" for key in data.keys())
query = f"UPDATE {table} SET {update_columns} WHERE {self.sanitize(pk)} = {pk_value}"
else:
query = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
conn.execute(query, values)
if commit:
self.commit()
conn.commit()
conn.close()
@classmethod
def get(cls, **kwargs):
"""Retrieves a single record matching the criteria.
Args:
**kwargs: Field-value pairs to filter by.
Returns:
The first matching record as a class instance, or None.
"""
conn = cls.get_connection()
_query = " AND ".join([f'"{key}" = "{value}"' for key, value in kwargs.items()])
query = f"""SELECT * FROM {cls.sanitize(cls._table)} WHERE {_query}"""
@@ -131,9 +328,16 @@ class DB:
@classmethod
def filter(cls, **kwargs):
"""Retrieves all records matching the criteria.
Args:
**kwargs: Field-value pairs to filter by. If empty, returns all.
Returns:
list: Matching records as class instances.
"""
conn = cls.get_connection()
_query = " AND ".join([f'"{key}" = "{value}"' for key, value in kwargs.items()])
# _query = " AND ".join([f'{key} = {value}' for key, value in kwargs.items()])
_query = f"WHERE {_query}" if _query else ""
query = f"SELECT * FROM {cls.sanitize(cls._table)} {_query}"
res = conn.execute(query).fetchall()
@@ -142,6 +346,15 @@ class DB:
@classmethod
def update(cls, data=None, /, **kwargs):
"""Updates records matching the criteria.
Args:
data: Dictionary of field-value pairs to update.
**kwargs: Field-value pairs to filter which records to update.
Returns:
bool: True if the update was successful.
"""
conn = cls.get_connection()
update = data or {}
_query = " AND ".join([f'"{key}" = "{value}"' for key, value in kwargs.items()])
@@ -156,12 +369,190 @@ class DB:
@classmethod
def fields(cls) -> list[str]:
"""Returns a list of field names for the model.
Returns:
list[str]: The names of all dataclass fields.
"""
fs = fields(cls)
return [f.name for f in fs]
@classmethod
def drop_table(cls):
"""Drops the database table if it exists.
Permanently removes the table and all its data from the database.
This is a destructive operation.
Note:
This operation is irreversible. All data in the table
will be permanently deleted.
"""
conn = cls.get_connection()
conn.execute(f"DROP TABLE IF EXISTS {cls._table}")
conn.commit()
conn.close()
@classmethod
def all(cls, limit: int | None = None):
"""Returns all records from the table.
Retrieves all rows from the model's table, optionally limited
to a specified number of records.
Args:
limit: Maximum number of records to return. If None,
returns all records. Defaults to None.
Returns:
list: All records as class instances, up to the specified
limit if provided.
Example:
>>> # Get all records
>>> all_trades = TradeRecord.all()
>>> # Get first 10 records
>>> recent_trades = TradeRecord.all(limit=10)
"""
conn = cls.get_connection()
if limit is None:
query = f"SELECT * FROM {cls._table}"
else:
query = f"SELECT * FROM {cls._table} LIMIT {limit}"
res = conn.execute(query).fetchall()
conn.close()
return res
@classmethod
def execute_raw(cls, sql: str, params: tuple | list | dict = None, *, allow_write: bool = False):
"""Execute a validated raw SQL query with parameterized values.
This method provides safe execution of raw SQL queries by:
1. Validating the SQL statement type (SELECT only by default)
2. Checking for dangerous SQL patterns
3. Using parameterized queries to prevent SQL injection
Args:
sql: The SQL query string with placeholders (? or :name).
Use '?' for positional parameters with tuple/list params.
Use ':name' for named parameters with dict params.
params: Query parameters as tuple, list, or dict. Defaults to None.
allow_write: If True, allows INSERT/UPDATE/DELETE operations.
Defaults to False for safety.
Returns:
list: Query results as class instances for SELECT queries.
int: Number of affected rows for write operations.
Raises:
ValueError: If the SQL contains dangerous patterns or invalid syntax.
PermissionError: If write operations are attempted without allow_write=True.
Example:
Safe parameterized SELECT::
results = MyModel.execute_raw(
"SELECT * FROM mytable WHERE symbol = ? AND volume > ?",
("EURUSD", 0.1)
)
Named parameters::
results = MyModel.execute_raw(
"SELECT * FROM mytable WHERE symbol = :sym",
{"sym": "EURUSD"}
)
Write operations (requires allow_write=True)::
affected = MyModel.execute_raw(
"UPDATE mytable SET closed = ? WHERE order_id = ?",
(True, 12345),
allow_write=True
)
"""
if not sql or not isinstance(sql, str):
raise ValueError("SQL query must be a non-empty string")
# Normalize whitespace and convert to uppercase for validation
sql_normalized = " ".join(sql.split()).upper()
# Dangerous patterns that should never be allowed
dangerous_patterns = [
r";\s*DROP\s+",
r";\s*DELETE\s+",
r";\s*TRUNCATE\s+",
r";\s*ALTER\s+",
r";\s*CREATE\s+",
r"--", # SQL comments
r"/\*", # Block comment
r"EXEC\s*\(",
r"EXECUTE\s*\(",
r"XP_", # Extended stored procedures
r"SP_", # System stored procedures
r"0x[0-9A-F]+", # Hex literals often used in attacks
]
for pattern in dangerous_patterns:
if re.search(pattern, sql_normalized, re.IGNORECASE):
raise ValueError(f"SQL query contains dangerous pattern: {pattern}")
# Check for multiple statements (multiple semicolons)
# Allow trailing semicolon but not multiple statements
sql_stripped = sql.strip().rstrip(";")
if ";" in sql_stripped:
raise ValueError("Multiple SQL statements are not allowed")
# Determine the statement type
sql_type = sql_normalized.split()[0] if sql_normalized.split() else ""
# Define allowed statement types
read_statements = {"SELECT"}
write_statements = {"INSERT", "UPDATE", "DELETE"}
if sql_type in write_statements:
if not allow_write:
raise PermissionError(
f"{sql_type} operations require allow_write=True. "
"This is a safety measure to prevent accidental data modification."
)
elif sql_type not in read_statements:
raise ValueError(
f"Unsupported SQL statement type: {sql_type}. "
f"Allowed: SELECT, or INSERT/UPDATE/DELETE with allow_write=True"
)
# Validate params type
if params is not None and not isinstance(params, (tuple, list, dict)):
raise ValueError("params must be tuple, list, or dict")
# Execute the query with parameterized values
conn = cls.get_connection()
try:
if params:
cursor = conn.execute(sql, params)
else:
cursor = conn.execute(sql)
if sql_type == "SELECT":
results = cursor.fetchall()
conn.close()
return results
else:
# For write operations, commit and return affected row count
affected_rows = cursor.rowcount
conn.commit()
conn.close()
return affected_rows
except sqlite3.Error as e:
conn.close()
logger.error("SQL execution error: %s", e)
raise ValueError(f"SQL execution failed: {e}") from e
@classmethod
def filter_dict(cls, data: dict, exclude: set[str] = None, include: set[str] = None) -> dict:
exclude, include = exclude or set(), include or set(cls.fields())
filter_ = include.difference(exclude)
return {key: value for key, value in data.items() if key in filter_ and value is not None}
+5 -2
View File
@@ -1,7 +1,6 @@
"""Exceptions for the aiomql package."""
__all__ = ["LoginError", "VolumeError", "SymbolError", "OrderError"]
__all__ = ["LoginError", "VolumeError", "SymbolError", "OrderError", "StopTrading", "InvalidRequest"]
class LoginError(Exception):
"""Raised when an error occurs when logging in."""
@@ -31,3 +30,7 @@ class StopTrading(Exception):
"""Raised when the user wants to stop trading."""
...
class InvalidRequest(Exception):
"""Raised when an error occurs when trying to query the market."""
...
+330 -7
View File
@@ -26,7 +26,6 @@ from .config import Config
logger = getLogger()
class MetaTrader(MetaCore):
def __new__(cls, *args, **kwargs):
if not hasattr(cls, "config"):
@@ -56,22 +55,40 @@ class MetaTrader(MetaCore):
await self.shutdown()
async def _handler(self, api: dict, retries=3):
"""Handles API calls to the MetaTrader terminal with retry logic.
Executes the specified function in a separate thread and handles
connection errors by retrying the initialization and login process.
Args:
api: A dictionary containing:
- func: The function to execute.
- args: Optional tuple of positional arguments.
- kwargs: Optional dictionary of keyword arguments.
- error_msg: Optional error message for logging.
retries: Number of retry attempts for connection errors.
Defaults to 3.
Returns:
The result of the API call, or None if the call failed.
"""
func = api["func"]
args = api.get("args", ())
kwargs = api.get("kwargs", {})
error_msg = api.get("error_msg", "An error occurred")
error_msg = api.get("error_msg", f"An error occurred in {func.__name__} of {self.__class__.__name__}")
res = await asyncio.to_thread(func, *args, **kwargs)
if res is not None:
return res
if res is None and self.error.is_connection_error() and retries > 0:
err = await self.last_error()
self.error = Error(*err)
if self.error.is_connection_error() and retries > 0:
await self.initialize()
await self.login()
return await self._handler(api, retries=retries - 1)
else:
err = await self.last_error()
self.error = Error(*err)
logger.warning(f"{error_msg}:{self.error.description}")
return res
@@ -179,6 +196,13 @@ class MetaTrader(MetaCore):
self._shutdown()
async def last_error(self) -> tuple[int, str]:
"""Retrieves the last error information from the MetaTrader terminal.
Returns:
tuple[int, str]: A tuple containing the error code and error
description. Returns (-1, error_message) if an exception
occurs while retrieving the error.
"""
try:
res = await asyncio.to_thread(self._last_error)
return res
@@ -187,34 +211,75 @@ class MetaTrader(MetaCore):
return -1, str(err)
async def version(self) -> tuple[int, int, str] | None:
""""""
"""Retrieves the MetaTrader terminal version information.
Returns:
tuple[int, int, str] | None: A tuple containing (version, build,
release_date) if successful, None otherwise.
"""
api = {"func": self._version, "error_msg": "Error in obtaining version."}
res = await self._handler(api)
return res
async def account_info(self) -> AccountInfo | None:
""""""
"""Retrieves information about the current trading account.
Returns:
AccountInfo | None: An AccountInfo object containing account
details if successful, None otherwise.
"""
api = {"func": self._account_info, "error_msg": "Error in obtaining account information"}
res = await self._handler(api)
return res
async def terminal_info(self) -> TerminalInfo | None:
"""Retrieves information about the MetaTrader terminal.
Returns:
TerminalInfo | None: A TerminalInfo object containing terminal
details if successful, None otherwise.
"""
api = {"func": self._terminal_info, "error_msg": "Error in obtaining terminal information"}
res = await self._handler(api)
return res
async def symbols_total(self) -> int:
"""Retrieves the total number of financial symbols available.
Returns:
int: The total number of symbols available in the terminal.
"""
api = {"func": self._symbols_total, "error_msg": "Error in obtaining total symbols."}
res = await self._handler(api)
return res
async def symbols_get(self, group: str = "") -> tuple[SymbolInfo] | None:
"""Retrieves all financial symbols or symbols matching a filter.
Args:
group: A filter for selecting symbols by group name. Supports
wildcards (*) and exclusions (!). Defaults to empty string
which returns all symbols.
Returns:
tuple[SymbolInfo] | None: A tuple of SymbolInfo objects if
successful, None otherwise.
"""
kwargs = {"group": group} if group else {}
api = {"func": self._symbols_get, "kwargs": kwargs, "error_msg": "Error in obtaining symbols."}
res = await self._handler(api)
return res
async def symbol_info(self, symbol: str) -> SymbolInfo | None:
"""Retrieves information about a specific financial symbol.
Args:
symbol: The name of the financial symbol.
Returns:
SymbolInfo | None: A SymbolInfo object containing symbol details
if successful, None otherwise.
"""
api = {
"func": self._symbol_info,
"args": (symbol,),
@@ -224,16 +289,43 @@ class MetaTrader(MetaCore):
return res
async def symbol_info_tick(self, symbol: str) -> Tick | None:
"""Retrieves the last tick data for a specified symbol.
Args:
symbol: The name of the financial symbol.
Returns:
Tick | None: A Tick object containing the last tick data
if successful, None otherwise.
"""
api = {"func": self._symbol_info_tick, "args": (symbol,), "error_msg": f"Error in obtaining tick for {symbol}"}
res = await self._handler(api)
return res
async def symbol_select(self, symbol: str, enable: bool) -> bool:
"""Selects or removes a symbol from the MarketWatch window.
Args:
symbol: The name of the financial symbol.
enable: If True, the symbol is selected in MarketWatch.
If False, the symbol is removed.
Returns:
bool: True if successful, False otherwise.
"""
api = {"func": self._symbol_select, "args": (symbol, enable), "error_msg": f"Error in selecting {symbol}"}
res = await self._handler(api)
return res
async def market_book_add(self, symbol: str) -> bool:
"""Subscribes to the market depth (order book) for a symbol.
Args:
symbol: The name of the financial symbol.
Returns:
bool: True if subscription was successful, False otherwise.
"""
api = {
"func": self._market_book_add,
"args": (symbol,),
@@ -243,6 +335,17 @@ class MetaTrader(MetaCore):
return res
async def market_book_get(self, symbol: str) -> tuple[BookInfo] | None:
"""Retrieves the market depth (order book) data for a symbol.
The symbol must first be subscribed using market_book_add().
Args:
symbol: The name of the financial symbol.
Returns:
tuple[BookInfo] | None: A tuple of BookInfo objects representing
the order book entries if successful, None otherwise.
"""
api = {
"func": self._market_book_get,
"args": (symbol,),
@@ -252,6 +355,14 @@ class MetaTrader(MetaCore):
return res
async def market_book_release(self, symbol: str) -> bool:
"""Unsubscribes from the market depth (order book) for a symbol.
Args:
symbol: The name of the financial symbol.
Returns:
bool: True if unsubscription was successful, False otherwise.
"""
api = {
"func": self._market_book_release,
"args": (symbol,),
@@ -263,6 +374,20 @@ class MetaTrader(MetaCore):
async def copy_rates_from(
self, symbol: str, timeframe: int, date_from: datetime | float, count: int
) -> np.ndarray | None:
"""Copies price history (bars/candles) starting from a specified date.
Args:
symbol: The name of the financial symbol.
timeframe: The chart timeframe as a TIMEFRAME constant.
date_from: The starting date for the data request. Can be a
datetime object or a Unix timestamp.
count: The number of bars to retrieve.
Returns:
np.ndarray | None: A numpy array with columns (time, open, high,
low, close, tick_volume, spread, real_volume) if successful,
None otherwise.
"""
api = {
"func": self._copy_rates_from,
"args": (symbol, timeframe, date_from, count),
@@ -272,6 +397,19 @@ class MetaTrader(MetaCore):
return res
async def copy_rates_from_pos(self, symbol: str, timeframe: int, start_pos: int, count: int) -> np.ndarray | None:
"""Copies price history (bars/candles) starting from a specified index.
Args:
symbol: The name of the financial symbol.
timeframe: The chart timeframe as a TIMEFRAME constant.
start_pos: The starting index position (0 is the current bar).
count: The number of bars to retrieve.
Returns:
np.ndarray | None: A numpy array with columns (time, open, high,
low, close, tick_volume, spread, real_volume) if successful,
None otherwise.
"""
api = {
"func": self._copy_rates_from_pos,
"args": (symbol, timeframe, start_pos, count),
@@ -283,6 +421,21 @@ class MetaTrader(MetaCore):
async def copy_rates_range(
self, symbol: str, timeframe: int, date_from: datetime | float, date_to: datetime | float
) -> np.ndarray | None:
"""Copies price history (bars/candles) within a specified date range.
Args:
symbol: The name of the financial symbol.
timeframe: The chart timeframe as a TIMEFRAME constant.
date_from: The starting date. Can be a datetime object or
Unix timestamp.
date_to: The ending date. Can be a datetime object or
Unix timestamp.
Returns:
np.ndarray | None: A numpy array with columns (time, open, high,
low, close, tick_volume, spread, real_volume) if successful,
None otherwise.
"""
api = {
"func": self._copy_rates_range,
"args": (symbol, timeframe, date_from, date_to),
@@ -294,6 +447,20 @@ class MetaTrader(MetaCore):
async def copy_ticks_from(
self, symbol: str, date_from: datetime | float, count: int, flags: CopyTicks
) -> np.ndarray | None:
"""Copies tick data starting from a specified date.
Args:
symbol: The name of the financial symbol.
date_from: The starting date. Can be a datetime object or
Unix timestamp.
count: The number of ticks to retrieve.
flags: A CopyTicks flag specifying the type of ticks to copy
(ALL, INFO, or TRADE).
Returns:
np.ndarray | None: A numpy array with tick data if successful,
None otherwise.
"""
api = {
"func": self._copy_ticks_from,
"args": (symbol, date_from, count, flags),
@@ -305,6 +472,21 @@ class MetaTrader(MetaCore):
async def copy_ticks_range(
self, symbol: str, date_from: datetime | float, date_to: datetime | float, flags: CopyTicks
) -> np.ndarray | None:
"""Copies tick data within a specified date range.
Args:
symbol: The name of the financial symbol.
date_from: The starting date. Can be a datetime object or
Unix timestamp.
date_to: The ending date. Can be a datetime object or
Unix timestamp.
flags: A CopyTicks flag specifying the type of ticks to copy
(ALL, INFO, or TRADE).
Returns:
np.ndarray | None: A numpy array with tick data if successful,
None otherwise.
"""
api = {
"func": self._copy_ticks_range,
"args": (symbol, date_from, date_to, flags),
@@ -314,11 +496,28 @@ class MetaTrader(MetaCore):
return res
async def orders_total(self) -> int:
"""Retrieves the total number of active pending orders.
Returns:
int: The total number of active pending orders.
"""
api = {"func": self._orders_total, "error_msg": "Error in obtaining total orders."}
res = await self._handler(api)
return res
async def orders_get(self, group: str = "", ticket: int = 0, symbol: str = "") -> tuple[TradeOrder] | None:
"""Retrieves active pending orders with optional filtering.
Args:
group: A filter for symbols by group name. Supports wildcards (*)
and exclusions (!). Defaults to empty string.
ticket: Order ticket number to filter by. Defaults to 0.
symbol: Symbol name to filter by. Defaults to empty string.
Returns:
tuple[TradeOrder] | None: A tuple of TradeOrder objects if
successful, None otherwise.
"""
kwargs = {key: value for key, value in (("group", group), ("ticket", ticket), ("symbol", symbol)) if value}
api = {"func": self._orders_get, "kwargs": kwargs, "error_msg": "Error in obtaining orders."}
res = await self._handler(api)
@@ -327,6 +526,18 @@ class MetaTrader(MetaCore):
async def order_calc_margin(
self, action: Literal[OrderType.BUY, OrderType.SELL], symbol: str, volume: float, price: float
) -> float | None:
"""Calculates the margin required for a specified order.
Args:
action: The order type (OrderType.BUY or OrderType.SELL).
symbol: The name of the financial symbol.
volume: The trade volume in lots.
price: The open price.
Returns:
float | None: The required margin in the account currency if
successful, None otherwise.
"""
api = {
"func": self._order_calc_margin,
"args": (action, symbol, volume, price),
@@ -343,6 +554,19 @@ class MetaTrader(MetaCore):
price_open: float,
price_close: float,
) -> float | None:
"""Calculates the profit/loss for a specified order.
Args:
action: The order type (OrderType.BUY or OrderType.SELL).
symbol: The name of the financial symbol.
volume: The trade volume in lots.
price_open: The open price.
price_close: The close price.
Returns:
float | None: The calculated profit/loss in the account currency
if successful, None otherwise.
"""
api = {
"func": self._order_calc_profit,
"args": (action, symbol, volume, price_open, price_close),
@@ -352,27 +576,83 @@ class MetaTrader(MetaCore):
return res
async def order_check(self, request: dict) -> OrderCheckResult:
"""Checks if the funds are sufficient for a specified order.
Args:
request: A dictionary containing the order parameters. Required
keys include action, symbol, volume, type, price, etc.
Returns:
OrderCheckResult: The result of the order check containing
information about margin, balance, and trade validity.
"""
comment = request.get("comment")
if comment is not None and len(comment) > 25:
request["comment"] = comment[:25]
logger.warning("order comment length exceeds 25 characters.")
api = {"func": self._order_check, "args": (request,), "error_msg": "Error in checking order."}
res = await self._handler(api)
return res
async def order_send(self, request: dict) -> OrderSendResult:
"""Sends a trade request to the MetaTrader terminal.
Args:
request: A dictionary containing the trade request parameters.
Required keys include action, symbol, volume, type, price, etc.
Returns:
OrderSendResult: The result of the trade request containing
the order ticket, deal ticket, and execution details.
"""
comment = request.get("comment")
if comment is not None and len(comment) > 25:
request["comment"] = comment[:25]
logger.warning("order comment length exceeds 25 characters.")
api = {"func": self._order_send, "args": (request,), "error_msg": "Error in sending order."}
res = await self._handler(api)
return res
async def positions_total(self) -> int:
"""Retrieves the total number of open positions.
Returns:
int: The total number of open positions.
"""
api = {"func": self._positions_total, "error_msg": "Error in obtaining total positions."}
res = await self._handler(api)
return res
async def positions_get(self, group: str = "", ticket: int = None, symbol: str = "") -> tuple[TradePosition] | None:
"""Retrieves open positions with optional filtering.
Args:
group: A filter for symbols by group name. Supports wildcards (*)
and exclusions (!). Defaults to empty string.
ticket: Position ticket number to filter by. Defaults to None.
symbol: Symbol name to filter by. Defaults to empty string.
Returns:
tuple[TradePosition] | None: A tuple of TradePosition objects if
successful, None otherwise.
"""
kwargs = {key: value for key, value in (("group", group), ("ticket", ticket), ("symbol", symbol)) if value}
api = {"func": self._positions_get, "kwargs": kwargs, "error_msg": "Error in obtaining open positions."}
res = await self._handler(api)
return res
async def history_orders_total(self, date_from: datetime | float, date_to: datetime | float) -> int:
"""Retrieves the total number of orders in trading history.
Args:
date_from: The starting date. Can be a datetime object or
Unix timestamp.
date_to: The ending date. Can be a datetime object or
Unix timestamp.
Returns:
int: The total number of orders in the specified date range.
"""
api = {
"func": self._history_orders_total,
"args": (date_from, date_to),
@@ -389,6 +669,22 @@ class MetaTrader(MetaCore):
ticket: int = None,
position: int = None,
) -> tuple[TradeOrder] | None:
"""Retrieves orders from trading history with optional filtering.
Args:
date_from: The starting date. Can be a datetime object or
Unix timestamp. Defaults to None.
date_to: The ending date. Can be a datetime object or
Unix timestamp. Defaults to None.
group: A filter for symbols by group name. Supports wildcards (*)
and exclusions (!). Defaults to empty string.
ticket: Order ticket number to filter by. Defaults to None.
position: Position identifier to filter by. Defaults to None.
Returns:
tuple[TradeOrder] | None: A tuple of TradeOrder objects if
successful, None otherwise.
"""
kwargs = {key: value for key, value in (("group", group), ("ticket", ticket), ("position", position)) if value}
args = tuple(arg for arg in (date_from, date_to) if arg)
api = {
@@ -401,6 +697,17 @@ class MetaTrader(MetaCore):
return res
async def history_deals_total(self, date_from: datetime | float, date_to: datetime | float) -> int:
"""Retrieves the total number of deals in trading history.
Args:
date_from: The starting date. Can be a datetime object or
Unix timestamp.
date_to: The ending date. Can be a datetime object or
Unix timestamp.
Returns:
int: The total number of deals in the specified date range.
"""
api = {
"func": self._history_deals_total,
"args": (date_from, date_to),
@@ -417,6 +724,22 @@ class MetaTrader(MetaCore):
ticket: int = None,
position: int = None,
) -> tuple[TradeDeal] | None:
"""Retrieves deals from trading history with optional filtering.
Args:
date_from: The starting date. Can be a datetime object or
Unix timestamp. Defaults to None.
date_to: The ending date. Can be a datetime object or
Unix timestamp. Defaults to None.
group: A filter for symbols by group name. Supports wildcards (*)
and exclusions (!). Defaults to empty string.
ticket: Deal ticket number to filter by. Defaults to None.
position: Position identifier to filter by. Defaults to None.
Returns:
tuple[TradeDeal] | None: A tuple of TradeDeal objects if
successful, None otherwise.
"""
kwargs = {key: value for key, value in (("group", group), ("ticket", ticket), ("position", position)) if value}
args = tuple(arg for arg in (date_from, date_to) if arg)
api = {
+39 -4
View File
@@ -507,7 +507,29 @@ class OrderCheckResult(Base):
margin_free: float
margin_level: float
comment: str
request: mt5.TradeRequest
request: TradeRequest
def __init__(self, **kwargs):
"""Initializes a new instance of the OrderCheckResult class.
Args:
**kwargs: Keyword arguments to set as instance attributes.
Only attributes that are annotated on the class body
will be set.
"""
req = kwargs.pop("request", {})
req = req._asdict() if isinstance(req, mt5.TradeRequest) else req
super().__init__(**kwargs)
self.request = TradeRequest(**req)
def __getstate__(self):
state = self.__dict__.copy()
state["request"] = state.pop('request').dict
return state
def __setstate__(self, state):
state['request'] = TradeRequest(**state['request'])
self.__dict__.update(state)
class OrderSendResult(Base):
@@ -536,19 +558,32 @@ class OrderSendResult(Base):
bid: float
ask: float
comment: str
request: mt5.TradeRequest
request: TradeRequest
request_id: int
retcode_external: int
profit: float = None
loss: float = None
def __init__(self, **kwargs):
"""Initializes a new instance of the OrderSendResult class.
Args:
**kwargs: Keyword arguments to set as instance attributes.
Only attributes that are annotated on the class body
will be set.
"""
req = kwargs.pop("request", {})
req = req._asdict() if isinstance(req, mt5.TradeRequest) else req
super().__init__(**kwargs)
self.request = TradeRequest(**req)
def __getstate__(self):
state = self.__dict__.copy()
state["request"] = state.pop('request')._asdict()
state["request"] = state.pop('request').dict
return state
def __setstate__(self, state):
state['request'] = mt5.TradeRequest(state['request'])
state['request'] = TradeRequest(**state['request'])
self.__dict__.update(state)
+250 -15
View File
@@ -1,3 +1,30 @@
"""State management module for persistent singleton key-value storage.
This module provides the State class, a singleton implementation of a
persistent dictionary-like storage backed by SQLite. The State class
implements the MutableMapping interface, providing dict-like access to
data that is automatically persisted to a database.
The entire state is stored as a single pickled dictionary in the database,
making it suitable for storing application-wide configuration and state
that needs to persist across sessions.
Example:
Basic usage::
from aiomql.core.state import State
# Initialize state (singleton - same instance returned each time)
state = State()
# Use like a dictionary
state['key'] = 'value'
print(state['key'])
# Persist changes to database
state.commit()
"""
import os
import pickle
import sqlite3
@@ -13,6 +40,32 @@ sqlite3.register_converter("pickle", pickle.loads)
sqlite3.register_adapter(dict, pickle.dumps)
class State(MutableMapping):
"""A singleton persistent key-value store backed by SQLite.
Implements the MutableMapping interface, providing dict-like access to
data that is automatically persisted to a SQLite database. The entire
state is stored as a single pickled dictionary, making it suitable for
application-wide state management.
This class uses the singleton pattern - all instances share the same
underlying data and database connection.
Attributes:
_data (ClassVar[dict]): The shared dictionary storing all state data.
_instance (Self): The singleton instance.
_lock (Lock): Thread lock for thread-safe operations.
db_name (str): Path to the SQLite database file.
autocommit (bool): If True, changes are committed immediately.
_initialized (bool): Whether the state has been initialized.
Example:
>>> state = State(db_name='app.db')
>>> state['user'] = {'name': 'John', 'id': 123}
>>> state.commit()
>>> print(state['user']['name'])
John
"""
_data: ClassVar
_instance: Self
_lock: Lock
@@ -21,6 +74,11 @@ class State(MutableMapping):
_initialized = False
def __new__(cls, *args, **kwargs):
"""Creates or returns the singleton instance.
Returns:
State: The singleton State instance.
"""
with (lock := Lock()) as _:
if not hasattr(cls, '_instance'):
cls._data = {}
@@ -28,47 +86,132 @@ class State(MutableMapping):
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, db_name: str | Path = "", data: dict = None, flush: bool = False, autocommit: bool = False):
def __init__(self, db_name: str | Path = "", data: dict = None, flush: bool = False, autocommit: bool = True):
"""Initializes the State instance.
Args:
db_name: Path to the SQLite database file. Defaults to the
DB_NAME environment variable or 'db.sqlite3'.
data: Initial data to merge into the state. Defaults to None.
flush: If True, clears existing data and replaces with provided
data. Defaults to False.
autocommit: If True, commits changes immediately after each
modification. Defaults to False.
"""
with self._lock:
self.autocommit = autocommit
self.init(data=data, flush=flush, db_name=db_name)
@property
def data(self):
"""Returns the shared state dictionary.
Returns:
dict: The state data dictionary.
"""
return self.__class__._data
@data.setter
def data(self, value):
"""Sets the state dictionary.
Args:
value: The dictionary to set as state data.
Raises:
AssertionError: If value is not a dictionary.
"""
assert isinstance(value, dict)
self.__class__._data = value
def __repr__(self):
"""Returns a string representation of the state.
Returns:
str: String representation of the state dictionary.
"""
return repr(self.data)
def __iter__(self):
"""Returns an iterator over the state keys.
Returns:
Iterator: An iterator yielding state keys.
"""
return iter(self.data)
def __len__(self):
"""Returns the number of items in the state.
Returns:
int: The number of key-value pairs in the state.
"""
return len(self.data)
def __contains__(self, key):
"""Checks if a key exists in the state.
Args:
key: The key to check for.
Returns:
bool: True if the key exists, False otherwise.
"""
return key in self.data
def __setitem__(self, key, value):
"""Sets a value for the given key.
Args:
key: The key to set.
value: The value to associate with the key.
"""
self.data[key] = value
if self.autocommit:
self.commit()
def __getitem__(self, key):
"""Retrieves a value by its key.
Args:
key: The key to look up.
Returns:
The value associated with the key.
Raises:
KeyError: If the key does not exist.
"""
value = self.data[key]
return value
def __delitem__(self, key):
"""Deletes a key-value pair from the state.
Args:
key: The key to delete.
Raises:
KeyError: If the key does not exist.
"""
del self.data[key]
if self.autocommit:
self.commit()
def pop(self, key, default=SENTINEL):
"""Removes and returns the value for the given key.
Args:
key: The key to remove.
default: The value to return if the key doesn't exist.
If not provided and key doesn't exist, raises KeyError.
Returns:
The value that was associated with the key.
Raises:
KeyError: If the key doesn't exist and no default is provided.
"""
if default is SENTINEL:
value = self.data.pop(key)
else:
@@ -78,57 +221,134 @@ class State(MutableMapping):
return value
def get(self, key, default=None):
"""Returns the value for key if it exists, otherwise returns default.
Args:
key: The key to look up.
default: The value to return if the key doesn't exist.
Defaults to None.
Returns:
The value associated with the key, or default if not found.
"""
return self.data.get(key, default)
def update(self, data: MutableMapping | Iterable[Iterable[Any]] = None, /, **kwargs):
"""Updates the state with multiple key-value pairs.
Args:
data: A mapping or iterable of key-value pairs to add.
**kwargs: Additional key-value pairs to add.
"""
self.data.update(data, **kwargs)
if self.autocommit:
self.commit()
def setdefault(self, key, default = None, /):
"""Returns the value for key if it exists, otherwise sets and returns default.
Args:
key: The key to look up or set.
default: The value to set if the key doesn't exist. Defaults to None.
Returns:
The existing value if the key exists, otherwise the default value.
"""
value = self.data.setdefault(key, default)
if self.autocommit:
self.commit()
return value
def keys(self):
"""Returns a view of the state's keys.
Returns:
dict_keys: A view of the keys in the state.
"""
return self.data.keys()
def values(self):
"""Returns a view of the state's values.
Returns:
dict_values: A view of the values in the state.
"""
return self.data.values()
def items(self):
"""Returns a view of the state's key-value pairs.
Returns:
dict_items: A view of the (key, value) pairs in the state.
"""
return self.data.items()
def load(self, *, conn = None, data: dict = None):
"""Loads state data from the database.
Retrieves the persisted state from the database and merges it with
any provided data. The merged result is then committed back.
Args:
conn: An existing database connection to use. If None, a new
connection is created.
data: Additional data to merge into the loaded state.
"""
try:
conn = conn or self.conn
res = conn.execute("SELECT value FROM state where key = 'data'").fetchone()
db_data = pickle.loads(res[0]) if res else {}
db_data |= (data or {})
self.update(db_data)
conn.close()
self.commit(conn=conn)
except Exception as err:
logger.error("%s: Failed to load data from database", err)
logger.error("%s: Failed to load state data from database", err)
def init(self, data: dict = None, flush: bool = False, db_name: str = ""):
if not self._initialized:
self.db_name = db_name or os.environ.get("DB_NAME", "db.sqlite3")
conn = self.conn
conn.execute("CREATE TABLE IF NOT EXISTS state (key text unique, value blob)")
self._initialized = True
if flush:
self.data = data or {}
self.commit(conn=conn)
else:
self.load(conn=conn, data=data)
return
"""Initializes the state with the database.
Creates the state table if it doesn't exist and loads or flushes
the data based on the flush parameter.
Args:
data: Initial data to populate the state with.
flush: If True, clears existing data and uses only provided data.
If False, loads existing data and merges with provided data.
db_name: Path to the SQLite database file.
"""
if self._initialized:
return
self.db_name = db_name or os.environ.get("DB_NAME", "db.sqlite3")
conn = self.conn
conn.execute("CREATE TABLE IF NOT EXISTS state (key text unique, value blob)")
self._initialized = True
if flush:
self.data = data or {}
self.commit()
self.commit(conn=conn)
else:
self.load(conn=conn, data=data)
def flush(self, data: dict = None):
"""Clears the state and optionally sets new data.
Args:
data: New data to set after clearing. Defaults to empty dict.
"""
self.data = data or {}
self.commit()
def commit(self, *, conn: sqlite3.Connection = None, close: bool = True):
"""Commits the current state to the database.
Serializes the state dictionary using pickle and saves it to the
SQLite database.
Args:
conn: An existing database connection to use. If None, a new
connection is created.
close: If True, closes the connection after committing.
Defaults to True.
"""
value = pickle.dumps(self.data, protocol=pickle.HIGHEST_PROTOCOL)
value = sqlite3.Binary(value)
conn = conn or self.conn
@@ -139,9 +359,24 @@ class State(MutableMapping):
@property
def conn(self):
"""Creates and returns a new database connection.
Returns:
sqlite3.Connection: A new connection to the state database.
"""
return sqlite3.connect(self.db_name)
async def acommit(self, conn=None, close=True):
"""Asynchronously commits the current state to the database.
This is a convenience wrapper for async contexts.
Args:
conn: An existing database connection to use. If None, a new
connection is created.
close: If True, closes the connection after committing.
Defaults to True.
"""
conn = conn or self.conn
value = pickle.dumps(self.data, protocol=pickle.HIGHEST_PROTOCOL)
value = sqlite3.Binary(value)
+253 -26
View File
@@ -1,5 +1,35 @@
"""Store module for persistent key-value storage backed by SQLite.
This module provides the Store class, a persistent dictionary-like storage
backed by SQLite. Unlike the State class which stores all data as a single
pickled dictionary, Store saves each key-value pair as individual rows in
the database table, making it more suitable for larger datasets.
The Store class implements the MutableMapping interface, providing dict-like
access to data that is automatically persisted to the database.
Example:
Basic usage::
from aiomql.core.store import Store
# Initialize store
store = Store(db_name='app.db', table_name='settings')
# Use like a dictionary
store['api_key'] = 'your-api-key'
store['timeout'] = 30
# Data is persisted automatically (autocommit=True by default)
print(store['api_key'])
# Get all data as a dictionary
all_data = store.data
"""
import os
import sqlite3
from threading import Lock
from typing import MutableMapping, Iterable, Any
from logging import getLogger
from pathlib import Path
@@ -10,89 +40,247 @@ logger = getLogger(__name__)
class Store(MutableMapping):
"""A persistent key-value store backed by SQLite.
Implements the MutableMapping interface, allowing dict-like access to
data that is automatically persisted to a SQLite database.
Attributes:
autocommit: If True, changes are committed immediately after each
modification.
db_name: Path to the SQLite database file.
table_name: Name of the table used for storage.
cursor: SQLite cursor for executing queries.
conn: SQLite database connection.
"""
autocommit: bool
db_name: str
table_name: str
cursor: sqlite3.Cursor
_conn: sqlite3.Connection = None
conn: sqlite3.Connection
lock: Lock = Lock()
def __init__(self, db_name: str | Path = "", table_name="store", data: dict = None, flush: bool = False, autocommit: bool = True):
"""Initializes the Store with a SQLite database.
def __init__(self, db_name: str | Path = "", data: dict = None, flush: bool = False, autocommit: bool = True):
Args:
db_name: Path to the SQLite database file. Defaults to the
DB_NAME environment variable or 'db.sqlite3'.
table_name: Name of the table to use for storage.
Defaults to 'store'.
data: Initial data to populate the store with.
Defaults to None.
flush: If True, clears existing data before loading new data.
Defaults to False.
autocommit: If True, commits changes immediately after each
modification. Defaults to True.
"""
self.autocommit = autocommit
self.db_name = db_name or os.environ.get("DB_NAME", "db.sqlite3")
self.conn = sqlite3.connect(self.db_name, check_same_thread=False)
self.table_name = table_name
if not self.autocommit:
self.conn = self.connection(db_name)
else:
self.conn = sqlite3.connect(self.db_name, check_same_thread=False)
self.cursor = self.conn.cursor()
self.conn.execute("CREATE TABLE IF NOT EXISTS store (key unique, value)")
make_table_query = f"CREATE TABLE IF NOT EXISTS {self.table_name} (key unique, value)"
self.conn.execute(make_table_query)
if flush:
self.conn.execute("DELETE FROM store")
self.commit()
flush_query = f"DELETE FROM {self.table_name}"
self.conn.execute(flush_query)
self.conn.commit()
if data:
self.conn.executemany("REPLACE INTO store VALUES(?, ?)", data.items())
self.commit()
load_query = f"REPLACE INTO {self.table_name} VALUES(?, ?)"
self.conn.executemany(load_query, data.items())
self.conn.commit()
@classmethod
def connection(cls, db_name: str | Path = ""):
if cls._conn is not None:
return cls._conn
cls._conn = sqlite3.connect(db_name, check_same_thread=False)
return cls._conn
def __len__(self):
rows = self.cursor.execute('SELECT COUNT(*) FROM store').fetchone()[0]
"""Returns the number of items in the store.
Returns:
int: The total count of key-value pairs in the store.
"""
count_query = f"SELECT COUNT(*) FROM {self.table_name}"
rows = self.cursor.execute(count_query).fetchone()[0]
return rows if rows is not None else 0
def __contains__(self, key):
return self.cursor.execute('SELECT 1 FROM store WHERE key = ?', (key,)).fetchone() is not None
"""Checks if a key exists in the store.
Args:
key: The key to check for.
Returns:
bool: True if the key exists, False otherwise.
"""
contains_query = f"SELECT 1 FROM {self.table_name} WHERE key = ?"
return self.cursor.execute(contains_query, (key,)).fetchone() is not None
def __getitem__(self, key):
item = self.cursor.execute('SELECT value FROM store WHERE key = ?', (key,)).fetchone()
"""Retrieves a value by its key.
Args:
key: The key to look up.
Returns:
The value associated with the key.
Raises:
KeyError: If the key does not exist in the store.
"""
get_query = f"SELECT value FROM {self.table_name} WHERE key = ?"
item = self.cursor.execute(get_query, (key,)).fetchone()
if item is None:
raise KeyError(key)
return item[0]
def __setitem__(self, key, value):
self.cursor.execute('REPLACE INTO store (key, value) VALUES (?,?)', (key, value))
"""Sets a value for the given key.
If the key already exists, its value is replaced.
Args:
key: The key to set.
value: The value to associate with the key.
"""
set_query = f"REPLACE INTO {self.table_name} VALUES (?, ?)"
self.cursor.execute(set_query, (key, value))
if self.autocommit:
self.conn.commit()
def __delitem__(self, key):
"""Deletes a key-value pair from the store.
Args:
key: The key to delete.
Raises:
KeyError: If the key does not exist in the store.
"""
if key not in self:
raise KeyError(key)
self.cursor.execute('DELETE FROM store WHERE key = ?', (key,))
delete_query = f"DELETE FROM {self.table_name} WHERE key = ?"
self.cursor.execute(delete_query, (key,))
if self.autocommit:
self.commit()
self.conn.commit()
def __iter__(self):
"""Returns an iterator over the keys in the store.
Returns:
Iterator: An iterator yielding keys.
"""
return self.iterkeys()
def __repr__(self):
"""Returns a string representation of the store.
Returns:
str: A string representation of the Store instance.
"""
return f"{self.__class__.__name__}()"
def iterkeys(self):
for row in self.cursor.execute('SELECT key FROM store'):
"""Yields keys from the store one at a time.
Yields:
Keys stored in the database.
"""
key_query = f"SELECT key FROM {self.table_name}"
for row in self.cursor.execute(key_query):
yield row[0]
def itervalues(self):
for row in self.cursor.execute('SELECT value FROM store'):
"""Yields values from the store one at a time.
Yields:
Values stored in the database.
"""
value_query = f"SELECT value FROM {self.table_name}"
for row in self.cursor.execute(value_query):
yield row[0]
def iteritems(self):
for row in self.cursor.execute('SELECT key, value FROM store'):
"""Yields key-value pairs from the store one at a time.
Yields:
tuple: A (key, value) pair.
"""
item_query = f"SELECT key, value FROM {self.table_name}"
for row in self.cursor.execute(item_query):
yield row[0], row[1]
def keys(self):
"""Returns a list of all keys in the store.
Returns:
list: A list containing all keys.
"""
return list(self.iterkeys())
def values(self):
"""Returns a list of all values in the store.
Returns:
list: A list containing all values.
"""
return list(self.itervalues())
def items(self):
"""Returns a list of all key-value pairs in the store.
Returns:
list[tuple]: A list of (key, value) tuples.
"""
return list(self.iteritems())
def update(self, data: MutableMapping | Iterable[Iterable[Any]] = None, /, **kwargs):
"""Updates the store with multiple key-value pairs.
Args:
data: A mapping or iterable of key-value pairs to add.
**kwargs: Additional key-value pairs to add.
"""
data = (dict(data if data is not None else {}) or {}) | kwargs
self.cursor.executemany("REPLACE INTO store VALUES(?, ?)", data.items())
update_query = f"REPLACE INTO {self.table_name} VALUES(?, ?)"
self.conn.executemany(update_query, data.items())
if self.autocommit:
self.commit()
self.conn.commit()
def setdefault(self, key, default = None, /):
"""Returns the value for key if it exists, otherwise sets and returns default.
Args:
key: The key to look up or set.
default: The value to set if the key doesn't exist. Defaults to None.
Returns:
The existing value if the key exists, otherwise the default value.
"""
if key in self:
return self[key]
self[key] = default
return self[key]
def get(self, key, /, default=None):
"""Returns the value for key if it exists, otherwise returns default.
Args:
key: The key to look up.
default: The value to return if the key doesn't exist.
Defaults to None.
Returns:
The value associated with the key, or default if not found.
"""
try:
value = self[key]
return value
@@ -100,11 +288,26 @@ class Store(MutableMapping):
return default
def clear(self):
self.cursor.execute("DELETE FROM store")
"""Removes all key-value pairs from the store."""
clear_query = f"DELETE FROM {self.table_name}"
self.cursor.execute(clear_query)
if self.autocommit:
self.commit()
self.conn.commit()
def pop(self, key, /, default=SENTINEL):
"""Removes and returns the value for the given key.
Args:
key: The key to remove.
default: The value to return if the key doesn't exist.
If not provided and key doesn't exist, raises KeyError.
Returns:
The value that was associated with the key.
Raises:
KeyError: If the key doesn't exist and no default is provided.
"""
try:
value = self[key]
del self[key]
@@ -114,16 +317,40 @@ class Store(MutableMapping):
raise err
return default
def commit(self, conn: sqlite3.Connection = None, close: bool = False):
conn = conn or self.conn
conn.commit()
if close:
conn.close()
@classmethod
def commit(cls, conn: sqlite3.Connection = None, close: bool = False):
"""Commits pending changes to the database.
Args:
conn: The database connection to use. Defaults to self.conn.
close: If True, closes the connection after committing.
Defaults to False.
"""
with cls.lock:
conn = conn or cls.connection()
conn.commit()
if close:
conn.close()
async def acommit(self, conn: sqlite3.Connection = None, close: bool = False):
"""Asynchronously commits pending changes to the database.
This is a convenience wrapper around commit() for async contexts.
Args:
conn: The database connection to use. Defaults to self.conn.
close: If True, closes the connection after committing.
Defaults to False.
"""
self.commit(conn=conn, close=close)
@property
def data(self):
res = self.cursor.execute("SELECT * FROM store").fetchall()
"""Returns all stored data as a dictionary.
Returns:
dict: A dictionary containing all key-value pairs in the store.
"""
select_query = f"SELECT * FROM {self.table_name}"
res = self.cursor.execute(select_query).fetchall()
return {key: value for key, value in res}
+1 -1
View File
@@ -1 +1 @@
from .meta_trader import MetaTrader
from .meta_trader import MetaTrader as MetaTraderSync
+342 -11
View File
@@ -27,13 +27,17 @@ logger = getLogger()
class MetaTrader(MetaCore):
def __new__(cls, *args, **kwargs):
if not hasattr(cls, "config"):
cls.config = Config()
return super().__new__(cls)
def __init__(self):
self.config = Config()
self.error: Error = Error(code=1)
def __enter__(self) -> Self:
"""
Async context manager entry point.
Context manager entry point.
Initializes the connection to the MetaTrader terminal.
Returns:
@@ -45,27 +49,48 @@ class MetaTrader(MetaCore):
def __exit__(self, exc_type, exc_val, exc_tb):
"""
Async context manager exit point. Closes the connection to the MetaTrader terminal.
Context manager exit point. Closes the connection to the MetaTrader terminal.
"""
self.shutdown()
def _handler(self, api: dict, retries=3):
"""Handles API calls to the MetaTrader terminal with retry logic.
Executes the specified function and handles connection errors by
retrying the initialization and login process.
Args:
api: A dictionary containing:
- func: The function to execute.
- args: Optional tuple of positional arguments.
- kwargs: Optional dictionary of keyword arguments.
- error_msg: Optional error message for logging.
retries: Number of retry attempts for connection errors.
Defaults to 3.
Returns:
The result of the API call, or None if the call failed.
"""
func = api["func"]
args = api.get("args", ())
kwargs = api.get("kwargs", {})
error_msg = api.get("error_msg", "An error occurred")
res = func(*args, **kwargs)
error_msg = api.get("error_msg", f"An error occurred in {func.__name__} of {self.__class__.__name__}")
if func.__name__ in ("order_send", "order_check"):
res = func(args[0])
else:
res = func(*args, **kwargs)
if res is not None:
return res
if res is None and self.error.is_connection_error() and retries > 0:
err = self.last_error()
self.error = Error(*err)
if self.error.is_connection_error() and retries > 0:
self.initialize()
self.login()
return self._handler(api, retries=retries - 1)
else:
err = self.last_error()
self.error = Error(*err)
logger.warning(f"{error_msg}:{self.error.description}")
return res
@@ -123,36 +148,84 @@ class MetaTrader(MetaCore):
self._shutdown()
def last_error(self) -> tuple[int, str]:
"""Retrieves the last error information from the MetaTrader terminal.
Returns:
tuple[int, str]: A tuple containing the error code and error
description. Returns (-1, error_message) if an exception
occurs while retrieving the error.
"""
try:
return self.last_error()
return self._last_error()
except Exception as err:
logger.warning("%s: Error in obtaining last error.", err)
return -1, str(err)
def version(self) -> tuple[int, int, str] | None:
""""""
"""Retrieves the MetaTrader terminal version information.
Returns:
tuple[int, int, str] | None: A tuple containing (version, build,
release_date) if successful, None otherwise.
"""
api = {"func": self._version, "error_msg": "Error in obtaining version."}
return self._handler(api)
def account_info(self) -> AccountInfo | None:
""""""
"""Retrieves information about the current trading account.
Returns:
AccountInfo | None: An AccountInfo object containing account
details if successful, None otherwise.
"""
api = {"func": self._account_info, "error_msg": "Error in obtaining account information"}
return self._handler(api)
def terminal_info(self) -> TerminalInfo | None:
"""Retrieves information about the MetaTrader terminal.
Returns:
TerminalInfo | None: A TerminalInfo object containing terminal
details if successful, None otherwise.
"""
api = {"func": self._terminal_info, "error_msg": "Error in obtaining terminal information"}
return self._handler(api)
def symbols_total(self) -> int:
"""Retrieves the total number of financial symbols available.
Returns:
int: The total number of symbols available in the terminal.
"""
api = {"func": self._symbols_total, "error_msg": "Error in obtaining total symbols."}
return self._handler(api)
def symbols_get(self, group: str = "") -> tuple[SymbolInfo] | None:
"""Retrieves all financial symbols or symbols matching a filter.
Args:
group: A filter for selecting symbols by group name. Supports
wildcards (*) and exclusions (!). Defaults to empty string
which returns all symbols.
Returns:
tuple[SymbolInfo] | None: A tuple of SymbolInfo objects if
successful, None otherwise.
"""
kwargs = {"group": group} if group else {}
api = {"func": self._symbols_get, "kwargs": kwargs, "error_msg": "Error in obtaining symbols."}
return self._handler(api)
def symbol_info(self, symbol: str) -> SymbolInfo | None:
"""Retrieves information about a specific financial symbol.
Args:
symbol: The name of the financial symbol.
Returns:
SymbolInfo | None: A SymbolInfo object containing symbol details
if successful, None otherwise.
"""
api = {
"func": self._symbol_info,
"args": (symbol,),
@@ -161,14 +234,41 @@ class MetaTrader(MetaCore):
return self._handler(api)
def symbol_info_tick(self, symbol: str) -> Tick | None:
"""Retrieves the last tick data for a specified symbol.
Args:
symbol: The name of the financial symbol.
Returns:
Tick | None: A Tick object containing the last tick data
if successful, None otherwise.
"""
api = {"func": self._symbol_info_tick, "args": (symbol,), "error_msg": f"Error in obtaining tick for {symbol}"}
return self._handler(api)
def symbol_select(self, symbol: str, enable: bool) -> bool:
"""Selects or removes a symbol from the MarketWatch window.
Args:
symbol: The name of the financial symbol.
enable: If True, the symbol is selected in MarketWatch.
If False, the symbol is removed.
Returns:
bool: True if successful, False otherwise.
"""
api = {"func": self._symbol_select, "args": (symbol, enable), "error_msg": f"Error in selecting {symbol}"}
return self._handler(api)
def market_book_add(self, symbol: str) -> bool:
"""Subscribes to the market depth (order book) for a symbol.
Args:
symbol: The name of the financial symbol.
Returns:
bool: True if subscription was successful, False otherwise.
"""
api = {
"func": self._market_book_add,
"args": (symbol,),
@@ -177,6 +277,17 @@ class MetaTrader(MetaCore):
return self._handler(api)
def market_book_get(self, symbol: str) -> tuple[BookInfo] | None:
"""Retrieves the market depth (order book) data for a symbol.
The symbol must first be subscribed using market_book_add().
Args:
symbol: The name of the financial symbol.
Returns:
tuple[BookInfo] | None: A tuple of BookInfo objects representing
the order book entries if successful, None otherwise.
"""
api = {
"func": self._market_book_get,
"args": (symbol,),
@@ -185,6 +296,14 @@ class MetaTrader(MetaCore):
return self._handler(api)
def market_book_release(self, symbol: str) -> bool:
"""Unsubscribes from the market depth (order book) for a symbol.
Args:
symbol: The name of the financial symbol.
Returns:
bool: True if unsubscription was successful, False otherwise.
"""
api = {
"func": self._market_book_release,
"args": (symbol,),
@@ -195,6 +314,20 @@ class MetaTrader(MetaCore):
def copy_rates_from(
self, symbol: str, timeframe: int, date_from: datetime | float, count: int
) -> np.ndarray | None:
"""Copies price history (bars/candles) starting from a specified date.
Args:
symbol: The name of the financial symbol.
timeframe: The chart timeframe as a TIMEFRAME constant.
date_from: The starting date for the data request. Can be a
datetime object or a Unix timestamp.
count: The number of bars to retrieve.
Returns:
np.ndarray | None: A numpy array with columns (time, open, high,
low, close, tick_volume, spread, real_volume) if successful,
None otherwise.
"""
api = {
"func": self._copy_rates_from,
"args": (symbol, timeframe, date_from, count),
@@ -203,6 +336,19 @@ class MetaTrader(MetaCore):
return self._handler(api)
def copy_rates_from_pos(self, symbol: str, timeframe: int, start_pos: int, count: int) -> np.ndarray | None:
"""Copies price history (bars/candles) starting from a specified index.
Args:
symbol: The name of the financial symbol.
timeframe: The chart timeframe as a TIMEFRAME constant.
start_pos: The starting index position (0 is the current bar).
count: The number of bars to retrieve.
Returns:
np.ndarray | None: A numpy array with columns (time, open, high,
low, close, tick_volume, spread, real_volume) if successful,
None otherwise.
"""
api = {
"func": self._copy_rates_from_pos,
"args": (symbol, timeframe, start_pos, count),
@@ -213,6 +359,21 @@ class MetaTrader(MetaCore):
def copy_rates_range(
self, symbol: str, timeframe: int, date_from: datetime | float, date_to: datetime | float
) -> np.ndarray | None:
"""Copies price history (bars/candles) within a specified date range.
Args:
symbol: The name of the financial symbol.
timeframe: The chart timeframe as a TIMEFRAME constant.
date_from: The starting date. Can be a datetime object or
Unix timestamp.
date_to: The ending date. Can be a datetime object or
Unix timestamp.
Returns:
np.ndarray | None: A numpy array with columns (time, open, high,
low, close, tick_volume, spread, real_volume) if successful,
None otherwise.
"""
api = {
"func": self._copy_rates_range,
"args": (symbol, timeframe, date_from, date_to),
@@ -223,6 +384,20 @@ class MetaTrader(MetaCore):
def copy_ticks_from(
self, symbol: str, date_from: datetime | float, count: int, flags: CopyTicks
) -> np.ndarray | None:
"""Copies tick data starting from a specified date.
Args:
symbol: The name of the financial symbol.
date_from: The starting date. Can be a datetime object or
Unix timestamp.
count: The number of ticks to retrieve.
flags: A CopyTicks flag specifying the type of ticks to copy
(ALL, INFO, or TRADE).
Returns:
np.ndarray | None: A numpy array with tick data if successful,
None otherwise.
"""
api = {
"func": self._copy_ticks_from,
"args": (symbol, date_from, count, flags),
@@ -233,6 +408,21 @@ class MetaTrader(MetaCore):
def copy_ticks_range(
self, symbol: str, date_from: datetime | float, date_to: datetime | float, flags: CopyTicks
) -> np.ndarray | None:
"""Copies tick data within a specified date range.
Args:
symbol: The name of the financial symbol.
date_from: The starting date. Can be a datetime object or
Unix timestamp.
date_to: The ending date. Can be a datetime object or
Unix timestamp.
flags: A CopyTicks flag specifying the type of ticks to copy
(ALL, INFO, or TRADE).
Returns:
np.ndarray | None: A numpy array with tick data if successful,
None otherwise.
"""
api = {
"func": self._copy_ticks_range,
"args": (symbol, date_from, date_to, flags),
@@ -241,10 +431,27 @@ class MetaTrader(MetaCore):
return self._handler(api)
def orders_total(self) -> int:
"""Retrieves the total number of active pending orders.
Returns:
int: The total number of active pending orders.
"""
api = {"func": self._orders_total, "error_msg": "Error in obtaining total orders."}
return self._handler(api)
def orders_get(self, group: str = "", ticket: int = 0, symbol: str = "") -> tuple[TradeOrder] | None:
"""Retrieves active pending orders with optional filtering.
Args:
group: A filter for symbols by group name. Supports wildcards (*)
and exclusions (!). Defaults to empty string.
ticket: Order ticket number to filter by. Defaults to 0.
symbol: Symbol name to filter by. Defaults to empty string.
Returns:
tuple[TradeOrder] | None: A tuple of TradeOrder objects if
successful, None otherwise.
"""
kwargs = {key: value for key, value in (("group", group), ("ticket", ticket), ("symbol", symbol)) if value}
api = {"func": self._orders_get, "kwargs": kwargs, "error_msg": "Error in obtaining orders."}
return self._handler(api)
@@ -252,6 +459,18 @@ class MetaTrader(MetaCore):
def order_calc_margin(
self, action: Literal[OrderType.BUY, OrderType.SELL], symbol: str, volume: float, price: float
) -> float | None:
"""Calculates the margin required for a specified order.
Args:
action: The order type (OrderType.BUY or OrderType.SELL).
symbol: The name of the financial symbol.
volume: The trade volume in lots.
price: The open price.
Returns:
float | None: The required margin in the account currency if
successful, None otherwise.
"""
api = {
"func": self._order_calc_margin,
"args": (action, symbol, volume, price),
@@ -267,6 +486,19 @@ class MetaTrader(MetaCore):
price_open: float,
price_close: float,
) -> float | None:
"""Calculates the profit/loss for a specified order.
Args:
action: The order type (OrderType.BUY or OrderType.SELL).
symbol: The name of the financial symbol.
volume: The trade volume in lots.
price_open: The open price.
price_close: The close price.
Returns:
float | None: The calculated profit/loss in the account currency
if successful, None otherwise.
"""
api = {
"func": self._order_calc_profit,
"args": (action, symbol, volume, price_open, price_close),
@@ -275,23 +507,79 @@ class MetaTrader(MetaCore):
return self._handler(api)
def order_check(self, request: dict) -> OrderCheckResult:
"""Checks if the funds are sufficient for a specified order.
Args:
request: A dictionary containing the order parameters. Required
keys include action, symbol, volume, type, price, etc.
Returns:
OrderCheckResult: The result of the order check containing
information about margin, balance, and trade validity.
"""
comment = request.get("comment")
if comment is not None and len(comment) > 25:
request["comment"] = comment[:25]
logger.warning("order comment length exceeds 25 characters.")
api = {"func": self._order_check, "args": (request,), "error_msg": "Error in checking order."}
return self._handler(api)
def order_send(self, request: dict) -> OrderSendResult:
"""Sends a trade request to the MetaTrader terminal.
Args:
request: A dictionary containing the trade request parameters.
Required keys include action, symbol, volume, type, price, etc.
Returns:
OrderSendResult: The result of the trade request containing
the order ticket, deal ticket, and execution details.
"""
comment = request.get("comment")
if comment is not None and len(comment) > 25:
request["comment"] = comment[:25]
logger.warning("order comment length exceeds 25 characters.")
api = {"func": self._order_send, "args": (request,), "error_msg": "Error in sending order."}
return self._handler(api)
def positions_total(self) -> int:
"""Retrieves the total number of open positions.
Returns:
int: The total number of open positions.
"""
api = {"func": self._positions_total, "error_msg": "Error in obtaining total positions."}
return self._handler(api)
def positions_get(self, group: str = "", ticket: int = None, symbol: str = "") -> tuple[TradePosition] | None:
"""Retrieves open positions with optional filtering.
Args:
group: A filter for symbols by group name. Supports wildcards (*)
and exclusions (!). Defaults to empty string.
ticket: Position ticket number to filter by. Defaults to None.
symbol: Symbol name to filter by. Defaults to empty string.
Returns:
tuple[TradePosition] | None: A tuple of TradePosition objects if
successful, None otherwise.
"""
kwargs = {key: value for key, value in (("group", group), ("ticket", ticket), ("symbol", symbol)) if value}
api = {"func": self._positions_get, "kwargs": kwargs, "error_msg": "Error in obtaining open positions."}
return self._handler(api)
def history_orders_total(self, date_from: datetime | float, date_to: datetime | float) -> int:
"""Retrieves the total number of orders in trading history.
Args:
date_from: The starting date. Can be a datetime object or
Unix timestamp.
date_to: The ending date. Can be a datetime object or
Unix timestamp.
Returns:
int: The total number of orders in the specified date range.
"""
api = {
"func": self._history_orders_total,
"args": (date_from, date_to),
@@ -307,6 +595,22 @@ class MetaTrader(MetaCore):
ticket: int = None,
position: int = None,
) -> tuple[TradeOrder] | None:
"""Retrieves orders from trading history with optional filtering.
Args:
date_from: The starting date. Can be a datetime object or
Unix timestamp. Defaults to None.
date_to: The ending date. Can be a datetime object or
Unix timestamp. Defaults to None.
group: A filter for symbols by group name. Supports wildcards (*)
and exclusions (!). Defaults to empty string.
ticket: Order ticket number to filter by. Defaults to None.
position: Position identifier to filter by. Defaults to None.
Returns:
tuple[TradeOrder] | None: A tuple of TradeOrder objects if
successful, None otherwise.
"""
kwargs = {key: value for key, value in (("group", group), ("ticket", ticket), ("position", position)) if value}
args = tuple(arg for arg in (date_from, date_to) if arg)
api = {
@@ -318,6 +622,17 @@ class MetaTrader(MetaCore):
return self._handler(api)
def history_deals_total(self, date_from: datetime | float, date_to: datetime | float) -> int:
"""Retrieves the total number of deals in trading history.
Args:
date_from: The starting date. Can be a datetime object or
Unix timestamp.
date_to: The ending date. Can be a datetime object or
Unix timestamp.
Returns:
int: The total number of deals in the specified date range.
"""
api = {
"func": self._history_deals_total,
"args": (date_from, date_to),
@@ -333,6 +648,22 @@ class MetaTrader(MetaCore):
ticket: int = None,
position: int = None,
) -> tuple[TradeDeal] | None:
"""Retrieves deals from trading history with optional filtering.
Args:
date_from: The starting date. Can be a datetime object or
Unix timestamp. Defaults to None.
date_to: The ending date. Can be a datetime object or
Unix timestamp. Defaults to None.
group: A filter for symbols by group name. Supports wildcards (*)
and exclusions (!). Defaults to empty string.
ticket: Deal ticket number to filter by. Defaults to None.
position: Position identifier to filter by. Defaults to None.
Returns:
tuple[TradeDeal] | None: A tuple of TradeDeal objects if
successful, None otherwise.
"""
kwargs = {key: value for key, value in (("group", group), ("ticket", ticket), ("position", position)) if value}
args = tuple(arg for arg in (date_from, date_to) if arg)
api = {
+146 -36
View File
@@ -1,3 +1,26 @@
"""Task queue module for managing asynchronous task execution.
This module provides classes for creating and managing asynchronous task queues.
It supports priority-based task scheduling, worker management, and both finite
and infinite queue modes.
Classes:
QueueItem: Represents a task item in the queue.
TaskQueue: A wrapper around asyncio Queue for managing task execution.
Example:
Basic usage::
from aiomql.core.task_queue import TaskQueue
async def my_task(x):
print(f"Processing {x}")
queue = TaskQueue()
queue.add_task(my_task, 42, priority=1)
await queue.run()
"""
import asyncio
import time
import random
@@ -7,42 +30,94 @@ from functools import partial
logger = getLogger(__name__)
class QueueItem:
"""A class to represent a task item in the queue.
"""Represents a task item in the queue.
Attributes:
- `task` (Callable | Coroutine): The task to run.
Wraps a callable or coroutine with its arguments for deferred execution.
Supports comparison operations for priority queue ordering.
- `args` (tuple): The arguments to pass to the task
Attributes:
task (Callable | Coroutine): The task to execute.
args (tuple): Positional arguments for the task.
kwargs (dict): Keyword arguments for the task.
time (int): Timestamp when the task was created (nanoseconds).
must_complete (bool): If True, task must complete even when queue stops.
- `kwargs` (dict): The keyword arguments to pass to the task
- `must_complete` (bool): A flag to indicate if the task must complete before the queue stops. Default is False.
- `time` (int): The time the task was added to the queue.
Example:
>>> async def my_task(x): return x * 2
>>> item = QueueItem(my_task, 5)
>>> await item() # Returns 10
"""
must_complete: bool
def __init__(self, task: Callable | Coroutine, /, *args, **kwargs):
"""Initializes a QueueItem.
Args:
task: The callable or coroutine to execute.
*args: Positional arguments to pass to the task.
**kwargs: Keyword arguments to pass to the task.
"""
self.task = task
self.args = args
self.kwargs = kwargs
self.time = time.time_ns()
def __hash__(self):
"""Returns the hash of the item based on creation time.
Returns:
int: The creation timestamp as hash.
"""
return self.time
def __lt__(self, other):
"""Compares items by creation time (less than).
Args:
other: Another QueueItem to compare.
Returns:
bool: True if this item was created before other.
"""
return self.time < other.time
def __eq__(self, other):
"""Compares items by creation time (equality).
Args:
other: Another QueueItem to compare.
Returns:
bool: True if items were created at the same time.
"""
return self.time == other.time
def __le__(self, other):
"""Compares items by creation time (less than or equal).
Args:
other: Another QueueItem to compare.
Returns:
bool: True if this item was created before or at same time.
"""
return self.time <= other.time
async def __call__(self):
"""Executes the wrapped task.
Handles both coroutine functions and regular callables.
Regular callables are run in a thread executor.
Returns:
The result of the task execution.
Raises:
asyncio.CancelledError: If the task was cancelled.
Exception: Any exception raised by the task.
"""
try:
if asyncio.iscoroutinefunction(self.task):
return await self.task(*self.args, **self.kwargs)
@@ -56,25 +131,48 @@ class QueueItem:
except Exception as err:
logger.error("Error %s occurred in %s", err, self.task.__name__)
class TaskQueue:
"""A wrapper around asyncio Queue for managing asynchronous task execution.
Provides priority-based task scheduling, worker management, timeout handling,
and support for both finite and infinite queue modes.
Attributes:
queue (asyncio.Queue): The underlying asyncio queue.
start_time (float): Timestamp when the queue started running.
max_workers (int | None): Maximum concurrent workers. None for dynamic.
worker_tasks (dict): Dictionary mapping worker IDs to their tasks.
queue_timeout (int): Timeout in seconds for queue execution.
stop (bool): Flag to signal queue shutdown.
on_exit (str): Action on exit - 'cancel' or 'complete_priority'.
mode (str): Queue mode - 'finite' or 'infinite'.
queue_cancelled (bool): Whether the queue was cancelled.
Example:
>>> queue = TaskQueue(mode='finite')
>>> queue.add_task(my_async_func, arg1, priority=1)
>>> await queue.run(queue_timeout=60)
"""
start_time: float
"""
A wrapper around an asyncio Queue
Attributes:
queue (Queue): An asyncio Queue
start_time (float): The time the task was started
size (int): The size of the queue
queue_timeout (float): The time to wait for the task to finish
on_exit (Literal['cancel', 'complete_priority']: Action to take on unfinished tasks
mode (Literal['finite', 'infinite'] = 'finite'): Run queue in finite or infinite mode
queue_cancelled (bool): Whether the queue was cancelled
max_workers (int): The maximum number of concurrent workers
"""
def __init__(self, *, size: int = 0, max_workers: int = None, queue: asyncio.Queue = None, queue_timeout: int = 0,
on_exit: Literal['cancel', 'complete_priority'] = 'complete_priority',
mode: Literal['finite', 'infinite'] = 'finite'):
"""Initializes the TaskQueue.
Args:
size: Maximum queue size. 0 for unlimited. Defaults to 0.
max_workers: Maximum concurrent workers. None for dynamic scaling.
queue: Custom asyncio queue to use. Defaults to PriorityQueue.
queue_timeout: Timeout in seconds. 0 for no timeout.
on_exit: Action for unfinished tasks on exit.
'cancel': Cancel all remaining tasks.
'complete_priority': Complete tasks marked as must_complete.
mode: Queue operation mode.
'finite': Stop when queue is empty.
'infinite': Keep running until explicitly stopped.
"""
self.queue = queue or asyncio.PriorityQueue(maxsize=size)
self.max_workers = max_workers
self.worker_tasks: dict[int | float, asyncio.Task] = {}
@@ -85,13 +183,19 @@ class TaskQueue:
self.queue_cancelled = False
def add_task(self, task: Callable | Coroutine, *args, must_complete=False, priority=3, **kwargs):
"""
"""Adds a task to the queue.
Convenience method that wraps the task in a QueueItem and adds it.
Args:
task (Callable | Coroutine): task to execute
*args (Any): args to pass to the task
**kwargs (Any): kwargs to pass to the task
must_complete: ensure task is completed, even when the queue is shut down
priority (int): priority of the task in priority queue
task: The callable or coroutine to execute.
*args: Positional arguments for the task.
must_complete: If True, task completes even during shutdown.
priority: Task priority (lower = higher priority). Defaults to 3.
**kwargs: Keyword arguments for the task.
Raises:
Exception: If an error occurs while adding the task.
"""
try:
task = QueueItem(task, *args, **kwargs)
@@ -123,9 +227,13 @@ class TaskQueue:
logger.error("Cannot add task: %s", exe)
async def worker(self, wid: int = None):
"""Worker function to run tasks in the queue.
"""Worker coroutine that processes tasks from the queue.
Continuously pulls and executes tasks until the queue is empty
(finite mode) or stopped (infinite mode).
Args:
wid (int): The worker id
wid: Unique worker identifier for tracking and removal.
"""
while True:
try:
@@ -171,7 +279,7 @@ class TaskQueue:
break
def check_timeout(self):
"""Check for timeout, and stop queue"""
"""Checks if queue timeout has been exceeded and stops if needed."""
if self.queue_timeout and (time.perf_counter() - self.start_time) > self.queue_timeout:
if self.on_exit == 'cancel':
self.queue_timeout = None
@@ -182,10 +290,11 @@ class TaskQueue:
@staticmethod
async def dummy_task():
"""A dummy task to make sure the queue keeps running when in infinite mode."""
"""A placeholder task to keep infinite mode queues active."""
await asyncio.sleep(1)
def add_dummy_task(self):
"""Adds a dummy task to prevent infinite mode queue from becoming empty."""
dt = QueueItem(self.dummy_task)
self.add(item=dt, with_new_workers=False)
@@ -221,7 +330,7 @@ class TaskQueue:
[self.worker_tasks.setdefault(wi := ri(), ct(wi)) for _ in wr]
async def watch(self):
"""If queue timeout is specified, monitors,the queue to shut down at timeout"""
"""Monitors the queue for timeout and triggers shutdown if exceeded."""
while True:
await asyncio.sleep(1)
if self.queue_timeout and ((time.perf_counter() - self.start_time) > self.queue_timeout):
@@ -237,9 +346,10 @@ class TaskQueue:
self.cancel()
async def run(self, queue_timeout: int = None):
"""Run the queue until all tasks are completed or the timeout is reached.
"""Runs the queue until all tasks complete or timeout is reached.
Args:
queue_timeout (int): The time to wait for the task to finish
queue_timeout: Optional timeout in seconds. Overrides init value.
"""
try:
self.queue_timeout = queue_timeout or self.queue_timeout
+75 -2
View File
@@ -1,3 +1,26 @@
"""Utility functions for the aiomql trading library.
This module provides utility functions for common operations such as sleeping
(with backtest support), automatic database commits, and other helper functions
used throughout the library.
Functions:
auto_commit: Automatically commits state changes to the database.
sleep: Async sleep that works in both live and backtest modes.
sleep_sync: Synchronous sleep that works in both live and backtest modes.
backtest_sleep: Async sleep for backtest mode using simulated time.
backtest_sleep_sync: Sync sleep for backtest mode using simulated time.
Example:
Using sleep in a trading bot::
from aiomql.core.utils import sleep
async def my_strategy():
# This works in both live and backtest modes
await sleep(60) # Wait 60 seconds
"""
import asyncio
import time
from logging import getLogger
@@ -6,11 +29,22 @@ from .config import Config
logger = getLogger(__name__)
async def auto_commit():
"""Automatically commits state changes to the database at regular intervals.
Runs continuously in the background, committing the state to the database
based on the configured commit interval until shutdown is signaled.
Note:
Uses Config.db_commit_interval to determine the commit frequency.
Stops when Config.shutdown is set to True.
"""
config = Config()
try:
with config.state.conn as conn:
while config.shutdown is False:
print("committing state to the database")
await config.state.acommit(conn=conn, close=False)
await sleep(config.db_commit_interval)
except Exception as err:
@@ -18,7 +52,14 @@ async def auto_commit():
async def backtest_sleep(secs):
"""An async sleep function for use during backtesting."""
"""Async sleep function for use during backtesting.
Uses the backtest engine's simulated time cursor instead of real time,
allowing backtests to run faster than real-time.
Args:
secs: Number of simulated seconds to sleep.
"""
config = Config()
secs = config.backtest_engine.cursor.time + secs
while secs > config.backtest_engine.cursor.time:
@@ -26,20 +67,52 @@ async def backtest_sleep(secs):
async def sleep(secs):
"""Async sleep that works in both live and backtest modes.
Automatically uses the appropriate sleep mechanism based on the
current trading mode (live or backtest).
Args:
secs: Number of seconds to sleep.
Example:
>>> await sleep(5) # Sleeps 5 seconds (real or simulated)
"""
if Config.mode == "backtest":
await backtest_sleep(secs)
else:
await asyncio.sleep(secs)
def sleep_sync(secs):
"""Synchronous sleep that works in both live and backtest modes.
Automatically uses the appropriate sleep mechanism based on the
current trading mode (live or backtest).
Args:
secs: Number of seconds to sleep.
Example:
>>> sleep_sync(5) # Sleeps 5 seconds (real or simulated)
"""
if Config.mode == "backtest":
backtest_sleep_sync(secs)
else:
time.sleep(secs)
def backtest_sleep_sync(secs):
"""A sleep function for use during backtesting."""
"""Synchronous sleep function for use during backtesting.
Uses the backtest engine's simulated time cursor instead of real time,
allowing backtests to run faster than real-time.
Args:
secs: Number of simulated seconds to sleep.
"""
config = Config()
secs = config.backtest_engine.cursor.time + secs
while secs > config.backtest_engine.cursor.time:
time.sleep(0)
+1 -1
View File
@@ -1,6 +1,6 @@
from .account import Account
from .bot import Bot
from .candle import Candle, Candles
from .candle import Candle, Candles, CandleProtocol, CandleBase
from .executor import Executor
from .history import History
from .order import Order
+65 -8
View File
@@ -1,3 +1,22 @@
"""Account module for trading account management.
This module provides the Account class, a singleton for managing the
trading account connection to MetaTrader 5. It supports both async
and sync context managers for connection handling.
Example:
Using the account asynchronously::
async with Account() as account:
print(f"Balance: {account.balance}")
print(f"Equity: {account.equity}")
Using the account synchronously::
with Account() as account:
print(f"Balance: {account.balance}")
"""
from threading import Lock
from logging import getLogger
from typing import Self, ClassVar
@@ -10,12 +29,27 @@ logger = getLogger(__name__)
class Account(_Base, AccountInfo):
"""A singleton class for managing a trading account. A subclass of _Base and AccountInfo. It supports
Asynchronous context management protocol.
"""A singleton class for managing a trading account. A subclass of _Base and AccountInfo.
Supports both asynchronous and synchronous context management protocols.
Attributes:
connected (bool): Status of connection to MetaTrader 5 Terminal
connected (bool): Status of connection to MetaTrader 5 Terminal.
_instance (Self): The singleton instance.
_lock (Lock): Thread lock for thread-safe singleton creation.
Example:
Async context manager::
async with Account() as account:
print(account.balance)
Sync context manager::
with Account() as account:
print(account.balance)
"""
_instance: Self
_lock: ClassVar[Lock]
connected: bool
@@ -30,14 +64,13 @@ class Account(_Base, AccountInfo):
return cls._instance
async def __aenter__(self) -> Self:
"""Connect to a trading account and return the account instance.
Async context manager for the Account class.
"""Connects to a trading account asynchronously.
Returns:
Account: An instance of the Account class
Account: The connected account instance.
Raises:
LoginError: If login fails
LoginError: If login fails.
"""
await self.mt5.initialize()
self.connected = await self.mt5.login()
@@ -47,18 +80,42 @@ class Account(_Base, AccountInfo):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Disconnects from the trading account asynchronously."""
await self.mt5.shutdown()
self.connected = False
def __enter__(self) -> Self:
"""Connects to a trading account synchronously.
Returns:
Account: The connected account instance.
Raises:
LoginError: If login fails.
"""
self.mt5.initialize_sync()
self.connected = self.mt5.login_sync()
if not self.connected:
raise LoginError(f"Login failed: {self.mt5.error}")
self.refresh_sync()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Disconnects from the trading account synchronously."""
self.mt5._shutdown()
self.connected = False
async def refresh(self):
"""Refreshes the account instance with the latest account details from the MetaTrader 5 terminal"""
"""Refreshes the account with the latest details from the terminal asynchronously."""
account_info = await self.mt5.account_info()
acc = account_info._asdict()
self.connected = True
self.set_attributes(**acc)
def refresh_sync(self):
"""Refreshes the account with the latest details from the terminal synchronously."""
account_info = self.mt5._account_info()
acc = account_info._asdict()
self.connected = True
self.set_attributes(**acc)
+16
View File
@@ -1,3 +1,19 @@
"""BackTester module for strategy backtesting.
This module provides the BackTester class for running trading strategies
against historical data using the backtest engine. It coordinates
strategy execution, account simulation, and result collection.
Example:
Running a backtest::
from aiomql import BackTester, BackTestEngine
engine = BackTestEngine(...)
backtester = BackTester(backtest_engine=engine)
backtester.add_strategy(strategy=my_strategy)
backtester.execute()
"""
import asyncio
import logging
import time
+424 -54
View File
@@ -1,3 +1,49 @@
"""Bot module for orchestrating trading strategies.
This module provides the Bot class which serves as the main orchestrator
for running trading strategies. It handles terminal initialization,
strategy management, and execution coordination.
Example:
Running a trading bot synchronously::
from aiomql import Bot
from my_strategies import MyStrategy
bot = Bot()
bot.add_strategy(strategy=MyStrategy(symbol=my_symbol))
bot.execute()
Running a trading bot asynchronously::
import asyncio
from aiomql import Bot
from my_strategies import MyStrategy
async def main():
bot = Bot()
bot.add_strategy(strategy=MyStrategy(symbol=my_symbol))
await bot.start()
asyncio.run(main())
Running multiple bot instances in parallel::
from aiomql import Bot
def run_bot1():
bot = Bot()
# configure bot1
bot.execute()
def run_bot2():
bot = Bot()
# configure bot2
bot.execute()
Bot.process_pool(processes={run_bot1: {}, run_bot2: {}})
"""
import asyncio
import time
from concurrent.futures import ProcessPoolExecutor
@@ -15,21 +61,63 @@ logger = logging.getLogger(__name__)
class Bot:
"""The bot class. Creates a bot instance to run strategies.
"""The main bot class for orchestrating trading strategies.
Creates a bot instance that manages the connection to MetaTrader 5,
initializes strategies, and coordinates their execution through the
Executor. Supports both synchronous and asynchronous operation modes,
as well as live trading and backtesting.
Attributes:
executor: A thread executor.
config (Config): Config instance
mt (MetaTrader): MetaTrader instance
config (Config): Configuration instance that holds bot settings and
references to shared resources like the task queue.
executor (Executor): Thread pool executor that manages the concurrent
execution of strategies, coroutines, and functions.
mt5 (MetaTrader | MetaBackTester): MetaTrader 5 interface instance.
Uses MetaTrader for live/demo trading or MetaBackTester for
backtesting based on the config mode.
strategies (list[Strategy]): List of strategy instances to be
initialized and run by the bot.
initialized (bool): Flag indicating whether the terminal has been
successfully initialized.
login (bool): Flag indicating whether the login to the trading
account was successful.
Example:
Basic usage with a single strategy::
bot = Bot()
bot.add_strategy(strategy=MyStrategy(symbol=Symbol(name="EURUSD")))
bot.execute()
Adding multiple strategies at once::
bot = Bot()
strategies = [
Strategy1(symbol=symbol1),
Strategy2(symbol=symbol2),
]
bot.add_strategies(strategies=strategies)
await bot.start()
"""
config: Config
executor: Executor
mt: MetaTrader
mt5: MetaTrader
strategies: list[Strategy]
initialized: bool
login: bool
def __init__(self):
"""Initialize a new Bot instance.
Creates and configures a new bot with a Config instance, Executor,
and the appropriate MetaTrader interface based on the configuration
mode. Initializes all tracking flags and the empty strategies list.
Note:
The bot automatically selects MetaTrader for live/demo trading
or MetaBackTester when config.mode is set to "backtest".
"""
self.config = Config(bot=self)
self.executor = Executor()
self.mt5 = MetaTrader() if self.config.mode != "backtest" else MetaBackTester()
@@ -39,20 +127,61 @@ class Bot:
@classmethod
def process_pool(cls, processes: dict[Callable:dict] = None, num_workers: int = None):
"""Run multiple processes in parallel using a ProcessPoolExecutor. Each process should be a callable that accepts
keyword arguments only.
"""Run multiple bot processes in parallel using a ProcessPoolExecutor.
This class method enables running multiple independent bot instances
in separate processes for parallel execution. Each process should be
a callable that accepts keyword arguments only.
Args:
processes (dict): A dictionary of processes to run with their respective keyword arguments as a dictionary
num_workers (int): Number of workers to run the processes
processes (dict[Callable, dict]): A dictionary mapping callables
(typically bot runner functions) to their respective keyword
arguments. Each callable will be submitted to the process pool.
num_workers (int, optional): Maximum number of worker processes.
Defaults to len(processes) + 1 if not specified.
Example:
Running two bot configurations in parallel::
def run_scalper(**kwargs):
bot = Bot()
# configure scalper bot
bot.execute()
def run_swing(**kwargs):
bot = Bot()
# configure swing bot
bot.execute()
Bot.process_pool(
processes={
run_scalper: {"param": "value1"},
run_swing: {"param": "value2"}
},
num_workers=3
)
"""
num_workers = num_workers or len(processes) + 1
with ProcessPoolExecutor(max_workers=num_workers) as executor:
for bot, kwargs in processes.items():
executor.submit(bot, **kwargs)
async def start_terminal(self):
"""Start terminal and login asynchronously"""
async def start_terminal(self) -> bool:
"""Start the MetaTrader 5 terminal and login asynchronously.
Initializes the connection to the MetaTrader 5 terminal and attempts
to login to the trading account. Updates the `initialized` and `login`
flags based on the results.
Returns:
bool: True if both initialization and login were successful,
False otherwise.
Note:
This method sets `self.initialized` to True if terminal
initialization succeeds, and `self.login` to True only if
both initialization and login succeed.
"""
res = await self.mt5.initialize()
if res:
self.initialized = True
@@ -61,8 +190,22 @@ class Bot:
self.login = True
return res
def start_terminal_sync(self):
"""Start terminal and login synchronously"""
def start_terminal_sync(self) -> bool:
"""Start the MetaTrader 5 terminal and login synchronously.
Synchronous version of start_terminal(). Initializes the connection
to the MetaTrader 5 terminal and attempts to login to the trading
account. Updates the `initialized` and `login` flags based on results.
Returns:
bool: True if both initialization and login were successful,
False otherwise.
Note:
This method sets `self.initialized` to True if terminal
initialization succeeds, and `self.login` to True only if
both initialization and login succeed.
"""
res = self.mt5.initialize_sync()
if res:
self.initialized = True
@@ -71,12 +214,27 @@ class Bot:
self.login = True
return res
async def initialize(self):
"""Prepares the bot by signing in to the trading account and initializing the symbols for each strategy.
Only strategies with successfully initialized symbols will be added to the executor. Starts the global task queue.
async def initialize(self) -> None:
"""Prepare the bot for trading asynchronously.
Performs complete bot initialization including:
1. Starting the terminal and logging into the trading account
2. Initializing all added strategies (only those with successfully
initialized symbols are added to the executor)
3. Starting the global task queue on a separate thread
4. Adding the executor's exit function for graceful shutdown
If no strategies are successfully initialized, the bot will set
the shutdown flag after a 1-second delay.
Raises:
SystemExit if sign_in was not successful
SystemExit: If login to the MetaTrader 5 terminal fails or
any other exception occurs during initialization.
Note:
This method is called internally by the start() method.
Strategies that fail initialization are silently skipped
and not added to the executor.
"""
try:
await self.start_terminal()
@@ -96,13 +254,28 @@ class Bot:
logger.error("%s: Bot initialization failed", err)
raise SystemExit
def initialize_sync(self):
"""Prepares the bot by signing in to the trading account and initializing the symbols for each strategy.
Only strategies with successfully initialized symbols will be added to the executor.
Starts the global task queue.
def initialize_sync(self) -> None:
"""Prepare the bot for trading synchronously.
Synchronous version of initialize(). Performs complete bot
initialization including:
1. Starting the terminal and logging into the trading account
2. Initializing all added strategies (only those with successfully
initialized symbols are added to the executor)
3. Starting the global task queue on a separate thread
4. Adding the executor's exit function for graceful shutdown
If no strategies are successfully initialized, the bot will set
the shutdown flag after a 1-second delay.
Raises:
SystemExit if sign_in was not successful
SystemExit: If login to the MetaTrader 5 terminal fails or
any other exception occurs during initialization.
Note:
This method is called internally by the execute() method.
Strategies that fail initialization are silently skipped
and not added to the executor.
"""
try:
self.start_terminal_sync()
@@ -122,87 +295,284 @@ class Bot:
logger.error("%s: Bot initialization failed", err)
raise SystemExit
def add_function(self, *, function: Callable[..., ...], **kwargs):
"""Add a function to the executor.
def add_function(self, *, function: Callable[..., ...], **kwargs) -> None:
"""Add a synchronous function to the executor for execution.
The function will be executed in a thread pool when the bot starts.
Any additional keyword arguments passed to this method will be
forwarded to the function when it is called.
Args:
function (Callable): A function to be executed
function (Callable): A synchronous function to be executed.
Must accept keyword arguments.
**kwargs: Keyword arguments to pass to the function when executed.
Example:
Adding a monitoring function::
def log_status(interval=60):
while True:
print("Bot is running...")
time.sleep(interval)
bot.add_function(function=log_status, interval=30)
"""
self.executor.add_function(function=function, kwargs=kwargs)
def add_coroutine(self, *, coroutine: Callable[..., ...] | Coroutine, on_separate_thread=False, **kwargs):
"""Add a coroutine to the executor.
def add_coroutine(
self, *, coroutine: Callable[..., ...] | Coroutine, on_separate_thread: bool = False, **kwargs
) -> None:
"""Add an asynchronous coroutine to the executor for execution.
The coroutine will be executed when the bot starts. By default,
coroutines run in the main event loop, but can optionally run
in a separate thread with its own event loop.
Args:
coroutine (Coroutine): A coroutine to be executed
on_separate_thread (bool): Run the coroutine
Returns:
coroutine (Callable | Coroutine): An async function or coroutine
to be executed. Must accept keyword arguments if any are provided.
on_separate_thread (bool, optional): If True, the coroutine runs
in a separate thread with its own event loop. Useful for
long-running tasks that should not block the main loop.
Defaults to False.
**kwargs: Keyword arguments to pass to the coroutine when executed.
Example:
Adding a data collection coroutine::
async def collect_data(symbol, interval=60):
while True:
data = await fetch_market_data(symbol)
save_data(data)
await asyncio.sleep(interval)
bot.add_coroutine(
coroutine=collect_data,
symbol="EURUSD",
interval=30
)
Running on a separate thread::
bot.add_coroutine(
coroutine=long_running_task,
on_separate_thread=True
)
"""
self.executor.add_coroutine(coroutine=coroutine, kwargs=kwargs, on_separate_thread=on_separate_thread)
def execute(self):
"""Start the bot in sync mode"""
def execute(self) -> None:
"""Start the bot in synchronous mode.
This is the main entry point for running the bot synchronously.
Initializes the bot (terminal connection, login, strategies) and
then starts the executor to run all strategies and tasks.
The executor will continue running until a shutdown signal is
received or all strategies complete.
Example:
Starting the bot::
bot = Bot()
bot.add_strategy(strategy=MyStrategy(symbol=my_symbol))
bot.execute() # Blocks until shutdown
Note:
If initialization sets the shutdown flag (e.g., no strategies
were successfully initialized), the executor will not start.
"""
self.initialize_sync()
if self.config.shutdown is False:
self.executor.execute()
async def start(self):
"""Initialize the bot and call the executor it."""
async def start(self) -> None:
"""Start the bot in asynchronous mode.
This is the main entry point for running the bot asynchronously.
Initializes the bot (terminal connection, login, strategies) and
then starts the executor to run all strategies and tasks.
The executor will continue running until a shutdown signal is
received or all strategies complete.
Example:
Starting the bot asynchronously::
async def main():
bot = Bot()
bot.add_strategy(strategy=MyStrategy(symbol=my_symbol))
await bot.start() # Blocks until shutdown
asyncio.run(main())
Note:
If initialization sets the shutdown flag (e.g., no strategies
were successfully initialized), the executor will not start.
"""
await self.initialize()
if self.config.shutdown is False:
self.executor.execute()
def add_strategy(self, *, strategy: Strategy):
def add_strategy(self, *, strategy: Strategy) -> None:
"""Add a strategy to the list of strategies.
Args:
strategy (Strategy): A Strategy instance to run on bot
Adds a single strategy instance to the bot's strategy list. The
strategy will be initialized when the bot starts and, if successful,
will be executed by the executor.
Notes:
Make sure the symbol has been added to the market
Args:
strategy (Strategy): A Strategy instance to run on the bot.
Must have a valid symbol assigned.
Example:
Adding a single strategy::
from aiomql import Symbol
from my_strategies import ScalperStrategy
symbol = Symbol(name="EURUSD")
strategy = ScalperStrategy(symbol=symbol, params={"risk": 0.01})
bot.add_strategy(strategy=strategy)
Note:
Strategies are only initialized and added to the executor when
the bot starts. Make sure the symbol is valid and available
in the market.
"""
self.strategies.append(strategy)
def add_strategies(self, *, strategies: Iterable[Strategy]):
"""Add multiple strategies at the same time
def add_strategies(self, *, strategies: Iterable[Strategy]) -> None:
"""Add multiple strategies at the same time.
Convenience method for adding multiple strategy instances at once.
Each strategy will be initialized when the bot starts.
Args:
strategies: A list of strategies
strategies (Iterable[Strategy]): An iterable of Strategy instances
to run on the bot. Can be a list, tuple, or any iterable.
Example:
Adding multiple strategies::
strategies = [
ScalperStrategy(symbol=Symbol(name="EURUSD")),
SwingStrategy(symbol=Symbol(name="GBPUSD")),
TrendStrategy(symbol=Symbol(name="USDJPY")),
]
bot.add_strategies(strategies=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
):
"""Use this to run a single strategy on multiple symbols with the same parameters and keyword arguments.
) -> None:
"""Run a single strategy type on multiple symbols.
Keyword Args:
strategy (Strategy): Strategy class
params (dict): A dictionary of parameters for the strategy
symbols (list): A list of symbols to run the strategy on
Creates and adds multiple instances of the same strategy class,
one for each provided symbol. All instances share the same
parameters and keyword arguments.
Args:
strategy (Type[Strategy]): The Strategy class (not instance) to
instantiate for each symbol.
params (dict, optional): A dictionary of parameters to pass to
each strategy instance. Defaults to None.
symbols (list[Symbol]): A list of Symbol instances to run the
strategy on. One strategy instance is created per symbol.
**kwargs: Additional keyword arguments passed to each strategy
constructor.
Example:
Running a strategy on multiple symbols::
symbols = [
Symbol(name="EURUSD"),
Symbol(name="GBPUSD"),
Symbol(name="USDJPY"),
]
bot.add_strategy_all(
strategy=ScalperStrategy,
params={"risk": 0.01, "take_profit": 50},
symbols=symbols,
timeframe=TimeFrame.M5
)
"""
[self.add_strategy(strategy=strategy(symbol=symbol, params=params, **kwargs)) for symbol in symbols]
async def init_strategy(self, *, strategy: Strategy) -> bool:
"""Initialize a single strategy. This method is called internally by the bot."""
"""Initialize a single strategy asynchronously.
Calls the strategy's initialize method and, if successful, adds
the strategy to the executor's strategy runners list.
Args:
strategy (Strategy): The Strategy instance to initialize.
Returns:
bool: True if the strategy was successfully initialized and
added to the executor, False otherwise.
Note:
This method is called internally by init_strategies() during
bot initialization. Strategies that fail initialization are
not added to the executor.
"""
res = await strategy.initialize()
if res:
self.executor.add_strategy(strategy=strategy)
return res
async def init_strategies(self):
"""Initialize the symbols for the current trading session. This method is called internally by the bot."""
async def init_strategies(self) -> None:
"""Initialize all strategies for the current trading session.
Concurrently initializes all strategies in the strategies list.
Each strategy is initialized in parallel using asyncio.gather.
Only strategies that successfully initialize are added to the
executor.
Note:
This method is called internally by initialize() during
bot startup. Failed initializations are handled gracefully
and do not prevent other strategies from initializing.
"""
tasks = [self.init_strategy(strategy=strategy) for strategy in self.strategies]
await asyncio.gather(*tasks, return_exceptions=True)
def init_strategy_sync(self, *, strategy: Strategy) -> bool:
"""Initialize a single strategy. This method is called internally by the bot."""
"""Initialize a single strategy synchronously.
Synchronous version of init_strategy(). Calls the strategy's
initialize_sync method and, if successful, adds the strategy
to the executor's strategy runners list.
Args:
strategy (Strategy): The Strategy instance to initialize.
Returns:
bool: True if the strategy was successfully initialized and
added to the executor, False otherwise.
Note:
This method is called internally by init_strategies_sync()
during bot initialization. Strategies that fail initialization
are not added to the executor.
"""
res = strategy.initialize_sync()
if res:
self.executor.add_strategy(strategy=strategy)
return res
def init_strategies_sync(self):
"""Initialize the symbols for the current trading session. This method is called internally by the bot."""
def init_strategies_sync(self) -> None:
"""Initialize all strategies for the current trading session synchronously.
Synchronous version of init_strategies(). Initializes all strategies
in the strategies list sequentially. Only strategies that successfully
initialize are added to the executor.
Note:
This method is called internally by initialize_sync() during
bot startup. Failed initializations are handled gracefully
and do not prevent other strategies from initializing.
"""
[self.init_strategy_sync(strategy=strategy) for strategy in self.strategies]
+587 -145
View File
@@ -1,40 +1,434 @@
"""Candle and Candles classes for handling bars from the MetaTrader 5 terminal."""
"""Candle and Candles classes for handling price bars (OHLC data).
This module provides classes for working with candlestick/bar data from
MetaTrader 5. Includes support for technical analysis via pandas_ta,
charting with mplfinance, and various data manipulation operations.
Example:
Working with candles::
candles = await symbol.copy_rates_from_pos(timeframe=TimeFrame.H1, count=100)
sma = candles.ta.sma(20)
candles.plot(type='candle', volume=True)
"""
from datetime import datetime
from typing import Type, Self, Iterable
from typing import Type, Self, Iterable, Protocol, runtime_checkable, Optional
from logging import getLogger
import pandas as pd
import mplfinance as mpf
from pandas import DataFrame, Series, DatetimeIndex, Timestamp
import pandas_ta as ta
from ..ta_libs import pandas_ta_classic as ta
from ..core.constants import TimeFrame
from ..core.config import Config
logger = getLogger(__name__)
class Candle:
"""A customized class representing rates from the MetaTrader 5 terminal analogous to Japanese
Candlesticks. You can subclass this class for added customization.
@runtime_checkable
class CandleProtocol(Protocol):
"""Protocol defining the minimal interface for Candle classes.
Any class used as a `candle_class` argument in the Candles container
must implement this protocol. This ensures type safety and provides
a clear contract for custom candle implementations.
Only the four core OHLC attributes are required. Custom candle classes
can optionally inherit from CandleBase to get common methods like
is_bullish(), is_bearish(), and wick/body calculations for free.
Attributes:
time (float): Period start time.
open (float): Open price
high (float): The highest price of the period
low (float): The lowest price of the period
close (float): Close price
tick_volume (float): Tick volume
real_volume (float): Trade volume
spread (float): Spread
index (Timestamp): Index of the object in the DataFrame, a timestamp
Index (int): Custom attribute representing the position of the candle for integer-location based indexing
open: Opening price of the period.
high: Highest price of the period.
low: Lowest price of the period.
close: Closing price of the period.
Example:
Creating a minimal custom candle class::
class MyCandle:
def __init__(self, **kwargs):
self.open = kwargs['open']
self.high = kwargs['high']
self.low = kwargs['low']
self.close = kwargs['close']
Creating a custom candle with helper methods::
class MyCandle(CandleBase):
def __init__(self, **kwargs):
self.open = kwargs['open']
self.high = kwargs['high']
self.low = kwargs['low']
self.close = kwargs['close']
# Now has access to is_bullish(), is_bearish(), etc.
"""
open: float
high: float
low: float
close: float
def __init__(self, **kwargs) -> None:
"""Initialize the candle with keyword arguments.
Args:
**kwargs: Must include 'open', 'high', 'low', 'close'.
"""
...
class CandleBase:
"""Base class providing common candle analysis methods.
This class provides methods that only depend on the four core OHLC
(Open, High, Low, Close) attributes. Custom candle classes can inherit
from this class to get these methods for free.
Attributes:
open: Opening price of the period.
high: Highest price of the period.
low: Lowest price of the period.
close: Closing price of the period.
time: Period start time as Unix timestamp. Optional.
index: Pandas Timestamp index. Optional.
Index: Integer position index. Optional.
Example:
Creating a custom candle class with base methods::
class MyCandle(CandleBase):
def __init__(self, open, high, low, close):
self.open = open
self.high = high
self.low = low
self.close = close
candle = MyCandle(open=100, high=110, low=95, close=105)
print(candle.is_bullish()) # True
print(candle.candle_body) # 5.0
"""
open: float
high: float
low: float
close: float
time: Optional[float] = None
index: Optional[Timestamp] = None
Index: Optional[int] = None
def __repr__(self) -> str:
"""Return a string representation of the candle.
Returns:
str: String showing class name and OHLC values.
"""
return (
"%(class)s(open=%(open)s, high=%(high)s, low=%(low)s, close=%(close)s)"
% {
"class": self.__class__.__name__,
"open": self.open,
"high": self.high,
"low": self.low,
"close": self.close,
}
)
@staticmethod
def _comparison_key(candle) -> tuple[float, float]:
"""Return a tuple used for comparison operations.
The comparison key consists of (candle_body, candle_range) which allows
candles to be compared and sorted based on their body size and range.
Args:
candle: Any object implementing CandleProtocol (has open, high, low, close
attributes) or a dict-like object with 'open', 'high', 'low', 'close' keys.
Returns:
tuple[float, float]: (candle_body, candle_range)
"""
# Support both attribute access and dict-like access
try:
open_ = candle.open
high = candle.high
low = candle.low
close = candle.close
except AttributeError:
open_ = candle['open']
high = candle['high']
low = candle['low']
close = candle['close']
return (abs(close - open_), high - low)
def __eq__(self, other: "CandleProtocol") -> bool:
"""Check equality based on candle body and range.
Two candles are equal if they have the same body size and range.
Args:
other: Any object implementing CandleProtocol.
Returns:
bool: True if candles have equal body and range.
"""
return self._comparison_key(self) == self._comparison_key(other)
def __ne__(self, other: "CandleProtocol") -> bool:
"""Check inequality based on candle body and range.
Args:
other: Any object implementing CandleProtocol.
Returns:
bool: True if candles have different body or range.
"""
return self._comparison_key(self) != self._comparison_key(other)
def __lt__(self, other: "CandleProtocol") -> bool:
"""Check if this candle is less than another based on body and range.
Comparison is done lexicographically: first by body size, then by range.
Args:
other: Any object implementing CandleProtocol.
Returns:
bool: True if this candle is smaller.
"""
return self._comparison_key(self) < self._comparison_key(other)
def __le__(self, other: "CandleProtocol") -> bool:
"""Check if this candle is less than or equal to another.
Args:
other: Any object implementing CandleProtocol.
Returns:
bool: True if this candle is smaller or equal.
"""
return self._comparison_key(self) <= self._comparison_key(other)
def __gt__(self, other: "CandleProtocol") -> bool:
"""Check if this candle is greater than another based on body and range.
Comparison is done lexicographically: first by body size, then by range.
Args:
other: Any object implementing CandleProtocol.
Returns:
bool: True if this candle is larger.
"""
return self._comparison_key(self) > self._comparison_key(other)
def __ge__(self, other: "CandleProtocol") -> bool:
"""Check if this candle is greater than or equal to another.
Args:
other: Any object implementing CandleProtocol.
Returns:
bool: True if this candle is larger or equal.
"""
return self._comparison_key(self) >= self._comparison_key(other)
def __hash__(self) -> int:
"""Return hash based on comparison key.
Returns:
int: Hash value based on (candle_body, candle_range).
"""
return hash((self.open, self.high, self.low, self.close))
def __getitem__(self, item: str):
"""Get an attribute value by key.
Args:
item: The attribute name to retrieve.
Returns:
The value of the requested attribute.
Raises:
KeyError: If the attribute does not exist.
"""
return self.__dict__[item]
def __setitem__(self, key: str, value) -> None:
"""Set an attribute value by key.
Args:
key: The attribute name to set.
value: The value to assign to the attribute.
"""
self.__dict__[key] = value
def __iter__(self):
"""Iterate over attribute key-value pairs.
Yields:
tuple: (key, value) pairs for each instance attribute.
"""
return iter(self.__dict__.items())
def keys(self):
"""Return the attribute names of the candle.
Returns:
dict_keys: A view of the attribute names.
"""
return self.__dict__.keys()
def values(self):
"""Return the attribute values of the candle.
Returns:
dict_values: A view of the attribute values.
"""
return self.__dict__.values()
def set_attributes(self, **kwargs) -> None:
"""Set multiple attributes from keyword arguments.
Args:
**kwargs: Attribute names and values to set.
"""
[setattr(self, i, j) for i, j in kwargs.items()]
def dict(self, *, exclude: set = None, include: set = None) -> dict:
"""Return instance attributes as a dictionary.
Args:
exclude: Set of attribute names to exclude. Defaults to None.
include: Set of attribute names to include. Defaults to None.
Returns:
dict: Dictionary of instance attributes.
Note:
If both include and exclude are provided, include takes precedence.
"""
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 if k in keys}
def to_series(self) -> Series:
"""Convert the candle to a pandas Series.
Returns:
Series: Pandas Series with candle attributes, excluding Index and index.
"""
return Series(self.dict(exclude={"Index", "index"}))
def is_bullish(self) -> bool:
"""Check if the candle is bullish (close >= open).
Returns:
bool: True if close >= open, False otherwise.
"""
return self.close >= self.open
def is_bearish(self) -> bool:
"""Check if the candle is bearish (close < open).
Returns:
bool: True if close < open, False otherwise.
"""
return self.close < self.open
@property
def upper_wick(self) -> float:
"""Calculate the upper wick length.
Returns:
float: The distance from the high to max(open, close).
"""
return self.high - max(self.open, self.close)
@property
def lower_wick(self) -> float:
"""Calculate the lower wick length.
Returns:
float: The distance from min(open, close) to the low.
"""
return min(self.open, self.close) - self.low
@property
def candle_range(self) -> float:
"""Calculate the total range of the candle.
Returns:
float: The distance from high to low.
"""
return self.high - self.low
@property
def candle_body(self) -> float:
"""Calculate the body size of the candle.
Returns:
float: The absolute distance between open and close.
"""
return abs(self.close - self.open)
@property
def upper_wick_percentage(self) -> float:
"""Calculate the upper wick as a percentage of candle range.
Returns:
float: Upper wick percentage (0-100).
"""
return self.upper_wick / self.candle_range * 100
@property
def lower_wick_percentage(self) -> float:
"""Calculate the lower wick as a percentage of candle range.
Returns:
float: Lower wick percentage (0-100).
"""
return self.lower_wick / self.candle_range * 100
@property
def candle_body_percentage(self) -> float:
"""Calculate the body as a percentage of candle range.
Returns:
float: Body percentage (0-100).
"""
return self.candle_body / self.candle_range * 100
class Candle(CandleBase):
"""MetaTrader 5 candle representation analogous to Japanese Candlesticks.
This class extends CandleBase with additional attributes and methods specific
to MetaTrader 5 candle data. You can subclass this class for added customization.
Attributes:
time: Period start time as Unix timestamp.
open: Open price.
high: Highest price of the period.
low: Lowest price of the period.
close: Close price.
volume: Volume (uses real_volume or tick_volume).
tick_volume: Tick volume.
real_volume: Trade volume.
spread: Spread value.
index: Pandas Timestamp index.
Index: Integer position for iloc-based indexing.
"""
time: float
open: float
high: float
low: float
close: float
volume: float
real_volume: float
spread: float
tick_volume: float
@@ -42,12 +436,17 @@ class Candle:
Index: int
def __init__(self, **kwargs):
"""Create a Candle object from keyword arguments. This class must always be instantiated with open, high, low
and close prices.
"""Create a Candle object from keyword arguments.
Keyword Args:
**kwargs: Candle attributes and values as keyword arguments.
Args:
**kwargs: Candle attributes. Must include 'open', 'high', 'low', 'close'.
Optional: 'time', 'index', 'Index', 'volume', 'tick_volume',
'real_volume', 'spread'.
Raises:
ValueError: If open, high, low, or close are not provided.
"""
kwargs = {k.lower() if k != "Index" else k: v for k, v in kwargs.items() }
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", Timestamp.now().timestamp())
@@ -56,9 +455,15 @@ class Candle:
self.real_volume = kwargs.pop("real_volume", 0)
self.spread = kwargs.pop("spread", 0)
self.tick_volume = kwargs.pop("tick_volume", 0)
self.volume = kwargs.pop("volume", self.real_volume or self.tick_volume)
self.set_attributes(**kwargs)
def __repr__(self):
def __repr__(self) -> str:
"""Return a string representation of the candle.
Returns:
str: String showing class name and all candle attributes.
"""
return (
"%(class)s(Index=%(Index)s, time=%(time)s, open=%(open)s, high=%(high)s, low=%(low)s, close=%(close)s, index=%(index)s)"
% {
@@ -73,97 +478,41 @@ class Candle:
}
)
def __eq__(self, other: Self):
return self.time == other.time
def __lt__(self, other: Self):
return self.time < other.time
def __hash__(self):
return id(self)
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 and values.
Keyword Args:
**kwargs: Instance attributes and values as keyword arguments
"""
[setattr(self, i, j) for i, j in kwargs.items()]
def is_bullish(self) -> bool:
"""A simple check to see if the candle is bullish.
"""Return hash based on time and comparison key.
Returns:
bool: True or False
int: Hash value based on (time, candle_body, candle_range).
"""
return self.close >= self.open
def is_bearish(self) -> bool:
"""A simple check to see if the candle is bearish.
Returns:
bool: True or False
"""
return self.close < self.open
def dict(self, *, exclude: set = None, include: set = None) -> dict:
"""
Returns a dictionary of the instance attributes.
Args:
exclude: A set of attributes to exclude from the dictionary. Defaults to None.
include: A set of attributes to include in the dictionary. Defaults to None.
Returns: dict
"""
exclude = exclude or set()
include = include or set()
keys = include or set(self.__dict__.keys()).difference(exclude)
return {k: v for k, v in self if k in keys}
def to_series(self) -> Series:
"""Returns a Series Object"""
return Series(self.dict(exclude={"Index", "index"}))
return hash((self.time, self.open, self.high, self.low, self.close))
class Candles:
"""An iterable container class of Candle objects in chronological order.
"""Iterable container of Candle objects in chronological order.
Provides a DataFrame-backed container for candle data with support for
technical analysis via pandas_ta, charting with mplfinance, and various
data manipulation operations.
Attributes:
index (DatetimeIndex): DatetimeIndex of the DataFrame object.
Index (Series['int']): A pandas Series of the indexes of all candles in the object:
time (Series['float']): A pandas Series of the time of all candles in the object.
open (Series[float]): A pandas Series of the opening price of all candles in the object.
high (Series[float]): A pandas Series of the high price of all candles in the object.
low (Series[float]): A pandas Series of the low price of all candles in the object.
close (Series[float]): A pandas Series of the closing price of all candles in the object.
tick_volume (Series[float]): A pandas Series of the tick volume of all candles in the object.
real_volume (Series[float]): A pandas Series of the real volume of all candles in the object.
spread (Series[float]): A pandas Series of the spread of all candles in the object.
timeframe (TimeFrame): The timeframe of the candles in the object.
Candle (Type[Candle]): The Candle class for representing the candles in the object.
index: DatetimeIndex of the underlying DataFrame.
Index: Series of integer position indices.
time: Series of candle timestamps.
open: Series of opening prices.
high: Series of high prices.
low: Series of low prices.
close: Series of closing prices.
volume: Series of volume values.
tick_volume: Series of tick volume values.
real_volume: Series of real volume values.
spread: Series of spread values.
timeframe: Detected TimeFrame of the candle data.
Candle: The candle class used for iteration.
data: The underlying pandas DataFrame.
properties:
data (DataFrame): A pandas DataFrame of all candles in the object.
Notes:
The candle class can be customized by subclassing the Candle class and passing the subclass as the candle
keyword argument, or defining it on the class body as a class attribute.
Note:
The candle class can be customized by passing a custom class as the
candle_class argument or by subclassing and setting the Candle attribute.
"""
index: DatetimeIndex
Index: Series
@@ -172,22 +521,26 @@ class Candles:
high: Series
low: Series
close: Series
volume: Series
tick_volume: Series
real_volume: Series
spread: Series
Candle: Type[Candle]
Candle: Type[CandleProtocol]
timeframe: TimeFrame
_data: DataFrame
def __init__(self, *, data: DataFrame | Self | Iterable, flip=False, candle_class: Candle = None):
"""A container class of Candle objects in chronological order.
def __init__(self, *, data: DataFrame | Self | Iterable, flip=False, candle_class: Type[Candle] = None):
"""Initialize a Candles container.
Args:
data (DataFrame|Candles|Iterable): A pandas dataframe, a Candles object or any suitable iterable
data: Source data as DataFrame, Candles object, or iterable.
flip: If True, reverse chronological order (most recent first).
Defaults to False.
candle_class: Custom candle class implementing CandleProtocol.
Defaults to Candle.
Keyword Args:
flip (bool): Reverse the chronological order of the candles to the most recent first. Defaults to False.
candle_class: A subclass of Candle to use as the candle class. Defaults to Candle.
Raises:
ValueError: If data cannot be converted to DataFrame.
"""
if isinstance(data, DataFrame):
data = data
@@ -206,16 +559,37 @@ class Candles:
self.Candle = candle_class or Candle
self.config = Config()
def __repr__(self):
def __repr__(self) -> str:
"""Return string representation of the underlying DataFrame."""
return repr(self._data)
def __len__(self):
def __len__(self) -> int:
"""Return the number of candles in the container."""
return len(self._data.index)
def __contains__(self, item: Candle):
def __contains__(self, item: Candle) -> bool:
"""Check if a candle exists in the container by time.
Args:
item: Candle object to check.
Returns:
bool: True if candle with matching time exists.
"""
return item.time == self[item.Index].time
def __getitem__(self, index: slice | int | str) -> Self | Series | Candle:
"""Get candle(s) by index, slice, or column name.
Args:
index: Integer index, slice, or column name string.
Returns:
Candle for int index, Candles for slice, Series for column name.
Raises:
TypeError: If index is not int, slice, or str.
"""
if isinstance(index, slice):
cls = self.__class__
data = self._data.iloc[index]
@@ -235,13 +609,33 @@ class Candles:
return self.Candle(**candle, Index=Index, index=_index)
raise TypeError(f"Expected int, slice or str got {type(index)}")
def __setitem__(self, index, value: Series):
def __setitem__(self, index: str, value: Series) -> None:
"""Set a column value by name.
Args:
index: Column name.
value: Series to assign to the column.
Raises:
TypeError: If value is not a Series.
"""
if isinstance(value, Series):
self._data[index] = value
return
raise TypeError(f"Expected Series got {type(value)}")
def __getattr__(self, item):
def __getattr__(self, item: str):
"""Get column or index by attribute name.
Args:
item: Attribute name (column name, 'index', or 'Index').
Returns:
Series or DatetimeIndex for the requested attribute.
Raises:
AttributeError: If attribute does not exist.
"""
if item in self._data.columns:
return self._data[item]
@@ -253,6 +647,11 @@ class Candles:
raise AttributeError(f"Attribute {item} not defined on class {self.__class__.__name__}")
def __reversed__(self):
"""Iterate over candles in reverse chronological order.
Yields:
Candle: Candle objects from newest to oldest.
"""
for index, row in enumerate(iter(self._data[::-1].iloc)):
row = row.to_dict()
index = len(self._data) - index - 1
@@ -261,6 +660,11 @@ class Candles:
yield self.Candle(**row)
def __iter__(self):
"""Iterate over candles in chronological order.
Yields:
Candle: Candle objects from oldest to newest.
"""
for index, row in enumerate(iter(self._data.iloc)):
row = row.to_dict()
row["Index"] = index
@@ -268,52 +672,74 @@ class Candles:
yield self.Candle(**row)
@property
def timeframe(self):
def timeframe(self) -> TimeFrame:
"""Detect the timeframe from consecutive candle timestamps.
Returns:
TimeFrame: The detected timeframe of the candle data.
"""
tf = self.time.iloc[1] - self.time.iloc[0]
return TimeFrame.get_timeframe(abs(tf))
@property
def columns(self) -> DataFrame:
def columns(self):
"""Return the column names of the underlying DataFrame.
Returns:
Index: Column names.
"""
return self._data.columns
@property
def ta(self):
"""Access to the pandas_ta library for performing technical analysis on the underlying data attribute.
"""Access pandas_ta for technical analysis on the data.
Returns:
pandas_ta: The pandas_ta library
pandas_ta accessor for the underlying DataFrame.
"""
return self._data.ta
@property
def ta_lib(self):
"""Access to the ta library for performing technical analysis. Not dependent on the underlying data attribute.
"""Access the ta library directly.
Returns:
ta: The ta library
The pandas_ta module (not data-dependent).
"""
return ta
@property
def data(self) -> DataFrame:
"""The original data passed to the class as a pandas DataFrame"""
return self._data
def rename(self, inplace=True, **kwargs) -> Self:
"""Rename columns of the candles class.
Keyword Args:
inplace (bool): Rename the columns inplace or return a new instance of the class with the renamed columns
**kwargs: The new names of the columns
"""Return the underlying DataFrame.
Returns:
Candles: A new instance of the class with the renamed columns if inplace is False else the modified instance
DataFrame: The candle data.
"""
return self._data
def rename(self, inplace: bool = True, **kwargs) -> Self:
"""Rename columns of the candles DataFrame.
Args:
inplace: If True, modify in place. If False, return new instance.
Defaults to True.
**kwargs: Column name mappings (old_name=new_name).
Returns:
Self: This instance if inplace, otherwise new Candles instance.
"""
res = self._data.rename(columns=kwargs, inplace=inplace)
return self if inplace else self.__class__(data=res)
def __iadd__(self, other: Self) -> Self:
"""Perform in place addition of candles"""
"""Merge another Candles object in place.
Args:
other: Candles object to merge.
Returns:
Self: This instance with merged data.
"""
data_copy = self._data.copy()
other = other._data
for index, row in zip(other.index, iter(other.iloc)):
@@ -322,14 +748,31 @@ class Candles:
return self
def __add__(self, other: Self) -> Self:
"""Add two candles object and return a new one"""
"""Merge two Candles objects into a new instance.
Args:
other: Candles object to merge.
Returns:
Self: New Candles instance with merged data.
"""
data = self._data.copy()
for index, row in zip(other._data.index, iter(other._data.iloc)):
data.loc[index] = row
return self.__class__(data=data.sort_index())
def add(self, obj: DataFrame | Series | Candle) -> Self:
"""Add new row(s) to the candles class."""
"""Add new row(s) to the container.
Args:
obj: Data to add as DataFrame, Series, or Candle.
Returns:
Self: This instance with added data.
Raises:
TypeError: If obj is not DataFrame, Series, or Candle.
"""
if isinstance(obj, Series):
index = Timestamp(obj.time, unit="s", tz=datetime.now().astimezone().tzinfo)
self._data.loc[index] = obj
@@ -350,14 +793,13 @@ class Candles:
raise TypeError("Expected Series, DataFrame or Candle, got {}".format(type(obj)))
def plot(self, subplots: dict = None, span: int = None, filename="", **kwargs):
"""
Create a plot of the candles
"""Create a candlestick chart of the candle data.
Args:
subplots (dict): Subplots to bed added to the main plot
span (int): Last 'n' candles to be used for making the subplot
filename (str): A filename to saved the plot
**kwargs: Kwargs to be passed to the plot
subplots: Subplots to be added to the main plot. Defaults to None.
span: Last 'n' candles to be used for the plot. Defaults to None (all candles).
filename: Filename to save the plot. Defaults to empty string.
**kwargs: Additional keyword arguments passed to mplfinance.plot().
"""
type_ = kwargs.pop("type", "candle")
subplots = subplots or []
@@ -369,15 +811,15 @@ class Candles:
mpf.plot(data, type=type_, addplot=subplots, **kwargs)
def make_subplot(self, *, column: str | list[str], span: int = None, **kwargs) -> dict:
"""
Create a subplot
"""Create a subplot for use with the plot method.
Args:
column (list[str] | str): Name of columns for the subplot
span (int): Last 'n' candles to be used for making the subplot
**kwargs: Keywords arguments to pass to the subplot
column: Column name(s) for the subplot data.
span: Last 'n' candles to be used for the subplot. Defaults to None (all candles).
**kwargs: Additional keyword arguments passed to mplfinance.make_addplot().
Returns:
dict: Subplots
dict: Subplot configuration for mplfinance.
"""
column = column if isinstance(column, list) else [column]
span = 0 if span is None else span
+19 -1
View File
@@ -1,4 +1,19 @@
"""Executor module for concurrent strategy execution.
This module provides the Executor class for running multiple trading
strategies concurrently using a ThreadPoolExecutor. It handles
strategy lifecycle, signal handling, and graceful shutdown.
Example:
Running strategies::
executor = Executor()
executor.add_strategy(strategy=my_strategy)
executor.execute(workers=5)
"""
import asyncio
import inspect
import os
import time
from concurrent.futures import ThreadPoolExecutor
@@ -68,7 +83,10 @@ class Executor:
Args:
strategy (Strategy): A strategy object
"""
asyncio.run(strategy.run_strategy())
if inspect.iscoroutinefunction(strategy.run_strategy):
asyncio.run(strategy.run_strategy())
else:
strategy.run_strategy()
async def run_coroutine_tasks(self):
"""Run all coroutines in the executor"""
+150 -70
View File
@@ -1,3 +1,18 @@
"""History module for accessing trade history.
This module provides the History class for retrieving completed trade
deals and orders from the trading account history within a specified
date range.
Example:
Getting trade history::
history = History(date_from=datetime(2024, 1, 1), date_to=datetime.now())
await history.initialize()
for deal in history.deals:
print(f"Deal {deal.ticket}: {deal.profit}")
"""
import asyncio
from typing import ClassVar
from datetime import datetime, UTC
@@ -7,21 +22,48 @@ from ..core.config import Config
from ..core.meta_trader import MetaTrader
from ..core.models import TradeDeal, TradeOrder
from ..core.meta_backtester import MetaBackTester
from ..core.base import BaseMeta
from ..core.exceptions import InvalidRequest
logger = getLogger(__name__)
class History:
"""The history class handles completed trade deals and trade orders in the trading history of an account.
class History(metaclass=BaseMeta):
"""Handles completed trade deals and orders from account history.
Provides methods to retrieve and filter historical trade deals and orders
within a specified date range. Supports filtering by symbol group, order
ticket, and position ID.
Attributes:
deals (list[TradeDeal]): Iterable of trade deals
orders (list[TradeOrder]): Iterable of trade orders
total_deals: Total number of deals
total_orders (int): Total number orders
group (str): Filter for selecting history by symbols.
mt5 (MetaTrader): MetaTrader instance
config (Config): Config instance
deals: Tuple of trade deals retrieved from history.
orders: Tuple of trade orders retrieved from history.
total_deals: Total number of deals retrieved.
total_orders: Total number of orders retrieved.
group: Symbol filter pattern for selecting history.
date_from: Start date for history query.
date_to: End date for history query.
mt5: MetaTrader or MetaBackTester instance (class variable).
config: Config instance (class variable).
Example:
Basic usage::
from datetime import datetime
from aiomql.lib.history import History
history = History(
date_from=datetime(2024, 1, 1),
date_to=datetime.now()
)
await history.initialize()
# Access deals and orders
print(f"Total deals: {history.total_deals}")
print(f"Total orders: {history.total_orders}")
# Filter by position
position_deals = history.get_deals_by_position(position=12345)
"""
mt5: ClassVar[MetaTrader | MetaBackTester]
config: ClassVar[Config]
@@ -31,27 +73,40 @@ class History:
total_orders: int
group: str
def __new__(cls, *args, **kwargs):
if not hasattr(cls, 'config'):
cls.config = Config()
if not hasattr(cls, 'mt5'):
cls.mt5 = MetaTrader() if cls.config.mode != "backtest" else MetaBackTester()
return super().__new__(cls)
def __init__(self, *, date_from: datetime | float, date_to: datetime | float, group: str = "", use_utc: bool = False):
"""Initialize a History instance with date range and filters.
def __init__(
self, *, date_from: datetime | float, date_to: datetime | float, group: str = "", use_utc: bool = float
):
"""
Args:
date_from (datetime, float): Date the orders are requested from. Set by the 'datetime' object or as a
number of seconds elapsed since 1970.01.01.
date_from: Start date for history query. Can be a datetime object
or Unix timestamp (seconds since 1970-01-01).
date_to: End date for history query. Can be a datetime object
or Unix timestamp (seconds since 1970-01-01).
group: Symbol filter pattern for selecting history. Use '*' as
wildcard. Defaults to empty string (all symbols).
use_utc: If True, convert date_from and date_to to UTC timezone.
Defaults to False.
date_to (datetime, float): Date up to which the orders are requested. Set by the 'datetime' object or as a
number of seconds elapsed since 1970.01.01.
Example:
Create history for specific date range::
use_utc (bool): Convert date_from and date_to to UTC. Default is False.
# Using datetime objects
history = History(
date_from=datetime(2024, 1, 1),
date_to=datetime(2024, 12, 31)
)
group (str): Filter for selecting history by symbols. This defaults to an empty string
# Using timestamps
history = History(
date_from=1704067200.0, # 2024-01-01
date_to=1735689600.0 # 2024-12-31
)
# With symbol filter
history = History(
date_from=datetime(2024, 1, 1),
date_to=datetime.now(),
group="*USD*" # Only USD pairs
)
"""
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)
@@ -63,8 +118,24 @@ class History:
self.total_deals: int = 0
self.total_orders: int = 0
async def initialize(self):
"""Get history deals and orders"""
async def initialize(self) -> None:
"""Fetch history deals and orders from the trading account.
Retrieves both deals and orders concurrently and stores them in
the instance attributes. Must be called before accessing deals
or orders.
Note:
This method handles exceptions gracefully. If fetching deals
or orders fails, the corresponding attribute will be an empty tuple.
Example:
Initialize and access history::
history = History(date_from=start, date_to=end)
await history.initialize()
print(f"Found {history.total_deals} deals")
"""
deals, orders = await asyncio.gather(self.get_deals(), self.get_orders(), return_exceptions=True)
self.deals = deals if isinstance(deals, tuple) else ()
self.orders = orders if isinstance(orders, tuple) else ()
@@ -72,60 +143,69 @@ class History:
self.total_orders = len(self.orders)
async def get_deals(self) -> tuple[TradeDeal, ...]:
"""Get deals from trading history using the parameters set in the constructor.
"""Retrieve trade deals from history.
Fetches deals from the trading history using the date range and
group filter set in the constructor.
Returns:
tuple[TradeDeal, ...]: A list of trade deals
tuple[TradeDeal, ...]: Tuple of TradeDeal objects. Returns empty
tuple if no deals found or on error.
Note:
Logs a warning if fetching deals fails.
"""
deals = await self.mt5.history_deals_get(date_from=self.date_from, date_to=self.date_to, group=self.group)
if deals is not None:
return tuple(TradeDeal(**deal._asdict()) for deal in deals)
logger.warning(f"Failed to get deals")
return tuple()
def get_deals_by_ticket(self, *, ticket: int) -> tuple[TradeDeal, ...]:
"""Call specifying the order ticket. Return all deals having the specified order ticket in the DEAL_ORDER
property.
Args:
ticket (int): The order ticket
Returns:
tuple[TradeDeal]: A tuple of all deals with the order ticket
"""
return tuple(sorted((deal for deal in self.deals if deal.order == ticket), key=lambda x: x.time_msc))
def get_deals_by_position(self, *, position: int = None) -> tuple[TradeDeal, ...]:
"""
Get all deals with the specified position ticket in the DEAL_POSITION_ID property
Args:
position (int): The position ticket
Returns:
tuple[TradeDeal]: A tuple of all deals with the position ticket
"""
return tuple(sorted((deal for deal in self.deals if deal.position_id == position), key=lambda x: x.time_msc))
return tuple(TradeDeal(**deal._asdict()) for deal in deals)
async def get_orders(self) -> tuple[TradeOrder, ...]:
"""Get orders from trading history using the parameters set in the constructor or the method arguments.
"""Retrieve trade orders from history.
Fetches orders from the trading history using the date range and
group filter set in the constructor.
Returns:
list[TradeOrder]: A list of trade orders
tuple[TradeOrder, ...]: Tuple of TradeOrder objects. Returns empty
tuple if no orders found or on error.
Note:
Logs a warning if fetching orders fails.
"""
orders = await self.mt5.history_orders_get(date_from=self.date_from, date_to=self.date_to, group=self.group)
return tuple(TradeOrder(**order._asdict()) for order in orders)
if orders is not None:
return tuple(TradeOrder(**order._asdict()) for order in orders)
def filter_deals_by_ticket(self, *, ticket: int) -> tuple[TradeDeal, ...]:
return tuple(deal for deal in self.deals if deal.ticket == ticket)
logger.warning(f"Failed to get orders")
return tuple()
def filter_deals_by_position(self, *, position: int) -> tuple[TradeDeal, ...]:
return tuple(deal for deal in self.deals if deal.position_id == position)
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 filter_orders_by_ticket(self, *, ticket: int) -> tuple[TradeOrder, ...]:
return tuple(order for order in self.orders if order.ticket == ticket)
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)
)
def filter_orders_by_position(self, *, position: int) -> tuple[TradeOrder, ...]:
return tuple(order for order in self.orders if order.position_id == position)
@classmethod
async def get_deal_by_ticket(cls, *, ticket: int) -> TradeDeal:
deals = await cls.mt5.history_deals_get(ticket=ticket)
if (deal := deals[0]).ticket == ticket:
return TradeDeal(**deal._asdict())
raise InvalidRequest("Ticket not found")
@classmethod
async def get_deals_by_position(cls, *, position: int = None) -> tuple[TradeDeal, ...]:
deals = await cls.mt5.history_deals_get(position=position)
return tuple(TradeDeal(**deal._asdict()) for deal in deals if deal.position_id == position)
@classmethod
async def get_order_by_ticket(cls, *, ticket: int) -> TradeOrder:
orders = await cls.mt5.history_orders_get(ticket=ticket)
if (order := orders[0]).ticket == ticket:
return TradeOrder(**order._asdict())
raise InvalidRequest("Ticket not found")
@classmethod
async def get_orders_by_position(cls, *, position: int) -> tuple[TradeOrder, ...]:
orders = await cls.mt5.history_orders_get(position=position)
return tuple(TradeOrder(**order._asdict()) for order in orders)
+40 -10
View File
@@ -1,10 +1,25 @@
"""Order module for trade order operations.
This module provides the Order class for creating, checking, and sending
trade orders to the MetaTrader 5 terminal. It includes functionality for
margin calculations, profit projections, and pending order management.
Example:
Sending a market order::
from aiomql import Order, OrderType
order = Order(symbol='EURUSD', type=OrderType.BUY, volume=0.1, price=1.1000)
result = await order.send()
"""
import asyncio
from logging import getLogger
from ..core.models import TradeRequest, TradeOrder, OrderCheckResult, OrderSendResult
from ..core.constants import TradeAction, OrderTime, OrderFilling, OrderType
from ..core.exceptions import OrderError
from ..core.base import _Base
from ..utils import error_handler, percentage_decrease, percentage_increase
from ..utils import error_handler, decrease_value_by_pct, increase_value_by_pct
logger = getLogger(__name__)
@@ -56,11 +71,10 @@ class Order(_Base, TradeRequest):
Returns:
"""
orders = await cls.mt5.orders_get(ticket=ticket)
order = None
for order_ in orders:
if order_.ticket == ticket:
return TradeOrder(**order_._asdict())
return order
return None
@classmethod
async def get_pending_orders(cls, *, ticket: int = 0, symbol: str = "", group: str = "") -> tuple[TradeOrder, ...]:
@@ -80,9 +94,11 @@ class Order(_Base, TradeRequest):
return tuple()
@classmethod
async def cancel_order(cls, *, order: int, symbol: str) -> OrderSendResult:
async def cancel_order(cls, *, order: int, symbol: str = "") -> OrderSendResult:
"""Cancel an active pending order by ticket number."""
res = await cls.mt5.order_send({"symbol": symbol, "order": order, "action": TradeAction.REMOVE})
res = await cls.send_order(request={"order": order, "action": TradeAction.REMOVE, "symbol": symbol})
if res is None:
raise OrderError("Unable to cancel order %d:%s" % (order, symbol))
return res
async def check(self, **kwargs) -> OrderCheckResult:
@@ -100,7 +116,11 @@ class Order(_Base, TradeRequest):
raise OrderError(f"Order check failed for {self.symbol}")
return OrderCheckResult(**res._asdict())
async def send(self) -> OrderSendResult:
async def send(self):
return await self.send_order(request=self.request)
@classmethod
async def send_order(cls, *, request: dict, connection_retries=0) -> OrderSendResult:
"""Send a request to perform a trading operation from the terminal to the trade server.
Returns:
@@ -109,9 +129,12 @@ class Order(_Base, TradeRequest):
Raises:
OrderError: If not successful
"""
res = await self.mt5.order_send(self.request)
res = await cls.mt5.order_send(request)
if res is None:
raise OrderError(f"Failed to send order {self.symbol}")
raise OrderError("Failed to send order %s" % request.get("symbol", ""))
if res.retcode == 10031 and connection_retries < 3:
await asyncio.sleep(3**connection_retries)
return await cls.send_order(request=request, connection_retries= connection_retries + 1)
return OrderSendResult(**res._asdict())
@error_handler(log_error_msg=False)
@@ -155,9 +178,16 @@ class Order(_Base, TradeRequest):
@classmethod
async def profit_to_price(cls, *, profit: float, order_type: OrderType, volume: float, symbol: str, price_open: float):
price_close = percentage_increase(price_open, 50) if order_type == 0 else percentage_decrease(price_open, 50)
price_close = increase_value_by_pct(price_open, 50) if order_type == 0 else decrease_value_by_pct(price_open, 50)
half_profit = await cls.mt5.order_calc_profit(symbol=symbol, action=order_type, volume=volume,
price_open=price_open, price_close=price_close)
rate = profit / half_profit * 50
rate = percentage_increase(price_open, rate) if order_type == 0 else percentage_decrease(price_open, rate)
rate = increase_value_by_pct(price_open, rate) if order_type == 0 else decrease_value_by_pct(price_open, rate)
return rate
@classmethod
async def get_history_order_by_ticket(cls, *, ticket: int) -> TradeOrder | None:
res = await cls.mt5.history_orders_get(ticket=ticket)
if res is not None and len(res) > 0 and res[0].ticket == ticket:
return TradeOrder(**res[0]._asdict())
return None
+59 -94
View File
@@ -1,66 +1,42 @@
"""Handle Open positions."""
"""Positions module for managing open trading positions.
This module provides the Positions class for retrieving, managing, and
closing open positions in the MetaTrader 5 terminal.
Example:
Getting and closing positions::
positions = Positions()
open_positions = await positions.get_positions(symbol='EURUSD')
closed_count = await positions.close_all()
"""
import asyncio
from logging import getLogger
from ..core.meta_trader import MetaTrader
from ..core.base import BaseMeta
from ..core.models import TradePosition, OrderSendResult
from ..core.constants import OrderType, TradeAction
from ..core.config import Config
from ..core.meta_backtester import MetaBackTester
from ..core.exceptions import InvalidRequest
from .order import Order
logger = getLogger(__name__)
class Positions:
class Positions(metaclass=BaseMeta):
"""Get Open Positions.
Attributes:
mt5 (MetaTrader): MetaTrader instance.
"""
mt5: MetaTrader | MetaBackTester
positions: tuple[TradePosition, ...]
config: Config
def __new__(cls, *args, **kwargs):
if not hasattr(cls, "config"):
cls.config = Config()
if not hasattr(cls, "mt5"):
cls.mt5 = MetaTrader()
return super().__new__(cls)
def __init__(self):
self.positions = ()
async def get_positions(self, *, symbol: str = None, ticket: int = None, group: str = None) -> tuple[TradePosition, ...]:
"""Get open positions with the ability to filter by symbol, ticket or group of symbols.
Args:
symbol (Optional[str]): Financial instrument name. If a symbol is provided, the ticket is ignored.
ticket (Optional[int]): Position ticket.
group (Optional[str]): Group of symbols.
Returns:
tuple[TradePosition, ...]: A tuple of open trade positions
"""
kwargs = {}
if symbol is not None:
kwargs["symbol"] = symbol
ticket = None
if ticket is not None:
kwargs["ticket"] = ticket
if group is not None:
kwargs["group"] = group
positions = await self.mt5.positions_get(**kwargs)
if positions is not None:
self.positions = tuple(TradePosition(**pos._asdict()) for pos in positions)
return self.positions
logger.warning("Failed to get open positions")
return ()
@classmethod
async def get_all_positions(cls, *, symbol: str = None, ticket: int = None, group: str = None) -> tuple[TradePosition, ...]:
async def get_positions(cls, *, symbol: str = None, ticket: int = None, group: str = None) -> tuple[TradePosition, ...]:
kwargs = {}
if symbol is not None:
kwargs["symbol"] = symbol
@@ -84,11 +60,8 @@ class Positions:
Returns:
TradePosition: Return an open position
"""
positions = await cls.mt5.positions_get(ticket=ticket)
position = positions[0] if positions else None
if position is None or position.ticket != ticket:
return None
return TradePosition(**position._asdict())
positions = await cls.get_positions(ticket=ticket)
return pos if len(positions) and (pos := positions[0]).ticket == ticket else None
@classmethod
async def get_positions_by_symbol(cls, *, symbol: str) -> tuple[TradePosition, ...]:
@@ -99,11 +72,10 @@ class Positions:
Returns:
tuple[TradePosition, ...]: A tuple of open trade positions
"""
positions = await cls.mt5.positions_get(symbol=symbol)
return tuple(TradePosition(**pos._asdict()) for pos in (positions or ()))
return await cls.get_positions(symbol=symbol)
@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) -> tuple[bool, OrderSendResult]:
"""Close an open position for the trading account using the ticket and other parameters.
Args:
@@ -113,64 +85,57 @@ class Positions:
volume (float): Volume to close.
order_type (OrderType): Order type.
"""
order = Order(
action=TradeAction.DEAL,
price=price,
position=ticket,
symbol=symbol,
volume=volume,
type=order_type.opposite,
)
return await order.send()
req = dict(action=TradeAction.DEAL, price=price, position=ticket, symbol=symbol, volume=volume,
type=OrderType(order_type).opposite)
res = await Order.send_order(request=req)
if res.retcode != 10009:
cop = await Order.get_history_order_by_ticket(ticket=ticket)
res.comment = f"{res.comment}: Position is already closed"
if cop is None:
return False, res
return True, res
@classmethod
async def close_position_by_ticket(cls, *, ticket: int) -> OrderSendResult | None:
async def close_position_by_ticket(cls, *, ticket: int) -> tuple[bool, OrderSendResult]:
"""Close an open position using the ticket."""
position = await cls.get_position_by_ticket(ticket=ticket)
if position is None:
return None
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()
cop = await Order.get_history_order_by_ticket(ticket=ticket)
if cop is None:
raise InvalidRequest("Failed to get open position with %d" % ticket)
return True, OrderSendResult(order=ticket, comment="Position is already closed")
return await cls.close_position(position=position)
@staticmethod
async def close_position(*, position: TradePosition):
async def close_position(*, position: TradePosition) -> tuple[bool, OrderSendResult]:
"""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,
)
return await order.send()
async def close_all(self) -> int:
"""Close all open positions for the trading account. Specify a symbol or group to filter positions.
Returns:
int: Return number of positions closed.
"""
positions = self.positions or await self.get_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)])
req = dict(position=position.ticket, symbol=position.symbol, volume=position.volume,
type=position.type.opposite, price=position.price_current, action=TradeAction.DEAL)
res = await Order.send_order(request=req)
if res.retcode != 10009:
cop = await Order.get_history_order_by_ticket(ticket=position.ticket)
res.comment = f"{res.comment}: Position is already closed"
if cop is None:
return False, res
return True, res
@classmethod
async def close_all_positions(cls):
positions = await cls.mt5.positions_get()
async def close_positions(cls, *, positions: tuple[TradePosition, ...]) -> tuple[tuple[bool, OrderSendResult], ...]:
"""Close open positions for the trading account."""
results = await asyncio.gather(
*(cls.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)])
return tuple(res for res in results if isinstance(res, tuple) and res[0])
@classmethod
async def close_all_positions(cls) -> tuple[OrderSendResult, ...]:
positions = await cls.get_positions()
if positions is None:
return ()
results = await asyncio.gather(
*(cls.close_position(position=position) for position in positions), return_exceptions=True
)
return tuple(res[1] for res in results if isinstance(res, tuple) and res[0])
@classmethod
async def get_total_positions(cls) -> int:
+15 -1
View File
@@ -1,4 +1,16 @@
"""Risk Assessment and Management"""
"""Risk Assessment and Management (RAM) module.
This module provides the RAM class for calculating position sizes,
managing risk per trade, and enforcing trading limits based on
account equity and open positions.
Example:
Using RAM in a trader::
ram = RAM(risk=2, risk_to_reward=3)
amount_to_risk = await ram.get_amount()
can_trade = await ram.check_open_positions()
"""
from .account import Account
from .positions import Positions
@@ -13,6 +25,7 @@ class RAM:
max_amount: float
loss_limit: int
open_limit: int
positions: Positions
def __init__(self, **kwargs):
"""Initialize Risk Assessment and Management with the provided keyword arguments.
@@ -25,6 +38,7 @@ class RAM:
loss_limit (int): Maximum number of losing positions. Defaults to 1
open_limit (int): Maximum number of open positions. Defaults to 1
fixed_amount (float): Fixed amount to risk per trade. Defaults to None
positions (Positions): Positions object.
"""
self.account = Account()
self.positions = Positions()
+167 -30
View File
@@ -1,5 +1,29 @@
"""Result module for recording and storing trade results.
This module provides the Result class for saving trade results and
strategy parameters to CSV, JSON, or SQL formats for record keeping
and analysis.
Example:
Recording a trade result::
result = Result(
result=order_result,
parameters={'volume': 0.1, 'magic': 12345},
name='MyStrategy',
time=1705312800000,
expected_profit=50.0
)
await result.save() # Saves to configured format (csv/json/sql)
Recording synchronously::
result.save_sync(trade_record_mode='csv') # Force CSV format
"""
import csv
import json
from datetime import datetime
from logging import getLogger
from typing import Iterable, Literal
from threading import Lock
@@ -12,51 +36,119 @@ logger = getLogger(__name__)
class Result:
"""A base class for handling trade results and strategy parameters for record keeping and reference purpose.
"""Handler for trade results and strategy parameters for record keeping.
Manages the recording of trade execution results and associated strategy
parameters to various storage formats (CSV, JSON, or SQL database).
Uses thread-safe locking for concurrent write operations.
Attributes:
config (Config): The configuration object
name: Any desired name for the result file object
config (Config): The configuration object for accessing settings.
lock (Lock): Thread lock for safe concurrent file operations.
parameters (dict): Strategy parameters associated with the trade.
result (OrderSendResult): The trade execution result from the broker.
name (str): Name for the result file/record.
extra_params (dict): Additional parameters passed via kwargs.
Example:
Recording a trade result::
result = Result(
result=order_result,
parameters={'symbol': 'EURUSD', 'strategy': 'MA_Cross'},
name='MyStrategy'
)
await result.save() # Saves to configured format
"""
config: Config
lock = Lock()
def __init__(self, *, result: OrderSendResult, parameters: dict = None, name: str = "", **kwargs):
"""
Prepare result data
"""Initialize the Result instance with trade data and parameters.
Args:
result:
parameters:
name:
result: The order execution result from the broker containing
deal details, order ticket, prices, and status.
parameters: Strategy parameters to associate with this trade.
Defaults to empty dict if not provided.
name: Name for the result file or record. If empty, uses
the 'name' key from parameters or defaults to 'Trades'.
**kwargs: Additional parameters to include in the record:
- time (float): Trade timestamp in milliseconds.
- expected_profit (float): Expected profit at entry.
- Any other custom fields to store.
Example:
>>> result = Result(
... result=order_result,
... parameters={'magic': 12345, 'volume': 0.1},
... name='ScalpingStrategy',
... time=1705312800000,
... expected_profit=25.5
... )
"""
self.config = Config()
self.parameters = parameters or {}
self.result = result
self.name = name or self.parameters.get("name", "Trades")
self.extra_params = kwargs
if not self.extra_params.get("time"):
self.extra_params["time"] = datetime.now().timestamp() * 1000
def to_sql(self):
"""Save trade result to a SQLite database.
Creates a ResultDB record and persists it to the configured SQLite
database. The record includes order details, parameters, and metadata.
Note:
Requires 'symbol' key to be present in parameters dict.
Logs an error if the save operation fails.
"""
try:
res = self.result.get_dict(include=set(ResultDB.fields()))
res["parameters"] = self.parameters
res["name"] = self.name
res["date"] = self.extra_params.get("date", "")
res["symbol"] = self.parameters["symbol"]
db = ResultDB(**res)
data = self.get_data() | {"name": self.name}
data = ResultDB.filter_dict(data)
db = ResultDB(**data)
db.save(commit=True)
db.close()
except Exception as err:
logger.error("%s: Error occurred while saving", err)
def get_data(self) -> dict:
res = self.result.get_dict(exclude={"retcode", "comment", "retcode_external", "request_id", "request"})
return self.parameters | res | {"actual_profit": 0, "closed": False, "win": False} | self.extra_params
"""Prepare trade data for storage.
async def save(self, *, trade_record_mode: Literal["csv", "json"] = None):
"""Record trade results as a csv or json file
Combines the order result, strategy parameters, request details,
and extra parameters into a single dictionary suitable for storage.
Returns:
dict: Combined dictionary containing:
- Strategy parameters from self.parameters
- Order result fields (deal, order, volume, price, bid, ask)
- Request fields (symbol, type, sl, tp)
- Default tracking fields (profit=0, closed=False, win=False)
- Extra parameters (time, expected_profit, etc.)
Note:
Fields 'retcode', 'comment', 'retcode_external', 'request_id',
and 'request' are excluded from the order result.
"""
res = self.result.get_dict(exclude={"retcode", "comment", "retcode_external", "request_id", "request"})
req = self.result.request.get_dict(include={"symbol", "type", "sl", "tp"})
return res | {"profit": 0, "closed": False, "win": False, "parameters": self.parameters} | req |self.extra_params
async def save(self, *, trade_record_mode: Literal["csv", "json", "sql"] = None):
"""Save trade results asynchronously to the configured storage format.
Thread-safe method that records trade results to CSV, JSON, or SQL
format based on configuration or explicit parameter.
Args:
trade_record_mode (Literal['csv'|'json']): Mode of saving trade records
trade_record_mode (Literal['csv', 'json', 'sql']): Storage format
to use. If None, uses the mode from Config.trade_record_mode.
Note:
Uses a threading Lock to ensure thread-safe file operations.
Logs an error for invalid trade record modes.
"""
with self.lock:
trade_record_mode = trade_record_mode or self.config.trade_record_mode
@@ -69,11 +161,19 @@ class Result:
else:
logger.error(f"Invalid trade record mode: {trade_record_mode}")
def save_sync(self, *, trade_record_mode: Literal["csv", "json"] = None):
"""Record trade results as a csv or json file
def save_sync(self, *, trade_record_mode: Literal["csv", "json", "sql"] = None):
"""Save trade results synchronously to the configured storage format.
Thread-safe synchronous method that records trade results to CSV, JSON,
or SQL format based on configuration or explicit parameter.
Args:
trade_record_mode (Literal['csv'|'json']): Mode of saving trade records
trade_record_mode (Literal['csv', 'json', 'sql']): Storage format
to use. If None, uses the mode from Config.trade_record_mode.
Note:
Uses a threading Lock to ensure thread-safe file operations.
Logs an error for invalid trade record modes.
"""
with self.lock:
trade_record_mode = trade_record_mode or self.config.trade_record_mode
@@ -87,9 +187,22 @@ class Result:
logger.error(f"Invalid trade record mode: {trade_record_mode}")
def to_csv(self):
"""Record trade results and associated parameters as a csv file"""
"""Save trade results and parameters to a CSV file.
Appends the trade record to a CSV file in the configured records
directory. Creates the file if it doesn't exist. Handles dynamic
column headers by reading existing headers and merging with new data.
The file is named '{self.name}.csv' and stored in config.records_dir.
Note:
Logs an error if the save operation fails.
"""
try:
data = self.get_data()
data["time"] = data["time"] / 1000
parameters = data.pop("parameters", {})
data |= parameters
file = self.config.records_dir / f"{self.name}.csv"
file.touch(exist_ok=True) if not file.exists() else ...
read_file = file.open("r", newline="")
@@ -109,7 +222,17 @@ class Result:
@staticmethod
def serialize(value) -> str:
"""Serialize the trade records and strategy parameters"""
"""Serialize a value to string for JSON storage.
Converts non-serializable values to their string representation
for JSON compatibility.
Args:
value: Any value to serialize.
Returns:
str: String representation of the value, or empty string on error.
"""
try:
return str(value)
except Exception as err:
@@ -117,7 +240,19 @@ class Result:
return ""
def to_json(self):
"""Save trades and strategy parameters in a json file"""
"""Save trade results and parameters to a JSON file.
Appends the trade record to a JSON array file in the configured
records directory. Creates the file with an empty array if it
doesn't exist.
The file is named '{self.name}.json' and stored in config.records_dir.
Uses the serialize method as a default handler for non-JSON-serializable
values.
Note:
Logs an error if the save operation fails.
"""
try:
file = self.config.records_dir / f"{self.name}.json"
data = self.get_data()
@@ -126,12 +261,14 @@ class Result:
with file.open("w") as fh:
json.dump([], fh, indent=2)
with file.open("r") as fh:
rows = json.load(fh)
rows.append(data)
try:
with file.open("r") as fh:
rows = json.load(fh)
rows.append(data)
except json.decoder.JSONDecodeError as _:
rows = [data]
with file.open("w") as fh:
json.dump(rows, fh, indent=2, skipkeys=True, default=self.serialize)
except Exception as err:
logger.error(f"Unable to save as json file: {err}")
+147 -11
View File
@@ -1,3 +1,36 @@
"""Result database module for SQL-based trade result storage.
This module provides the ResultDB dataclass for storing trade results
in a SQLite database. It extends the DB base class to provide ORM-style
operations for trade records.
Example:
Saving a trade result to database::
result_db = ResultDB(
deal=12345,
order=67890,
name='MyStrategy',
symbol='EURUSD',
time=1705312800.0,
volume=0.1,
price=1.0850,
type=0
)
result_db.save(commit=True)
Querying trade results::
# Get all results for a specific strategy
results = ResultDB.filter(name='MyStrategy')
# Get a specific trade by order number
trade = ResultDB.get(order=67890)
# Get all closed trades
closed_trades = ResultDB.filter(closed=True)
"""
import pickle
from dataclasses import dataclass, field
from typing import ClassVar
@@ -7,34 +40,137 @@ from ..core.db import DB
@dataclass(kw_only=True)
class ResultDB(DB):
"""Dataclass for storing trade results in a SQLite database.
Extends the DB base class to provide ORM-style persistence for trade
execution results. Each record represents a single executed trade with
its associated parameters and outcome tracking fields.
Attributes:
deal: The deal ticket number from the broker. Required.
order: The order ticket number. Primary key, must be unique.
name: Name of the strategy or result set. Required.
symbol: Trading symbol (e.g., 'EURUSD'). Required.
time: Timestamp of the trade in seconds. Required.
volume: Trade volume in lots. Required.
price: Execution/opening price. Required.
type: Order type (e.g., ORDER_TYPE_BUY=0, ORDER_TYPE_SELL=1). Required.
bid: Bid price at execution. Defaults to 0.
ask: Ask price at execution. Defaults to 0.
tp: Take profit price. Defaults to 0.
sl: Stop loss price. Defaults to 0.
price_close: Closing price of the trade. Defaults to 0.
time_close: Timestamp of the trade closing in seconds. Defaults to 0.
expected_profit: Expected profit at trade entry. Defaults to 0.
win: Whether the trade was profitable. Defaults to False.
closed: Whether the trade has been closed. Defaults to False.
profit: Final profit/loss amount. Defaults to 0.
comment: Optional trade comment. Defaults to empty string.
parameters: Strategy parameters, stored as pickled bytes in the
database. Can be dict, bytes, or str. Defaults to empty string.
Class Attributes:
_table: The database table name ('result').
Example:
>>> result = ResultDB(
... deal=123, order=456, name='Test', symbol='EURUSD',
... time=1705312800.0, volume=0.1, price=1.085, type=0
... )
>>> result.save()
"""
_table: ClassVar[str] = "result"
deal: int = field(metadata={"NOT NULL": True})
order: int = field(metadata={"PRIMARY KEY": True, "UNIQUE": True, "NOT NULL": True})
name: str = field(metadata={"NOT NULL": True})
symbol: str = field(metadata={"NOT NULL": True})
date: str = field(metadata={"NOT NULL": True})
time: float = field(metadata={"NOT NULL": True})
volume: float
price: float
bid: float
ask: float
type: int
bid: float = 0
ask: float = 0
tp: float = 0
sl: float = 0
actual_profit: float = 0
price_close: float = 0
time_close: float = 0
expected_profit: float = 0
win: bool = False
closed: bool = False
profit: float = 0
loss: float = 0
comment: str = field(default="''")
parameters: dict|bytes|str = field(default="''")
comment: str = ""
parameters: dict|bytes|str = ""
def __post_init__(self):
super().__post_init__()
if isinstance(self.parameters, bytes):
self.parameters = pickle.loads(self.parameters)
"""Initialize the ResultDB instance after dataclass initialization.
def get_data(self):
Calls the parent DB.__post_init__ and deserializes the parameters
field if it was stored as pickled bytes.
"""
if isinstance(self.parameters, bytes):
params = pickle.loads(self.parameters)
self.parameters = params if isinstance(params, dict) else {}
else:
self.parameters = self.parameters or {}
self.comment = str() if self.comment is None else self.comment
self.win = bool(self.win)
self.closed = bool(self.closed)
def get_data(self) -> dict:
"""Prepare the record data for database storage.
Converts the dataclass to a dictionary and serializes the parameters
field to pickled bytes for database storage.
Returns:
dict: Dictionary representation of the record with parameters
serialized as bytes for SQLite blob storage.
"""
data = self.asdict()
if not isinstance(params:=data["parameters"], bytes):
data["parameters"] = pickle.dumps(params, protocol=pickle.HIGHEST_PROTOCOL)
return data
@classmethod
def dump_to_csv(cls, file_path: str = None, name: str = ""):
"""Dump all records from the database table to a CSV file.
Args:
file_path (str): The path to the CSV file to write.
"""
import csv
query = {"name": name} if name else {}
records = cls.filter(**query)
if not records:
return
data_to_write = [record.asdict() for record in records]
# The 'parameters' field is a dict, which is not suitable for a single CSV cell.
# We'll flatten it, adding 'param_' prefix to each key from the parameters.
# This also handles records that might have different parameters.
processed_data = []
all_keys = set()
for item in data_to_write:
params = item.pop('parameters', {})
if isinstance(params, dict):
for p_key, p_value in params.items():
item[f'param_{p_key}'] = p_value
processed_data.append(item)
all_keys.update(item.keys())
# Ensure all dictionaries have the same set of keys
final_data = []
sorted_keys = sorted(list(all_keys))
for item in processed_data:
row = {key: item.get(key) for key in sorted_keys}
final_data.append(row)
file_path = file_path or (f"{name}.csv" if name else "") or cls.config.db_dir_name / 'result_db.csv'
with open(file_path, 'w', newline='') as output_file:
writer = csv.DictWriter(output_file, fieldnames=sorted_keys)
writer.writeheader()
writer.writerows(final_data)
+203 -48
View File
@@ -1,3 +1,37 @@
"""Sessions module for time-based trading session management.
This module provides Session and Sessions classes for defining trading
time windows and automating actions at session boundaries (e.g., closing
positions at end of day).
Classes:
Duration: Named tuple representing session duration in hours, minutes, seconds.
Session: A trading time window with start/end times and configurable actions.
Sessions: A collection of Session objects with automatic session management.
Example:
Defining and using trading sessions::
from datetime import time
from aiomql.lib.sessions import Session, Sessions
# Create sessions for different trading periods
morning = Session(start=time(8, 0), end=time(12, 0), name="Morning")
afternoon = Session(
start=time(13, 0),
end=time(17, 0),
on_end='close_all',
name="Afternoon"
)
sessions = Sessions(sessions=[morning, afternoon])
# Use as async context manager
async with sessions:
# Trading code here - executes during session hours
await execute_strategy()
"""
import asyncio
from datetime import time, timedelta, datetime, UTC
from typing import Literal, Callable, Iterable, NamedTuple
@@ -12,22 +46,39 @@ logger = getLogger(__name__)
class Duration(NamedTuple):
"""Named tuple representing a duration of time.
Attributes:
hours: Number of hours.
minutes: Number of minutes.
seconds: Number of seconds.
"""
hours: int
minutes: int
seconds: int
def delta(obj: time) -> timedelta:
"""Get the timedelta of a datetime.time object.
"""Convert a datetime.time object to a timedelta.
Args:
obj (datetime.time): A datetime.time object.
obj: A datetime.time object to convert.
Returns:
timedelta: The time represented as a timedelta from midnight.
"""
return timedelta(hours=obj.hour, minutes=obj.minute, seconds=obj.second, microseconds=obj.microsecond)
async def backtest_sleep(secs):
"""A sleep function for use during backtesting."""
"""An async sleep function for use during backtesting.
Waits for the backtest engine cursor to advance by the specified
number of seconds.
Args:
secs: Number of seconds to sleep in backtest time.
"""
config = Config()
btc = config.backtest_controller
sleep_secs = config.backtest_engine.cursor.time + secs
@@ -36,16 +87,36 @@ async def backtest_sleep(secs):
class Session:
"""A session is a time period between two datetime.time objects specified in utc.
"""A trading session representing a time period between two UTC times.
Sessions define trading windows and can execute actions automatically
at session start and end times. Common actions include closing all
positions, closing winning positions, or closing losing positions.
Attributes:
start (datetime.time): The start time of the session.
end (datetime.time): The end time of the session.
on_start (str): The action to take when the session starts. Default is None.
on_end (str): The action to take when the session ends. Default is None.
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.
start: The start time of the session in UTC.
end: The end time of the session in UTC.
on_start: Action to take when the session starts.
on_end: Action to take when the session ends.
custom_start: Custom function to call when session starts.
custom_end: Custom function to call when session ends.
name: Human-readable name for the session.
positions_manager: Positions instance for managing open positions.
config: Configuration instance.
Example:
Creating a session that closes positions at end of day::
session = Session(
start=time(9, 0),
end=time(17, 0),
on_end='close_all',
name="Trading Hours"
)
if session.in_session():
# Execute trading logic
pass
"""
def __init__(
@@ -59,17 +130,23 @@ class Session:
custom_end: Callable = None,
name: str = "",
):
"""Create a session.
Keyword Args:
start (int | datetime.time): The start time of the session in UTC.
end (int | datetime.time): The end time of the session in UTC.
on_start (Literal['close_all', 'close_win', 'close_loss', 'custom_start']): The action to take when the
session starts. Default is None.
on_end (Literal['close_all', 'close_win', 'close_loss', 'custom_end']): The action to take when the session
ends. Default is None.
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.
"""Initialize a trading session.
Args:
start: The start time of the session in UTC. Can be an integer
(hour) or a datetime.time object.
end: The end time of the session in UTC. Can be an integer
(hour) or a datetime.time object.
on_start: Action to execute when session starts. Options are:
'close_all', 'close_win', 'close_loss', 'custom_start'.
on_end: Action to execute when session ends. Options are:
'close_all', 'close_win', 'close_loss', 'custom_end'.
custom_start: Custom async callable to invoke at session start.
Used when on_start='custom_start'.
custom_end: Custom async callable to invoke at session end.
Used when on_end='custom_end'.
name: Human-readable name for the session. Defaults to
"{start}<-->{end}" format.
"""
self.start = start.replace(tzinfo=UTC) if isinstance(start, time) else time(hour=start, tzinfo=UTC)
self.end = end if isinstance(end, time) else time(hour=end, tzinfo=UTC)
@@ -81,22 +158,37 @@ class Session:
self.positions_manager = Positions()
self.config = Config()
def __contains__(self, item: time):
def __contains__(self, item: time) -> bool:
"""Check if a time falls within this session.
Args:
item: A datetime.time object to check.
Returns:
bool: True if the time is within the session, False otherwise.
"""
span = (delta(self.end) - delta(self.start)).seconds
item_span = (delta(self.end) - delta(item)).seconds
return item_span <= span
def __str__(self):
def __str__(self) -> str:
"""Return string representation of the session."""
return f"{self.start}<-->{self.end}"
def __repr__(self):
def __repr__(self) -> str:
"""Return detailed string representation of the session."""
return f"{self.start}<-->{self.end}"
def __len__(self):
def __len__(self) -> int:
"""Return the session duration in seconds."""
return int((delta(self.end) - delta(self.start)).seconds)
def in_session(self) -> bool:
"""Check if the current time is within the session."""
"""Check if the current time is within the session.
Returns:
bool: True if current time is within session bounds.
"""
now = (
datetime.now(tz=UTC).time()
if self.config.mode == "live"
@@ -105,20 +197,29 @@ class Session:
return now in self
async def begin(self):
"""Call the action specified in on_start or custom_start."""
"""Execute the action specified in on_start when session begins."""
await self.action(action=self.on_start)
async def close(self):
"""Call the action specified in on_end or custom_end."""
"""Execute the action specified in on_end when session ends."""
await self.action(action=self.on_end)
def duration(self) -> Duration:
"""Get the duration of the session in seconds."""
"""Get the duration of the session.
Returns:
Duration: Named tuple with hours, minutes, and 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, ...]):
"""Close multiple positions concurrently.
Args:
positions: Tuple of TradePosition objects to close.
"""
results = await asyncio.gather(
*(self.positions_manager.close_position(position=position) for position in positions),
return_exceptions=True,
@@ -133,24 +234,30 @@ class Session:
logger.warning(f"{pending} positions still pending") if pending else ...
async def close_all(self):
"""Close all open positions."""
open_positions = await self.positions_manager.get_positions()
await self.close_positions(positions=open_positions)
async def close_win(self):
"""Close all positions with non-negative profit."""
open_positions = await self.positions_manager.get_positions()
positions = tuple(position for position in open_positions if position.profit >= 0)
await self.close_positions(positions=positions)
async def close_loss(self):
"""Close all positions with negative profit."""
open_positions = await self.positions_manager.get_positions()
positions = tuple(position for position in open_positions if position.profit < 0)
await self.close_positions(positions=positions)
async def action(self, *, action):
"""Used by begin and close to call the action specified.
"""Execute the specified action.
Used internally by begin() and close() to dispatch actions.
Args:
action (Literal['close_all', 'close_win', 'close_loss', 'custom_start', 'custom_end']): The action to take.
action: The action to execute. One of 'close_all', 'close_win',
'close_loss', 'custom_start', 'custom_end', or None.
"""
try:
match action:
@@ -174,8 +281,12 @@ class Session:
except Exception as exe:
logger.warning(f"Failed to call action {action} due to {exe}")
def until(self):
"""Get the seconds until the session starts from the current time in seconds."""
def until(self) -> int:
"""Get seconds until the session starts.
Returns:
int: Number of seconds until session start time.
"""
if self.config.mode == "backtest":
now = datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=UTC).time()
secs = (delta(self.start) - delta(now)).seconds
@@ -185,31 +296,54 @@ class Session:
class Sessions:
"""Sessions allow you to run code at specific times of the day. It is a collection of Session objects.
Sessions are sorted by start time. The sessions object is an asynchronous context manager.
"""A collection of Session objects with automatic session management.
Sessions manages multiple trading sessions, automatically handling
transitions between sessions and waiting for session start times.
Works as an async context manager.
Attributes:
sessions (list[Session]): A list of Session objects.
current_session (Session): The current session.
sessions: List of Session objects, sorted by start time.
current_session: The currently active session, or None.
config: Configuration instance.
Example:
Using Sessions as an async context manager::
morning = Session(start=8, end=12, name="Morning")
afternoon = Session(start=13, end=17, on_end='close_all', name="Afternoon")
sessions = Sessions(sessions=[morning, afternoon])
async with sessions:
# This code runs during session hours
# Automatically waits for next session if outside hours
await execute_strategy()
"""
sessions: list[Session]
current_session: Session | None
def __init__(self, *, sessions: Iterable[Session]):
"""Initialize the Sessions collection.
Args:
sessions: Iterable of Session objects to manage.
Sessions are automatically sorted by start time.
"""
self.sessions = list(sessions)
self.sessions.sort(key=lambda x: (x.start.hour, x.end.hour))
self.current_session = None
self.config = Config()
def find(self, *, moment: time = None) -> Session | None:
"""Find a session that contains a datetime.time object, if not found return None.
"""Find a session containing the specified time.
Keyword Args:
moment (datetime.time | None): A datetime.time object. if not provided, the current time is used.
Args:
moment: Time to search for. Uses current time if not provided.
Returns:
Session | None: A Session object or None if not found.
Session | None: The matching session, or None if not found.
"""
moment = (
moment or datetime.now(tz=UTC).time()
@@ -222,17 +356,17 @@ class Sessions:
return None
def find_next(self, *, moment: time = None) -> Session:
"""Find the next session that contains a datetime.time object.
"""Find the next session after the specified time.
Args:
moment (datetime.time | None): A datetime.time object, if not provided, the current time is used.
moment: Time to search from. Uses current time if not provided.
Returns:
Session: A Session object.
Session: The next session. Wraps to first session if at end of day.
"""
moment = (
moment or datetime.now(tz=UTC).time()
if self.config.mode == "live"
if self.config.mode != "backtest"
else (datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=UTC).time())
)
for session in self.sessions:
@@ -240,18 +374,39 @@ class Sessions:
return session
return self.sessions[0]
def __contains__(self, moment: time):
def __contains__(self, moment: time) -> bool:
"""Check if a time falls within any session.
Args:
moment: Time to check.
Returns:
bool: True if time is within any session.
"""
return True if self.find(moment=moment) is not None else False
async def __aenter__(self):
"""Enter async context manager, checking and waiting for session.
Returns:
Sessions: Self reference for context manager use.
"""
await self.check()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Exit async context manager, closing current session if active."""
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."""
"""Check session state and wait for next session if needed.
Handles session transitions by:
- Continuing if already in active session
- Starting new session if one is found
- Closing previous session when transitioning
- Sleeping until next session if outside all sessions
"""
if self.current_session is not None and self.current_session.in_session():
return
+19 -1
View File
@@ -1,4 +1,22 @@
"""The base class for creating strategies."""
"""Strategy module for creating trading strategies.
This module provides the Strategy base class for implementing trading
strategies. It handles session management, sleep functions for both live
and backtest modes, and the main trading loop.
Example:
Creating a custom strategy::
from aiomql import Strategy, Symbol, TimeFrame
class MyStrategy(Strategy):
async def trade(self):
# Your trading logic here
candles = await self.symbol.copy_rates_from_pos(
timeframe=TimeFrame.H1, count=100
)
# Analyze and trade
"""
import asyncio
import time
+25 -2
View File
@@ -1,4 +1,17 @@
"""Symbol class for handling a financial instrument."""
"""Symbol module for handling financial instruments.
This module provides the Symbol class for interacting with trading
instruments in MetaTrader 5. It includes methods for retrieving
market data, ticks, rates, and symbol information.
Example:
Working with a symbol::
symbol = Symbol(name='EURUSD')
await symbol.initialize()
tick = await symbol.info_tick()
candles = await symbol.copy_rates_from_pos(timeframe=TimeFrame.H1, count=100)
"""
from datetime import datetime
from logging import getLogger
@@ -220,7 +233,7 @@ class Symbol(_Base, SymbolInfo):
)
return amount
async def compute_volume(self) -> float:
def compute_volume(self) -> 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.
@@ -373,3 +386,13 @@ class Symbol(_Base, SymbolInfo):
if ticks is not None:
return Ticks(data=ticks)
raise ValueError(f"Could not get ticks for {self.name}.")
def compute_volume_sl(self, *, amount: float, price: float, sl: float, round_down: bool = False) -> float:
raise NotImplementedError
def compute_volume_points(self, *, amount: float, points: float, round_down: bool = False) -> float:
raise NotImplementedError
@property
def pip(self) -> float:
return self.point * 10
+26 -1
View File
@@ -1 +1,26 @@
from .lib import *
"""Synchronous API for aiomql library.
This package exports synchronous versions of all core classes
with a 'Sync' suffix for use in non-async contexts.
"""
from .history import History as HistorySync
from .order import Order as OrderSync
from .positions import Positions as PositionsSync
from ..ram import RAM as RAMSync
from .sessions import Session as SessionSync, Sessions as SessionsSync
from .strategy import Strategy as StrategySync
from .symbol import Symbol as SymbolSync
from .trader import Trader as TraderSync
__all__ = [
"HistorySync",
"OrderSync",
"PositionsSync",
"RAMSync",
"SessionSync",
"SessionsSync",
"StrategySync",
"SymbolSync",
"TraderSync",
]
+145 -68
View File
@@ -1,3 +1,18 @@
"""Synchronous History module for accessing trade history.
This module provides the synchronous History class for retrieving
completed trade deals and orders from the trading account history
within a specified date range without async/await.
Example:
Getting trade history synchronously::
history = History(date_from=datetime(2024, 1, 1), date_to=datetime.now())
history.initialize()
for deal in history.deals:
print(f"Deal {deal.ticket}: {deal.profit}")
"""
from typing import ClassVar
from datetime import datetime, UTC
from logging import getLogger
@@ -6,21 +21,51 @@ from ...core.config import Config
from ...core.sync.meta_trader import MetaTrader
from ...core.models import TradeDeal, TradeOrder
from ...core.meta_backtester import MetaBackTester
from ...core.exceptions import InvalidRequest
from ...core.base import BaseMeta
logger = getLogger(__name__)
class History:
"""The history class handles completed trade deals and trade orders in the trading history of an account.
class History(metaclass=BaseMeta):
"""Handles completed trade deals and orders from account history (synchronous).
Provides synchronous methods to retrieve and filter historical trade deals
and orders within a specified date range. Supports filtering by symbol group,
order ticket, and position ID.
This is the synchronous version of the History class. Use this when you need
to access trade history without async/await syntax.
Attributes:
deals (list[TradeDeal]): Iterable of trade deals
orders (list[TradeOrder]): Iterable of trade orders
total_deals: Total number of deals
total_orders (int): Total number orders
group (str): Filter for selecting history by symbols.
mt5 (MetaTrader): MetaTrader instance
config (Config): Config instance
deals: Tuple of trade deals retrieved from history.
orders: Tuple of trade orders retrieved from history.
total_deals: Total number of deals retrieved.
total_orders: Total number of orders retrieved.
group: Symbol filter pattern for selecting history.
date_from: Start date for history query.
date_to: End date for history query.
mt5: MetaTrader or MetaBackTester instance (class variable).
config: Config instance (class variable).
Example:
Basic usage::
from datetime import datetime
from aiomql.lib.sync.history import History
history = History(
date_from=datetime(2024, 1, 1),
date_to=datetime.now()
)
history.initialize()
# Access deals and orders
print(f"Total deals: {history.total_deals}")
print(f"Total orders: {history.total_orders}")
# Filter by position
position_deals = history.get_deals_by_position(position=12345)
"""
mt5: ClassVar[MetaTrader | MetaBackTester]
config: ClassVar[Config]
@@ -29,28 +74,44 @@ class History:
total_deals: int
total_orders: int
group: str
def __new__(cls, *args, **kwargs):
if not hasattr(cls, 'config'):
cls.config = Config()
if not hasattr(cls, 'mt5'):
cls.mt5 = MetaTrader() if cls.config.mode != "backtest" else MetaBackTester()
return super().__new__(cls)
mode: str = "sync"
def __init__(
self, *, date_from: datetime | float, date_to: datetime | float, group: str = "", use_utc: bool = float
self, *, date_from: datetime | float, date_to: datetime | float, group: str = "", use_utc: bool = False
):
"""
"""Initialize a History instance with date range and filters.
Args:
date_from (datetime, float): Date the orders are requested from. Set by the 'datetime' object or as a
number of seconds elapsed since 1970.01.01.
date_from: Start date for history query. Can be a datetime object
or Unix timestamp (seconds since 1970-01-01).
date_to: End date for history query. Can be a datetime object
or Unix timestamp (seconds since 1970-01-01).
group: Symbol filter pattern for selecting history. Use '*' as
wildcard. Defaults to empty string (all symbols).
use_utc: If True, convert date_from and date_to to UTC timezone.
Defaults to False.
date_to (datetime, float): Date up to which the orders are requested. Set by the 'datetime' object or as a
number of seconds elapsed since 1970.01.01.
Example:
Create history for specific date range::
use_utc (bool): Convert date_from and date_to to UTC. Default is False.
# Using datetime objects
history = History(
date_from=datetime(2024, 1, 1),
date_to=datetime(2024, 12, 31)
)
group (str): Filter for selecting history by symbols. This defaults to an empty string
# Using timestamps
history = History(
date_from=1704067200.0, # 2024-01-01
date_to=1735689600.0 # 2024-12-31
)
# With symbol filter
history = History(
date_from=datetime(2024, 1, 1),
date_to=datetime.now(),
group="*USD*" # Only USD pairs
)
"""
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)
@@ -62,69 +123,85 @@ class History:
self.total_deals: int = 0
self.total_orders: int = 0
def initialize(self):
"""Get history deals and orders"""
deals, orders = [self.get_deals(), self.get_orders()]
self.deals = deals if isinstance(deals, tuple) else ()
self.orders = orders if isinstance(orders, tuple) else ()
def initialize(self) -> None:
"""Fetch history deals and orders from the trading account.
Retrieves deals and orders separately and stores them in the instance
attributes. Must be called before accessing deals or orders.
Note:
This is the synchronous version. If fetching deals or orders fails,
the corresponding attribute will be an empty tuple.
"""
self.deals = self.get_deals()
self.orders = self.get_orders()
self.total_deals = len(self.deals)
self.total_orders = len(self.orders)
def get_deals(self) -> tuple[TradeDeal, ...]:
"""Get deals from trading history using the parameters set in the constructor.
"""Retrieve trade deals from history.
Fetches deals from the trading history using the date range and
group filter set in the constructor.
Returns:
tuple[TradeDeal, ...]: A list of trade deals
tuple[TradeDeal, ...]: Tuple of TradeDeal objects. Returns empty
tuple if no deals found or on error.
Note:
Logs a warning if fetching deals fails.
"""
deals = self.mt5.history_deals_get(date_from=self.date_from, date_to=self.date_to, group=self.group)
if deals is not None:
return tuple(TradeDeal(**deal._asdict()) for deal in deals)
logger.warning(f"Failed to get deals")
return tuple()
return tuple(TradeDeal(**deal._asdict()) for deal in deals)
def get_deals_by_ticket(self, *, ticket: int) -> tuple[TradeDeal, ...]:
"""Call specifying the order ticket. Return all deals having the specified order ticket in the DEAL_ORDER
property.
def filter_deals_by_ticket(self, *, ticket: int) -> tuple[TradeDeal, ...]:
return tuple(deal for deal in self.deals if deal.ticket == ticket)
Args:
ticket (int): The order ticket
def filter_deals_by_position(self, *, position: int) -> tuple[TradeDeal, ...]:
return tuple(deal for deal in self.deals if deal.position_id == position)
Returns:
tuple[TradeDeal]: A tuple of all deals with the order ticket
"""
return tuple(sorted((deal for deal in self.deals if deal.order == ticket), key=lambda x: x.time_msc))
@classmethod
def get_deal_by_ticket(cls, *, ticket: int) -> TradeDeal:
deals = cls.mt5.history_deals_get(ticket=ticket)
if (deal := deals[0]).ticket == ticket:
return TradeDeal(**deal._asdict())
raise InvalidRequest("Ticket not found")
def get_deals_by_position(self, *, position: int = None) -> tuple[TradeDeal, ...]:
"""
Get all deals with the specified position ticket in the DEAL_POSITION_ID property
Args:
position (int): The position ticket
Returns:
tuple[TradeDeal]: A tuple of all deals with the position ticket
"""
return tuple(sorted((deal for deal in self.deals if deal.position_id == position), key=lambda x: x.time_msc))
@classmethod
def get_deals_by_position(cls, *, position: int = None) -> tuple[TradeDeal, ...]:
deals = cls.mt5.history_deals_get(position=position)
return tuple(TradeDeal(**deal._asdict()) for deal in deals if deal.position_id == position)
def get_orders(self) -> tuple[TradeOrder, ...]:
"""Get orders from trading history using the parameters set in the constructor or the method arguments.
"""Retrieve trade orders from history.
Fetches orders from the trading history using the date range and
group filter set in the constructor.
Returns:
list[TradeOrder]: A list of trade orders
tuple[TradeOrder, ...]: Tuple of TradeOrder objects. Returns empty
tuple if no orders found or on error.
Note:
Logs a warning if fetching orders fails.
"""
orders = self.mt5.history_orders_get(date_from=self.date_from, date_to=self.date_to, group=self.group)
return tuple(TradeOrder(**order._asdict()) for order in orders)
if orders is not None:
return tuple(TradeOrder(**order._asdict()) for order in orders)
def filter_orders_by_ticket(self, *, ticket: int) -> tuple[TradeOrder, ...]:
return tuple(order for order in self.orders if order.ticket == ticket)
logger.warning(f"Failed to get orders")
return tuple()
def filter_orders_by_position(self, *, position: int) -> tuple[TradeOrder, ...]:
return tuple(order for order in self.orders if order.position_id == position)
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))
@classmethod
def get_order_by_ticket(cls, *, ticket: int) -> TradeOrder:
orders = cls.mt5.history_orders_get(ticket=ticket)
if (order := orders[0]).ticket == ticket:
return TradeOrder(**order._asdict())
raise InvalidRequest("Ticket not found")
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)
)
@classmethod
def get_orders_by_position(cls, *, position: int) -> tuple[TradeOrder, ...]:
orders = cls.mt5.history_orders_get(position=position)
return tuple(TradeOrder(**order._asdict()) for order in orders)
+48 -20
View File
@@ -1,20 +1,34 @@
"""Synchronous Order module for trade order operations.
This module provides the synchronous Order class for creating,
checking, and sending trade orders to the MetaTrader 5 terminal
without async/await.
Example:
Sending an order synchronously::
order = Order(symbol='EURUSD', type=OrderType.BUY, volume=0.1, price=1.1000)
result = order.send()
if result.retcode == 10009:
print("Order placed successfully")
"""
import time
from logging import getLogger
from ...core.models import TradeRequest, TradeOrder, OrderCheckResult, OrderSendResult
from ...core.constants import TradeAction, OrderTime, OrderFilling, OrderType
from ...core.exceptions import OrderError
from ...core.base import _Base
from ...core.sync.meta_trader import MetaTrader
from ...utils import error_handler_sync
from ...utils.change import percentage_decrease, percentage_increase
from ...utils import error_handler_sync, decrease_value_by_pct, increase_value_by_pct
logger = getLogger(__name__)
class Order(_Base, TradeRequest):
mode: str = "sync"
"""Trade order related functions and properties. Subclass of TradeRequest."""
mt5: MetaTrader
def __init__(self, **kwargs):
"""Initialize the order object with keyword arguments, symbol must be provided.
Provide default values for action, type_time and type_filling if not provided.
@@ -49,12 +63,6 @@ class Order(_Base, TradeRequest):
"""
return cls.mt5.orders_total()
@classmethod
def cancel_order(cls, *, order: int, symbol: str) -> OrderSendResult:
"""Cancel an active pending order by ticket number."""
res = cls.mt5.order_send({"symbol": symbol, "order": order, "action": TradeAction.REMOVE})
return res
@classmethod
def get_pending_order(cls, *, ticket: int) -> TradeOrder | None:
"""
@@ -66,11 +74,10 @@ class Order(_Base, TradeRequest):
Returns:
"""
orders = cls.mt5.orders_get(ticket=ticket)
order = None
for order_ in orders:
if order_.ticket == ticket:
return TradeOrder(**order_._asdict())
return order
return None
@classmethod
def get_pending_orders(cls, *, ticket: int = 0, symbol: str = "", group: str = "") -> tuple[TradeOrder, ...]:
@@ -89,6 +96,14 @@ class Order(_Base, TradeRequest):
return tuple(TradeOrder(**order._asdict()) for order in orders)
return tuple()
@classmethod
def cancel_order(cls, *, order: int, symbol: str = "") -> OrderSendResult:
"""Cancel an active pending order by ticket number."""
res = cls.send_order(request={"order": order, "action": TradeAction.REMOVE, "symbol": symbol})
if res is None:
raise OrderError("Unable to cancel order %d:%s" % (order, symbol))
return res
def check(self, **kwargs) -> OrderCheckResult:
"""Check funds sufficiency for performing a required trading operation and the possibility of executing it.
@@ -105,6 +120,10 @@ class Order(_Base, TradeRequest):
return OrderCheckResult(**res._asdict())
def send(self) -> OrderSendResult:
return self.send_order(request=self.request)
@classmethod
def send_order(cls, *, request: dict, connection_retries=0) -> OrderSendResult:
"""Send a request to perform a trading operation from the terminal to the trade server.
Returns:
@@ -113,9 +132,12 @@ class Order(_Base, TradeRequest):
Raises:
OrderError: If not successful
"""
res = self.mt5.order_send(self.request)
res = cls.mt5.order_send(request)
if res is None:
raise OrderError(f"Failed to send order {self.symbol}")
raise OrderError("Failed to send order %s" % request.get("symbol", ""))
if res.retcode == 10031 and connection_retries < 3:
time.sleep(3**connection_retries)
return cls.send_order(request=request, connection_retries=connection_retries + 1)
return OrderSendResult(**res._asdict())
@error_handler_sync(log_error_msg=False)
@@ -158,11 +180,17 @@ class Order(_Base, TradeRequest):
return {key: value for key, value in self.dict.items() if key in self.mt5.TradeRequest.__match_args__}
@classmethod
def profit_to_price(cls, *, profit: float, order_type: OrderType, volume: float, symbol: str,
price_open: float):
price_close = percentage_increase(price_open, 50) if order_type == 0 else percentage_decrease(price_open, 50)
def profit_to_price(cls, *, profit: float, order_type: OrderType, volume: float, symbol: str, price_open: float):
price_close = increase_value_by_pct(price_open, 50) if order_type == 0 else decrease_value_by_pct(price_open, 50)
half_profit = cls.mt5.order_calc_profit(symbol=symbol, action=order_type, volume=volume,
price_open=price_open, price_close=price_close)
price_open=price_open, price_close=price_close)
rate = profit / half_profit * 50
rate = percentage_increase(price_open, rate) if order_type == 0 else percentage_decrease(price_open, rate)
rate = increase_value_by_pct(price_open, rate) if order_type == 0 else decrease_value_by_pct(price_open, rate)
return rate
@classmethod
def get_history_order_by_ticket(cls, *, ticket: int) -> TradeOrder | None:
res = cls.mt5.history_orders_get(ticket=ticket)
if res is not None and len(res) > 0 and res[0].ticket == ticket:
return TradeOrder(**res[0]._asdict())
return None
+59 -92
View File
@@ -1,64 +1,42 @@
"""Handle Open positions."""
"""Synchronous Positions module for managing open trades.
This module provides the synchronous Positions class for retrieving,
filtering, and closing open trading positions without async/await.
Example:
Managing positions synchronously::
open_positions = Positions.get_positions(symbol='EURUSD')
closed_count = Positions.close_all_positions()
"""
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 ...core.sync.meta_trader import MetaTrader
from ...core.meta_backtester import MetaBackTester
from ...core.base import BaseMeta
from ...core.exceptions import InvalidRequest
from .order import Order
logger = getLogger(__name__)
class Positions:
class Positions(metaclass=BaseMeta):
"""Get Open Positions.
Attributes:
mt5 (MetaTrader): MetaTrader instance.
"""
mt5: MetaTrader
mt5: MetaTrader | MetaBackTester
config: Config
positions: tuple[TradePosition, ...]
def __new__(cls, *args, **kwargs):
if not hasattr(cls, "config"):
cls.config = Config()
if not hasattr(cls, "mt5"):
cls.mt5 = MetaTrader()
return super().__new__(cls)
def __init__(self):
self.positions = ()
def get_positions(self, *, symbol: str = None, ticket: int = None, group: str = None) -> tuple[TradePosition, ...]:
"""Get open positions with the ability to filter by symbol, ticket or group of symbols.
Args:
symbol (Optional[str]): Financial instrument name. If a symbol is provided, the ticket is ignored.
ticket (Optional[int]): Position ticket.
group (Optional[str]): Group of symbols.
Returns:
tuple[TradePosition, ...]: A tuple of open trade positions
"""
kwargs = {}
if symbol is not None:
kwargs["symbol"] = symbol
ticket = None
if ticket is not None:
kwargs["ticket"] = ticket
if group is not None:
kwargs["group"] = group
positions = self.mt5.positions_get(**kwargs)
if positions is not None:
return tuple(TradePosition(**pos._asdict()) for pos in positions)
logger.warning("Failed to get open positions")
return ()
mode: str = "sync"
@classmethod
def get_all_positions(cls, *, symbol: str = None, ticket: int = None, group: str = None) -> tuple[
TradePosition, ...]:
def get_positions(cls, *, symbol: str = None, ticket: int = None, group: str = None) -> tuple[TradePosition, ...]:
kwargs = {}
if symbol is not None:
kwargs["symbol"] = symbol
@@ -82,11 +60,8 @@ class Positions:
Returns:
TradePosition: Return an open position
"""
positions = cls.mt5.positions_get(ticket=ticket)
position = positions[0] if positions else None
if position is None or position.ticket != ticket:
return None
return TradePosition(**position._asdict())
positions = cls.get_positions(ticket=ticket)
return pos if len(positions) and (pos := positions[0]).ticket == ticket else None
@classmethod
def get_positions_by_symbol(cls, *, symbol: str) -> tuple[TradePosition, ...]:
@@ -97,11 +72,10 @@ class Positions:
Returns:
tuple[TradePosition, ...]: A tuple of open trade positions
"""
positions = cls.mt5.positions_get(symbol=symbol)
return tuple(TradePosition(**pos._asdict()) for pos in (positions or ()))
return cls.get_positions(symbol=symbol)
@staticmethod
def close(*, ticket: int, symbol: str, price: float, volume: float, order_type: OrderType) -> OrderSendResult:
def close(*, ticket: int, symbol: str, price: float, volume: float, order_type: OrderType) -> tuple[bool, OrderSendResult]:
"""Close an open position for the trading account using the ticket and other parameters.
Args:
@@ -111,60 +85,53 @@ class Positions:
volume (float): Volume to close.
order_type (OrderType): Order type.
"""
order = Order(
action=TradeAction.DEAL,
price=price,
position=ticket,
symbol=symbol,
volume=volume,
type=order_type.opposite,
)
return order.send()
req = dict(action=TradeAction.DEAL, price=price, position=ticket, symbol=symbol, volume=volume,
type=OrderType(order_type).opposite)
res = Order.send_order(request=req)
if res.retcode != 10009:
cop = Order.get_history_order_by_ticket(ticket=ticket)
res.comment = f"{res.comment}: Position is already closed"
if cop is None:
return False, res
return True, res
@classmethod
def close_position_by_ticket(cls, *, ticket: int) -> OrderSendResult | None:
def close_position_by_ticket(cls, *, ticket: int) -> tuple[bool, OrderSendResult]:
"""Close an open position using the ticket."""
position = cls.get_position_by_ticket(ticket=ticket)
if position is None:
return None
order = Order(
position=position.ticket,
symbol=position.symbol,
volume=position.volume,
type=position.type.opposite,
price=position.price_current,
action=TradeAction.DEAL,
)
return order.send()
cop = Order.get_history_order_by_ticket(ticket=ticket)
if cop is None:
raise InvalidRequest("Failed to get open position with %d" % ticket)
return True, OrderSendResult(order=ticket, comment="Position is already closed")
return cls.close_position(position=position)
@staticmethod
def close_position(*, position: TradePosition):
def close_position(*, position: TradePosition) -> tuple[bool, OrderSendResult]:
"""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,
)
return order.send()
def close_all(self) -> int:
"""Close all open positions for the trading account. Specify a symbol or group to filter positions.
Returns:
int: Return number of positions closed.
"""
positions = self.positions or self.get_positions()
results = [self.close_position(position=position) for position in positions]
return len([res for res in results if (isinstance(res, OrderSendResult) and res.retcode == 10009)])
req = dict(position=position.ticket, symbol=position.symbol, volume=position.volume,
type=position.type.opposite, price=position.price_current, action=TradeAction.DEAL)
res = Order.send_order(request=req)
if res.retcode != 10009:
cop = Order.get_history_order_by_ticket(ticket=position.ticket)
res.comment = f"{res.comment}: Position is already closed"
if cop is None:
return False, res
return True, res
@classmethod
async def close_all_positions(cls):
positions = cls.mt5.positions_get()
def close_positions(cls, *, positions: tuple[TradePosition, ...]) -> tuple[tuple[bool, OrderSendResult], ...]:
"""Close open positions for the trading account."""
results = [cls.close_position(position=position) for position in positions]
return len([res for res in results if (isinstance(res, OrderSendResult) and res.retcode == 10009)])
return tuple(res for res in results if isinstance(res, tuple) and res[0])
@classmethod
def close_all_positions(cls) -> tuple[OrderSendResult, ...]:
positions = cls.get_positions()
if positions is None:
return ()
results = [cls.close_position(position=position) for position in positions]
return tuple(res[1] for res in results if isinstance(res, tuple) and res[0])
@classmethod
def get_total_positions(cls) -> int:
+239 -54
View File
@@ -1,8 +1,43 @@
"""Synchronous Sessions module for time-based trading session management.
This module provides synchronous Session and Sessions classes for defining
trading time windows and automating actions at session boundaries (e.g.,
closing positions at end of day) without async/await.
Classes:
Duration: Named tuple representing session duration in hours, minutes, seconds.
Session: A trading time window with start/end times and configurable actions.
Sessions: A collection of Session objects with automatic session management.
Example:
Defining and using trading sessions synchronously::
from datetime import time
from aiomql.lib.sync.sessions import Session, Sessions
# Create sessions for different trading periods
morning = Session(start=time(8, 0), end=time(12, 0), name="Morning")
afternoon = Session(
start=time(13, 0),
end=time(17, 0),
on_end='close_all',
name="Afternoon"
)
sessions = Sessions(sessions=[morning, afternoon])
# Use as context manager
with sessions:
# Trading code here - executes during session hours
pass
"""
from datetime import time, timedelta, datetime, UTC
from typing import Literal, Callable, Iterable, NamedTuple
from logging import getLogger
from time import sleep
from ...core.models import OrderSendResult, TradePosition
from ...core.config import Config
from .positions import Positions
@@ -10,39 +45,77 @@ logger = getLogger(__name__)
class Duration(NamedTuple):
"""Named tuple representing a duration of time.
Attributes:
hours: Number of hours.
minutes: Number of minutes.
seconds: Number of seconds.
"""
hours: int
minutes: int
seconds: int
def delta(obj: time) -> timedelta:
"""Get the timedelta of a datetime.time object.
"""Convert a datetime.time object to a timedelta.
Args:
obj (datetime.time): A datetime.time object.
obj: A datetime.time object to convert.
Returns:
timedelta: The time represented as a timedelta from midnight.
"""
return timedelta(hours=obj.hour, minutes=obj.minute, seconds=obj.second, microseconds=obj.microsecond)
def backtest_sleep(secs):
"""A sleep function for use during backtesting."""
"""A synchronous sleep function for use during backtesting.
Waits for the backtest engine cursor to advance by the specified
number of seconds.
Args:
secs: Number of seconds to sleep in backtest time.
"""
config = Config()
btc = config.backtest_controller
sleep_secs = config.backtest_engine.cursor.time + secs
while sleep_secs > config.backtest_engine.cursor.time:
btc.wait()
class Session:
"""A session is a time period between two datetime.time objects specified in utc.
"""A trading session representing a time period between two UTC times.
Sessions define trading windows and can execute actions automatically
at session start and end times. Common actions include closing all
positions, closing winning positions, or closing losing positions.
Attributes:
start (datetime.time): The start time of the session.
end (datetime.time): The end time of the session.
on_start (str): The action to take when the session starts. Default is None.
on_end (str): The action to take when the session ends. Default is None.
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.
start: The start time of the session in UTC.
end: The end time of the session in UTC.
on_start: Action to take when the session starts.
on_end: Action to take when the session ends.
custom_start: Custom function to call when session starts.
custom_end: Custom function to call when session ends.
name: Human-readable name for the session.
positions_manager: Positions instance for managing open positions.
config: Configuration instance.
Example:
Creating a session that closes positions at end of day::
session = Session(
start=time(9, 0),
end=time(17, 0),
on_end='close_all',
name="Trading Hours"
)
if session.in_session():
# Execute trading logic
pass
"""
def __init__(
@@ -56,17 +129,23 @@ class Session:
custom_end: Callable = None,
name: str = "",
):
"""Create a session.
Keyword Args:
start (int | datetime.time): The start time of the session in UTC.
end (int | datetime.time): The end time of the session in UTC.
on_start (Literal['close_all', 'close_win', 'close_loss', 'custom_start']): The action to take when the
session starts. Default is None.
on_end (Literal['close_all', 'close_win', 'close_loss', 'custom_end']): The action to take when the session
ends. Default is None.
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.
"""Initialize a trading session.
Args:
start: The start time of the session in UTC. Can be an integer
(hour) or a datetime.time object.
end: The end time of the session in UTC. Can be an integer
(hour) or a datetime.time object.
on_start: Action to execute when session starts. Options are:
'close_all', 'close_win', 'close_loss', 'custom_start'.
on_end: Action to execute when session ends. Options are:
'close_all', 'close_win', 'close_loss', 'custom_end'.
custom_start: Custom callable to invoke at session start.
Used when on_start='custom_start'.
custom_end: Custom callable to invoke at session end.
Used when on_end='custom_end'.
name: Human-readable name for the session. Defaults to
"{start}<-->{end}" format.
"""
self.start = start.replace(tzinfo=UTC) if isinstance(start, time) else time(hour=start, tzinfo=UTC)
self.end = end if isinstance(end, time) else time(hour=end, tzinfo=UTC)
@@ -78,22 +157,37 @@ class Session:
self.positions_manager = Positions()
self.config = Config()
def __contains__(self, item: time):
def __contains__(self, item: time) -> bool:
"""Check if a time falls within this session.
Args:
item: A datetime.time object to check.
Returns:
bool: True if the time is within the session, False otherwise.
"""
span = (delta(self.end) - delta(self.start)).seconds
item_span = (delta(self.end) - delta(item)).seconds
return item_span <= span
def __str__(self):
def __str__(self) -> str:
"""Return string representation of the session."""
return f"{self.start}<-->{self.end}"
def __repr__(self):
def __repr__(self) -> str:
"""Return detailed string representation of the session."""
return f"{self.start}<-->{self.end}"
def __len__(self):
def __len__(self) -> int:
"""Return the session duration in seconds."""
return int((delta(self.end) - delta(self.start)).seconds)
def in_session(self) -> bool:
"""Check if the current time is within the session."""
"""Check if the current time is within the session.
Returns:
bool: True if current time is within session bounds.
"""
now = (
datetime.now(tz=UTC).time()
if self.config.mode == "live"
@@ -102,49 +196,96 @@ class Session:
return now in self
def begin(self):
"""Call the action specified in on_start or custom_start."""
"""Execute the action specified in on_start when session begins."""
self.action(action=self.on_start)
def close(self):
"""Call the action specified in on_end or custom_end."""
"""Execute the action specified in on_end when session ends."""
self.action(action=self.on_end)
def duration(self) -> Duration:
"""Get the duration of the session in seconds."""
"""Get the duration of the session.
Returns:
Duration: Named tuple with hours, minutes, and seconds.
"""
hours, seconds = divmod(len(self), 3600)
minutes, seconds = divmod(seconds, 60)
return Duration(hours=hours, minutes=minutes, seconds=seconds)
def action(self, *, action):
"""Used by begin and close to call the action specified.
def close_positions(self, *, positions: tuple[TradePosition, ...]):
"""Close multiple positions.
Args:
action (Literal['close_all', 'close_win', 'close_loss', 'custom_start', 'custom_end']): The action to take.
positions: Tuple of TradePosition objects to close.
"""
closed = pending = 0
for position in positions:
try:
result = self.positions_manager.close_position(position=position)
if isinstance(result, OrderSendResult) and result.retcode == 10009:
closed += 1
else:
pending += 1
except Exception:
pending += 1
logger.info(f"Closed {closed} positions")
logger.warning(f"{pending} positions still pending") if pending else ...
def close_all(self):
"""Close all open positions."""
open_positions = self.positions_manager.get_positions()
self.close_positions(positions=open_positions)
def close_win(self):
"""Close all positions with non-negative profit."""
open_positions = self.positions_manager.get_positions()
positions = tuple(position for position in open_positions if position.profit >= 0)
self.close_positions(positions=positions)
def close_loss(self):
"""Close all positions with negative profit."""
open_positions = self.positions_manager.get_positions()
positions = tuple(position for position in open_positions if position.profit < 0)
self.close_positions(positions=positions)
def action(self, *, action):
"""Execute the specified action.
Used internally by begin() and close() to dispatch actions.
Args:
action: The action to execute. One of 'close_all', 'close_win',
'close_loss', 'custom_start', 'custom_end', or None.
"""
try:
match action:
case "close_all":
raise NotImplementedError("To be implemented")
self.close_all()
case "close_win":
raise NotImplementedError("To be implemented")
self.close_win()
case "close_loss":
raise NotImplementedError("To be implemented")
self.close_loss()
case "custom_end":
raise NotImplementedError("To be implemented")
self.custom_end()
case "custom_start":
raise NotImplementedError("To be implemented")
self.custom_start()
case _:
pass
except Exception as exe:
logger.warning(f"Failed to call action {action} due to {exe}")
def until(self):
"""Get the seconds until the session starts from the current time in seconds."""
def until(self) -> int:
"""Get seconds until the session starts.
Returns:
int: Number of seconds until session start time.
"""
if self.config.mode == "backtest":
now = datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=UTC).time()
secs = (delta(self.start) - delta(now)).seconds
@@ -154,31 +295,54 @@ class Session:
class Sessions:
"""Sessions allow you to run code at specific times of the day. It is a collection of Session objects.
Sessions are sorted by start time. The sessions object is an asynchronous context manager.
"""A collection of Session objects with automatic session management.
Sessions manages multiple trading sessions, automatically handling
transitions between sessions and waiting for session start times.
Works as a synchronous context manager.
Attributes:
sessions (list[Session]): A list of Session objects.
current_session (Session): The current session.
sessions: List of Session objects, sorted by start time.
current_session: The currently active session, or None.
config: Configuration instance.
Example:
Using Sessions as a context manager::
morning = Session(start=8, end=12, name="Morning")
afternoon = Session(start=13, end=17, on_end='close_all', name="Afternoon")
sessions = Sessions(sessions=[morning, afternoon])
with sessions:
# This code runs during session hours
# Automatically waits for next session if outside hours
execute_strategy()
"""
sessions: list[Session]
current_session: Session | None
def __init__(self, *, sessions: Iterable[Session]):
"""Initialize the Sessions collection.
Args:
sessions: Iterable of Session objects to manage.
Sessions are automatically sorted by start time.
"""
self.sessions = list(sessions)
self.sessions.sort(key=lambda x: (x.start.hour, x.end.hour))
self.current_session = None
self.config = Config()
def find(self, *, moment: time = None) -> Session | None:
"""Find a session that contains a datetime.time object, if not found return None.
"""Find a session containing the specified time.
Keyword Args:
moment (datetime.time | None): A datetime.time object. if not provided, the current time is used.
Args:
moment: Time to search for. Uses current time if not provided.
Returns:
Session | None: A Session object or None if not found.
Session | None: The matching session, or None if not found.
"""
moment = (
moment or datetime.now(tz=UTC).time()
@@ -191,17 +355,17 @@ class Sessions:
return None
def find_next(self, *, moment: time = None) -> Session:
"""Find the next session that contains a datetime.time object.
"""Find the next session after the specified time.
Args:
moment (datetime.time | None): A datetime.time object, if not provided, the current time is used.
moment: Time to search from. Uses current time if not provided.
Returns:
Session: A Session object.
Session: The next session. Wraps to first session if at end of day.
"""
moment = (
moment or datetime.now(tz=UTC).time()
if self.config.mode == "live"
if self.config.mode != "backtest"
else (datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=UTC).time())
)
for session in self.sessions:
@@ -209,18 +373,39 @@ class Sessions:
return session
return self.sessions[0]
def __contains__(self, moment: time):
def __contains__(self, moment: time) -> bool:
"""Check if a time falls within any session.
Args:
moment: Time to check.
Returns:
bool: True if time is within any session.
"""
return True if self.find(moment=moment) is not None else False
def __enter__(self):
"""Enter context manager, checking and waiting for session.
Returns:
Sessions: Self reference for context manager use.
"""
self.check()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Exit context manager, closing current session if active."""
self.current_session.close() if self.current_session is not None else ...
def check(self):
"""Check if the current session has started and if not, wait until it starts."""
"""Check session state and wait for next session if needed.
Handles session transitions by:
- Continuing if already in active session
- Starting new session if one is found
- Closing previous session when transitioning
- Sleeping until next session if outside all sessions
"""
if self.current_session is not None and self.current_session.in_session():
return
+20 -4
View File
@@ -1,6 +1,21 @@
"""The base class for creating strategies."""
"""Synchronous Strategy module for creating trading strategies.
This module provides the synchronous Strategy base class for implementing
custom trading strategies without async/await. It handles session management,
sleep functions, and the main trading loop.
Example:
Creating a custom sync strategy::
class MyStrategy(Strategy):
def trade(self):
# Synchronous trading logic
candles = self.symbol.copy_rates_from_pos(timeframe=TimeFrame.H1, count=100)
# Analyze and trade
self.sleep(secs=3600)
"""
import time
from abc import ABC
from datetime import time as dtime
from logging import getLogger
@@ -11,12 +26,13 @@ from ...core.backtesting.backtest_controller import BackTestController
from ...core.exceptions import StopTrading
from ...core.meta_backtester import MetaBackTester
from ...core.meta_trader import MetaTrader
from ..strategy import Strategy as BaseStrategy
logger = getLogger(__name__)
class Strategy(ABC):
class Strategy(BaseStrategy):
"""The base class for creating strategies.
Attributes:
@@ -157,7 +173,7 @@ class Strategy(ABC):
self.backtest_strategy()
def live_strategy(self):
"""Run the strategy."""
"""Run the strategy"""
with self as _:
logger.info("Running %s strategy on %s", self.name, self.symbol.name)
while self.running:
+16 -7
View File
@@ -1,8 +1,22 @@
"""Symbol class for handling a financial instrument."""
"""Synchronous Symbol module for handling financial instruments.
This module provides the synchronous Symbol class for interacting with trading
instruments in MetaTrader 5 without async/await. It includes methods for retrieving
market data, ticks, rates, and symbol information.
Example:
Working with a symbol synchronously::
symbol = Symbol(name='EURUSD')
symbol.initialize()
tick = symbol.info_tick()
candles = symbol.copy_rates_from_pos(timeframe=TimeFrame.H1, count=100)
"""
from datetime import datetime
from logging import getLogger
from ...core.meta_backtester import MetaBackTester
from ...core.constants import TimeFrame, CopyTicks
from ...core.base import _Base
from ...core.config import Config
@@ -32,12 +46,7 @@ class Symbol(_Base, SymbolInfo):
initialized: bool
tick: Tick
account: Account
def __new__(cls, *args, **kwargs):
instance = super().__new__(cls)
instance.__class__.config = Config()
instance.__class__.mt5 = MetaTrader()
return instance
mode: str = "sync"
def __init__(self, **kwargs):
"""Initialize the Symbol object with the name of the financial instrument.
+243
View File
@@ -0,0 +1,243 @@
"""Synchronous Trader module for order creation and trade execution.
This module provides the synchronous Trader base class for creating and managing
trade orders without async/await. It includes methods for setting stop levels,
calculating volumes based on risk, and recording trades.
Example:
Creating a custom sync trader::
class MyTrader(Trader):
def place_trade(self, order_type, sl, tp):
self.create_order_with_stops(
order_type=order_type, sl=sl, tp=tp
)
result = self.send_order()
return result
"""
from abc import ABC, abstractmethod
from datetime import datetime, UTC
from typing import TypeVar
from logging import getLogger
from ...core.models import OrderType, OrderSendResult, OrderCheckResult
from ...core.config import Config
from ...core.task_queue import QueueItem
from ..result import Result
from .order import Order
from .symbol import Symbol as _Symbol
from ..ram import RAM
from ...utils import error_handler_sync
logger = getLogger(__name__)
Symbol = TypeVar("Symbol", bound=_Symbol)
class Trader(ABC):
"""Synchronous 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.
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
config (Config): The Config instance.
"""
config: Config
ram: RAM
parameters: dict
def __init__(self, *, symbol: Symbol, ram: RAM = None):
"""Initializes the order object and RAM instance
Args:
symbol (Symbol): Financial instrument
ram (RAM): Risk Assessment and Management instance
"""
self.config = Config()
self.symbol = symbol
self.ram = ram or RAM()
self.order = Order(symbol=symbol.name)
self.parameters = {}
def set_trade_stop_levels_pips(self, *, pips: float, risk_to_reward: float = None):
"""Sets the stop loss and take profit for the order.
This method uses pips as defined for forex instruments. It is assumed
that order_type and price are already set before calling this method.
Args:
pips: Target pips for stop loss distance.
risk_to_reward: Optional risk to reward ratio. If not provided,
uses the ratio from the RAM instance.
"""
pips = pips * self.symbol.pip
sl, tp = pips, pips * (risk_to_reward or self.ram.risk_to_reward)
price = self.order.price
if self.order.type.is_long:
self.order.sl, self.order.tp = round(price - sl, self.symbol.digits), round(price + tp, self.symbol.digits)
elif self.order.type.is_short:
self.order.sl, self.order.tp = round(price + sl, self.symbol.digits), round(price - tp, self.symbol.digits)
def set_trade_stop_levels_points(self, *, points: float, risk_to_reward: float = None):
"""Set the stop loss and take profit based on points and risk to reward.
It is assumed that order_type and price are already set before calling
this method.
Args:
points: Target points for stop loss distance.
risk_to_reward: Risk to reward ratio. If not provided, uses the
ratio from the RAM instance.
"""
points = points * self.symbol.point
sl, tp = points, points * (risk_to_reward or self.ram.risk_to_reward)
price, digits = self.order.price, self.symbol.digits
if self.order.type.is_long:
self.order.sl, self.order.tp = round(price - sl, self.symbol.digits), round(price + tp, digits)
elif self.order.type.is_short:
self.order.sl, self.order.tp = round(price + sl, self.symbol.digits), round(price - tp, digits)
def create_order_with_stops(
self, *, order_type: OrderType, sl: float, tp: float, amount_to_risk: float = None
):
"""Create an order with stop loss and take profit levels.
Uses the amount to risk per trade to calculate the volume.
Args:
order_type: Order type (BUY or SELL).
sl: Stop loss price level.
tp: Take profit price level.
amount_to_risk: Amount to risk per trade in account currency.
If not provided, uses the amount from the RAM instance.
"""
amount = amount_to_risk or self.ram.get_amount()
amount = self.symbol.amount_in_quote_currency(amount=amount)
tick = self.symbol.info_tick()
price = tick.ask if order_type.is_long else tick.bid
volume = self.symbol.compute_volume_sl(amount=amount, price=price, sl=sl)
self.order.set_attributes(sl=sl, tp=tp, volume=volume, price=price, type=order_type)
def create_order_with_sl(
self, *, order_type: OrderType, sl: float, amount_to_risk: float = None, risk_to_reward: float = None
):
"""
Create an order with a given stop_loss level. Use the amount to risk per trade to calculate the volume.
Args:
order_type (OrderType): Order type
sl (float): Stop loss in price
amount_to_risk (float): Amount to risk per trade in terms of the account currency. Optional parameter,
default is the amount as computed by the RAM instance.
risk_to_reward (float): Risk to reward ratio. Optional parameter, default is the risk to reward ratio as
defined in the RAM instance.
"""
amount = amount_to_risk or self.ram.get_amount()
amount = self.symbol.amount_in_quote_currency(amount=amount)
tick = self.symbol.info_tick()
price = tick.ask if order_type == OrderType.BUY else tick.bid
dsl = abs(price - sl)
dtp = dsl * (risk_to_reward or self.ram.risk_to_reward)
tp = price + dtp if order_type == OrderType.BUY else price - dtp
volume = self.symbol.compute_volume_sl(amount=amount, price=price, sl=sl)
self.order.set_attributes(sl=sl, tp=tp, volume=volume, price=price, type=order_type)
def create_order_with_points(
self, *, order_type: OrderType, points: float, amount_to_risk: float = None, risk_to_reward: float = None
):
"""Create an order with specific points to risk. Use the amount to risk per trade to calculate the volume.
Args:
order_type (OrderType): Order type
points (float): Points to risk
amount_to_risk (float): Amount to risk per trade in terms of the account currency. Optional parameter,
default is the amount as computed by the RAM instance.
risk_to_reward (float): Risk to reward ratio. Optional parameter, default is the risk to reward ratio as
defined in the RAM instance.
"""
self.order.type = order_type
amount = amount_to_risk or self.ram.get_amount()
amount = self.symbol.amount_in_quote_currency(amount=amount)
tick = self.symbol.info_tick()
self.order.price = tick.ask if order_type == OrderType.BUY else tick.bid
volume = self.symbol.compute_volume_points(amount=amount, points=points)
self.order.volume = volume
self.set_trade_stop_levels_points(points=points, risk_to_reward=risk_to_reward)
def create_order_no_stops(self, *, order_type: OrderType, volume: float = None):
"""Create an order without setting stop loss and take profit. Using minimum lot size.
Args:
order_type (OrderType): Order type
volume (float): Volume to trade with. Optional parameter, default is the minimum lot size.
"""
tick = self.symbol.info_tick()
self.order.volume = volume or self.symbol.volume_min
self.order.price = tick.ask if order_type == OrderType.BUY else tick.bid
self.order.type = order_type
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 = self.order.check()
if check is None:
logger.warning(f"{self.order.mt5.error}: Order check failed")
return check
if check.retcode != 0:
logger.warning("Invalid order %s, for due to %s", self.symbol, check.comment)
return check
def send_order(self) -> OrderSendResult | None:
"""Send the order to the broker."""
result = self.order.send()
if result is None:
logger.warning("%s: Failed to place order.", self.order.mt5.error)
return result
if result.retcode != 10009:
logger.warning("Unable to place order for %s due to %s", self.symbol, result.comment)
return result
return result
@error_handler_sync
def record_trade(self, *, result: OrderSendResult, parameters: dict = None, name: str = "",
expected_profit: float = None, use_task_queue=True):
"""Record the trade in csv, json, or sql database.
Args:
result: Result of the order send operation.
parameters: Parameters of the trading strategy used to place the trade.
name: Name of the trading strategy.
expected_profit: Expected profit for the trade. If not provided,
calculates using order.calc_profit().
use_task_queue: If True, adds save operation to task queue.
If False, saves synchronously. Defaults to True.
"""
if self.config.record_trades is False or result.retcode != 10009:
return
params = {**parameters} if isinstance(parameters, dict) else {}
expected_profit = expected_profit or self.order.calc_profit() or 0
order = self.order.get_history_order_by_ticket(ticket=result.order)
result.request.sl = order.sl
result.request.tp = order.tp
res = Result(result=result, parameters=params, name=name, time=order.time_setup_msc, expected_profit=expected_profit)
if use_task_queue:
self.config.task_queue.add(item=QueueItem(res.save_sync), must_complete=True)
else:
res.save_sync()
@abstractmethod
def place_trade(self, *args, **kwargs):
"""Places a trade based on the order_type."""
+59 -1
View File
@@ -1,4 +1,16 @@
"""Terminal related functions and properties"""
"""Terminal module for MetaTrader 5 terminal information.
This module provides the Terminal class for retrieving information
about the connected MetaTrader 5 terminal, including version,
connection status, and available symbols.
Example:
Getting terminal info::
terminal = Terminal()
await terminal.initialize()
print(f"Terminal version: {terminal.version}")
"""
from typing import NamedTuple
from logging import getLogger
@@ -69,3 +81,49 @@ class Terminal(_Base, TerminalInfo):
int: Total number of available symbols
"""
return await self.mt5.symbols_total()
def initialize_sync(self) -> bool:
"""Establish a connection with the MetaTrader 5 terminal synchronously.
Returns:
bool: True if successful else False
"""
self.connected = self.mt5.initialize_sync()
if not self.connected:
err = self.mt5._last_error()
logger.warning(f"Failed to initialize Terminal. Error Code: {err}")
info = self.info_sync()
self.get_version_sync()
return bool(self.connected and info and self.version)
def get_version_sync(self) -> Version | None:
"""Get the MetaTrader 5 terminal version synchronously.
Returns:
Version: version of tuple as Version object
"""
res = self.mt5._version()
if res is None:
logger.error("Failed to get terminal version")
return None
self.version = Version(*res)
return self.version
def info_sync(self) -> TerminalInfo | None:
"""Get the connected MetaTrader 5 client terminal status and settings synchronously.
Returns:
Terminal: Terminal status and settings as a terminal object.
"""
info = self.mt5._terminal_info()
if info:
self.set_attributes(**info._asdict())
return info
def symbols_total_sync(self) -> int:
"""Get the number of all financial instruments in the MetaTrader 5 terminal synchronously.
Returns:
int: Total number of available symbols
"""
return self.mt5._symbols_total()
+299 -37
View File
@@ -1,30 +1,52 @@
"""Module for working with price ticks."""
"""Tick and Ticks classes for working with price tick data.
This module provides classes for handling tick-level price data from
MetaTrader 5. Includes support for technical analysis via pandas_ta
and various data manipulation operations.
Example:
Working with ticks::
ticks = await symbol.copy_ticks_from(date_from=datetime.now(), count=1000)
print(f"Latest bid: {ticks[-1].bid}")
"""
from typing import Iterable, Self
from datetime import datetime
import pandas as pd
from pandas import DataFrame, Series
import pandas_ta as ta
from ..ta_libs import pandas_ta_classic as ta
from ..core.constants import TickFlag
class Tick:
"""Price Tick of a Financial Instrument.
Represents a single price tick from MetaTrader 5, containing bid/ask prices,
volume data, and timing information. Supports dictionary-like access and
comparison operations based on timestamp.
Attributes:
time (int): Time of the last prices update for the symbol
bid (float): Current Bid price
ask (float): Current Ask price
last (float): Price of the last deal (Last)
volume (float): Volume for the current Last price
time_msc (int): Time of the last prices update for the symbol in milliseconds
flags (TickFlag): Tick flags
volume_real (float): Volume for the current Last price
Index (int): Custom attribute representing the position of the tick in a sequence.
index (int): Index of the tick in the input dataframe object.
time (float): Time of the last prices update for the symbol as Unix timestamp.
bid (float): Current Bid price.
ask (float): Current Ask price.
last (float): Price of the last deal (Last).
volume (float): Volume for the current Last price.
time_msc (float): Time of the last prices update in milliseconds.
flags (TickFlag): Tick flags indicating what data changed.
volume_real (float): Volume for the current Last price with extended accuracy.
Index (int): Position of the tick in a Ticks sequence (0-based from oldest).
index (int | float): Index of the tick in the underlying DataFrame, typically time_msc.
Example:
Creating a tick from data::
tick = Tick(bid=1.1234, ask=1.1236, last=1.1235, volume=100)
print(f"Spread: {tick.ask - tick.bid}")
"""
time: float
bid: float
ask: float
@@ -37,8 +59,24 @@ class Tick:
Index: int
def __init__(self, **kwargs):
"""Initialize the Tick class. Set attributes from keyword arguments. bid, ask, last and volume must be
present"""
"""Initialize the Tick instance with price and volume data.
Sets attributes from keyword arguments. The required fields bid, ask,
last, and volume must be present. Time defaults to current timestamp
if not provided.
Args:
**kwargs: Keyword arguments for tick attributes. Must include:
- bid (float): Current Bid price.
- ask (float): Current Ask price.
- last (float): Price of the last deal.
- volume (float): Volume for the current Last price.
Optional arguments include time, time_msc, Index, index,
flags, and volume_real.
Raises:
ValueError: If bid, ask, last, or volume are not provided.
"""
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.time = kwargs.pop("time", datetime.now().timestamp())
@@ -47,7 +85,12 @@ class Tick:
self.index = kwargs.pop("index", self.time_msc)
self.set_attributes(**kwargs)
def __repr__(self):
def __repr__(self) -> str:
"""Return a string representation of the Tick.
Returns:
str: A formatted string showing key tick attributes.
"""
return (
"%(class)s(Index=%(Index)s, time=%(time)s, bid=%(bid)s, ask=%(ask)s, last=%(last)s, volume=%(volume)s, index=%(index)s)"
% {
@@ -62,28 +105,81 @@ class Tick:
}
)
def __eq__(self, other: Self):
def __eq__(self, other: Self) -> bool:
"""Check equality based on millisecond timestamp.
Args:
other (Tick): Another Tick instance to compare.
Returns:
bool: True if both ticks have the same time_msc.
"""
return self.time_msc == other.time_msc
def __lt__(self, other: Self):
def __lt__(self, other: Self) -> bool:
"""Compare ticks chronologically by millisecond timestamp.
Args:
other (Tick): Another Tick instance to compare.
Returns:
bool: True if this tick occurred before the other.
"""
return self.time_msc < other.time_msc
def __hash__(self):
def __hash__(self) -> int:
"""Return hash based on millisecond timestamp.
Returns:
int: Hash value for the tick.
"""
return hash(self.time_msc)
def __getitem__(self, item):
def __getitem__(self, item: str):
"""Get an attribute value by name using dictionary-style access.
Args:
item (str): Name of the attribute to retrieve.
Returns:
Any: The value of the requested attribute.
Raises:
KeyError: If the attribute does not exist.
"""
return self.__dict__[item]
def __setitem__(self, key, value):
def __setitem__(self, key: str, value):
"""Set an attribute value using dictionary-style assignment.
Args:
key (str): Name of the attribute to set.
value: Value to assign to the attribute.
"""
self.__dict__[key] = value
def __iter__(self):
"""Iterate over the tick's attributes as key-value pairs.
Yields:
tuple[str, Any]: Attribute name and value pairs.
"""
return iter(self.__dict__.items())
def keys(self):
"""Return the tick's attribute names.
Returns:
dict_keys: View of the attribute names.
"""
return self.__dict__.keys()
def values(self):
"""Return the tick's attribute values.
Returns:
dict_values: View of the attribute values.
"""
return self.__dict__.values()
def dict(self, exclude: set = None, include: set = None) -> dict:
@@ -102,16 +198,54 @@ class Tick:
return {k: v for k, v in self.__dict__.items() if k in keys}
def set_attributes(self, **kwargs):
"""Set attributes from keyword arguments"""
"""Set multiple attributes from keyword arguments.
Args:
**kwargs: Attribute name-value pairs to set on the Tick instance.
"""
for key, value in kwargs.items():
setattr(self, key, value)
def to_series(self) -> pd.Series:
"""Returns a Series Object"""
"""Convert the Tick to a pandas Series.
Creates a Series from the tick's attributes, excluding the Index
and index attributes which are positional metadata.
Returns:
pd.Series: Series containing the tick's price and volume data.
"""
return Series(self.dict(exclude={"Index", "index"}))
class Ticks:
"""Container class for price ticks. Arrange in chronological order. Supports iteration, slicing and assignment"""
"""Container class for price ticks with DataFrame-like functionality.
Stores and manages a collection of price ticks arranged in chronological
order. Supports iteration, slicing, indexing, technical analysis via
pandas_ta, and various data manipulation operations.
Attributes:
time (Series): Unix timestamps for each tick.
bid (Series): Bid prices for each tick.
ask (Series): Ask prices for each tick.
last (Series): Last deal prices for each tick.
volume (Series): Volumes for each tick.
time_msc (Series): Millisecond timestamps for each tick.
flags (Series): Tick flags for each tick.
volume_real (Series): Extended accuracy volumes for each tick.
Index (Series): Sequential position indices (0-based from oldest).
index (Series): DataFrame index, typically based on time_msc.
Example:
Working with a collection of ticks::
ticks = await symbol.copy_ticks_from(date_from=datetime.now(), count=1000)
latest = ticks[-1] # Get latest tick
subset = ticks[-100:] # Get last 100 ticks
for tick in ticks:
print(tick.bid, tick.ask)
"""
time: Series
bid: Series
ask: Series
@@ -123,13 +257,21 @@ class Ticks:
Index: Series
index: Series
def __init__(self, *, data: DataFrame | Iterable | Self, flip=False):
"""Initialize the Ticks class. Creates a DataFrame of price ticks from the data argument.
def __init__(self, *, data: DataFrame | Iterable | Self, flip: bool = False):
"""Initialize the Ticks container from tick data.
Creates a DataFrame of price ticks from the provided data source.
The DataFrame is indexed by time_msc if that column is present.
Args:
data (DataFrame | Iterable): Dataframe of price ticks or any iterable object that can be converted to a
pandas DataFrame
flip (bool): If flip is True reverse data chronological order.
data (DataFrame | Iterable | Ticks): Source data for the ticks.
Can be a pandas DataFrame, another Ticks instance, or any
iterable that can be converted to a DataFrame.
flip (bool): If True, reverse the chronological order of the data.
Defaults to False.
Raises:
ValueError: If data cannot be converted to a DataFrame.
"""
if isinstance(data, DataFrame):
data = data
@@ -144,16 +286,49 @@ class Ticks:
if 'time_msc' in self._data.columns:
self._data.index = self._data.time_msc
def __repr__(self):
def __repr__(self) -> str:
"""Return a string representation of the Ticks container.
Returns:
str: String representation of the underlying DataFrame.
"""
return repr(self._data)
def __len__(self):
def __len__(self) -> int:
"""Return the number of ticks in the container.
Returns:
int: Number of ticks.
"""
return self._data.shape[0]
def __contains__(self, item: Tick) -> bool:
"""Check if a tick is in the container by comparing time_msc.
Args:
item (Tick): Tick to check for membership.
Returns:
bool: True if a tick with the same time_msc exists at the given Index.
"""
return item.time_msc == self[item.Index].time_msc
def __getattr__(self, item):
def __getattr__(self, item: str):
"""Access DataFrame columns as attributes.
Provides attribute-style access to the underlying DataFrame columns.
Special handling for 'index' (DataFrame index) and 'Index' (sequential
position numbers).
Args:
item (str): Name of the column or special attribute.
Returns:
Series: The requested column data.
Raises:
AttributeError: If the attribute is not a valid column or special name.
"""
if item in list(self._data.columns.values):
return self._data[item]
@@ -165,6 +340,29 @@ class Ticks:
raise AttributeError(f"Attribute {item} not defined on class {self.__class__.__name__}")
def __getitem__(self, index) -> Tick | Self:
"""Retrieve tick(s) by index, slice, or column name.
Supports multiple access patterns:
- Integer index: Returns a single Tick object.
- Slice: Returns a new Ticks container with the sliced data.
- String: Returns the column Series or special index.
Args:
index (int | slice | str): The index, slice, or column name.
Returns:
Tick | Ticks | Series: Single Tick, sliced Ticks, or column Series.
Raises:
TypeError: If index is not int, slice, or str.
Example:
Accessing ticks::
latest = ticks[-1] # Get last tick
subset = ticks[10:20] # Get ticks 10-19
bids = ticks['bid'] # Get bid column
"""
if isinstance(index, slice):
cls = self.__class__
data = self._data.iloc[index]
@@ -185,13 +383,27 @@ class Ticks:
raise TypeError(f"Expected int, slice or str got {type(index)}")
def __setitem__(self, index, value: Series):
def __setitem__(self, index: str, value: Series):
"""Set a column in the underlying DataFrame.
Args:
index (str): Name of the column to set.
value (Series): Series data to assign to the column.
Raises:
TypeError: If value is not a pandas Series.
"""
if isinstance(value, Series):
self._data[index] = value
return
raise TypeError(f"Expected Series got {type(value)}")
def __reversed__(self):
"""Iterate over ticks in reverse chronological order.
Yields:
Tick: Each tick from newest to oldest.
"""
for index, row in enumerate(iter(self._data[::-1].iloc)):
row = row.to_dict()
index = len(self._data) - index - 1
@@ -200,6 +412,11 @@ class Ticks:
yield Tick(**row)
def __iter__(self):
"""Iterate over ticks in chronological order.
Yields:
Tick: Each tick from oldest to newest.
"""
for index, row in enumerate(iter(self._data.iloc)):
row = row.to_dict()
row["Index"] = index
@@ -244,7 +461,18 @@ class Ticks:
return res if inplace else self.__class__(data=res)
def __iadd__(self, other: Self) -> Self:
"""Perform in place addition of candles"""
"""Perform in-place addition of ticks from another Ticks container.
Merges ticks from another Ticks instance into this one, updating
existing entries by index and adding new ones. The result is sorted
by index.
Args:
other (Ticks): Ticks container to merge.
Returns:
Ticks: This instance with merged data.
"""
data_copy = self._data.copy()
other = other._data
for index, row in zip(other.index, iter(other.iloc)):
@@ -253,16 +481,50 @@ class Ticks:
return self
def __add__(self, other: Self) -> Self:
"""Add two candles object and return a new one"""
"""Combine two Ticks containers and return a new one.
Creates a new Ticks instance containing data from both containers.
Entries are merged by index, with later values overwriting earlier
ones for duplicate indices. The result is sorted by index.
Args:
other (Ticks): Ticks container to add.
Returns:
Ticks: New Ticks instance with combined data.
"""
data = self._data.copy()
for index, row in zip(other._data.index, iter(other._data.iloc)):
data.loc[index] = row
return self.__class__(data=data.sort_index())
def add(self, obj: DataFrame | Series | Tick) -> Self:
"""Add new row(s) to the candles class."""
"""Add new tick(s) to the container.
Adds one or more ticks to the container, maintaining sorted order
by index. Supports adding individual Tick objects, pandas Series,
or entire DataFrames.
Args:
obj (DataFrame | Series | Tick): Data to add. Can be:
- Tick: A single tick object.
- Series: A row of tick data.
- DataFrame: Multiple rows of tick data.
Returns:
Ticks: This instance with the added data.
Raises:
TypeError: If obj is not a Series, DataFrame, or Tick.
Example:
Adding new tick data::
new_tick = Tick(bid=1.1234, ask=1.1236, last=1.1235, volume=100)
ticks.add(new_tick)
"""
if isinstance(obj, Series):
self._data.loc[obj.index] = obj
self._data.loc[obj.time_msc] = obj
self._data = self._data.sort_index()
return self
elif isinstance(obj, DataFrame):
@@ -277,4 +539,4 @@ class Ticks:
self._data = self._data.sort_index()
return self
else:
raise TypeError("Expected Series, DataFrame or Candle, got {}".format(type(obj)))
raise TypeError("Expected Series, DataFrame or Tick, got {}".format(type(obj)))
+180 -54
View File
@@ -1,5 +1,24 @@
"""This module contains the Records class, which is used to read and update trade records from csv files."""
"""Trade records module for managing trade result files.
This module provides the TradeRecords class for reading, updating, and
managing trade record files in CSV, JSON, and SQL formats. It updates trade
records with actual profit/loss data from closed positions.
Example:
Updating trade records asynchronously::
records = TradeRecords()
await records.update_csv_records()
await records.update_json_records()
await records.update_sql_records()
Updating trade records synchronously::
records = TradeRecords()
records.update_csv_records_sync()
records.update_json_records_sync()
"""
from datetime import datetime
import asyncio
import json
from pathlib import Path
@@ -7,6 +26,7 @@ import csv
import logging
from typing import Iterable
from .result_db import ResultDB
from ..core.config import Config
from ..core.meta_trader import MetaTrader
from ..core.meta_backtester import MetaBackTester
@@ -16,16 +36,26 @@ logger = logging.getLogger(__name__)
class TradeRecords:
"""This utility class read trade records from the csv and json files, and update them based on their closing positions.
"""Utility class for reading and updating trade records from various storage formats.
Reads trade records from CSV, JSON files, or SQL database and updates them
with actual profit/loss data from closed positions retrieved via MetaTrader 5.
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: Configuration object for accessing settings.
mt5: MetaTrader or MetaBackTester instance for retrieving trade data.
result_db: ResultDB class reference for SQL operations.
records_dir: Path to directory containing trade record files.
positions: Cached list of open positions, or None.
Example:
>>> records = TradeRecords(records_dir='/path/to/records')
>>> await records.update_csv_records()
>>> await records.update_sql_records()
"""
config: Config
mt5: MetaTrader | MetaBackTester
result_db: type[ResultDB]
positions: list[TradePosition] | None = None
def __init__(self, *, records_dir: Path | str = ""):
@@ -38,6 +68,42 @@ class TradeRecords:
self.config = Config()
self.mt5 = MetaTrader() if self.config.mode != "backtest" else MetaBackTester()
self.records_dir = records_dir or self.config.records_dir
self.result_db = ResultDB
def get_sql_records_unclosed(self):
"""Retrieve all unclosed trade records from the SQL database.
Returns:
list[ResultDB]: List of ResultDB instances where closed=False.
"""
rows = self.result_db.execute_raw("select * from result where closed = 0")
return rows
async def update_sql_records(self):
"""Update SQL trade records with actual profit/loss from closed positions.
Fetches unclosed records from the database, retrieves closing deals
from MetaTrader history, and updates records with profit, win status,
closing time, and closing price.
Note:
Uses batch processing for efficiency - all updates are committed
together at the end.
"""
conn = self.result_db.get_connection()
rows = self.get_sql_records_unclosed()
rows.sort(key=lambda r: r.time)
start = datetime.fromtimestamp(rows[0].time/1000).replace(hour=0, minute=0)
end = datetime.now().replace(hour=23, minute=59)
deals = await self.mt5.history_deals_get(date_from=start, date_to=end)
closing_deals = {deal.position_id: deal for deal in deals if deal.order != deal.position_id and deal.entry == self.mt5.DEAL_ENTRY_OUT}
for row in rows:
if row.order in closing_deals and not row.closed:
deal = closing_deals[row.order]
data = dict(profit=deal.profit, win=deal.profit > 0, closed=True, time_close=deal.time_msc, price_close=deal.price)
row.save(conn=conn, update=True, data=data, commit=False)
conn.commit()
conn.close()
def get_csv_records(self):
"""Get trade records saved as csv from records_dir folder
@@ -94,60 +160,44 @@ class TradeRecords:
except Exception as err:
logger.error(f"Error: {err}. Unable to read and update json trade records")
async def update_row(self, *, row: dict) -> dict:
"""Update a single row of entered trade in the csv or json file with the actual profit.
async def update_rows(self, rows: list[dict]) -> list[dict]:
"""Update multiple trade rows with actual profit/loss from closed positions.
Retrieves historical deals from MetaTrader for the time range covered
by the rows and updates each unclosed row with closing information.
Args:
row: A dictionary from the dictionary writer object of the csv file.
rows: List of trade record dictionaries to update. Each dict must
contain 'time' (in seconds) and 'order' keys.
Returns:
dict: A dictionary with the actual profit and win status.
list[dict]: Updated list of trade records with profit, win status,
time_close, and price_close fields populated for closed trades.
Note:
Time values in rows should be in seconds. The method converts
them to milliseconds for the MetaTrader API.
"""
try:
order = int(row["order"])
positions = self.positions or await self.mt5.positions_get()
position_ids = [position.ticket for position in positions]
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
and deal.position_id not in position_ids
)
]
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
except Exception as err:
logging.error(f"Error: {err}. Unable to update trade record")
return row
async def update_rows(self, *, rows: list[dict]) -> list[dict]:
"""Update the rows of entered trades in the csv or json file with the actual profit.
Args:
rows: A list of dictionaries.
Returns:
list[dict]: A list of dictionaries with the actual profit and win status.
"""
self.positions = await self.mt5.positions_get()
closed, unclosed = [], []
rows.sort(key=lambda _row: _row["time"])
start = datetime.fromtimestamp(float(rows[0]["time"]) / 1000).replace(hour=0, minute=0)
end = datetime.now().replace(hour=23, minute=59)
deals = await self.mt5.history_deals_get(date_from=start, date_to=end)
closing_deals = {deal.position_id: deal for deal in deals if deal.order != deal.position_id and deal.entry == self.mt5.DEAL_ENTRY_OUT}
for row in rows:
closed_ = row.get("closed", False)
closed_ = closed_.title() == "True" if isinstance(closed_, str) else closed_
if closed_:
closed.append(row)
else:
unclosed.append(row)
unclosed = await asyncio.gather(*[self.update_row(row=row) for row in unclosed])
return closed + list(unclosed)
if (self.str_to_bool(row["closed"])) is False and (deal := closing_deals.get(int(row["order"]), 0)) and deal.order != deal.position_id and deal.entry == self.mt5.DEAL_ENTRY_OUT:
row.update(profit=deal.profit, win=deal.profit > 0, closed=True, time_close=deal.time_msc/1000, price_close=deal.price)
return rows
@staticmethod
def str_to_bool(val: bool | str):
if isinstance(val, bool):
return val
elif val.lower() == "true":
return True
elif val.lower() == "false":
return False
else:
raise TypeError(f"{val} is not a valid boolean value")
async def update_csv_records(self):
"""Update csv trade records in the records_dir folder."""
@@ -166,3 +216,79 @@ class TradeRecords:
async def update_json_record(self, *, file: Path | str):
"""Update a single json trade record file"""
await self.read_update_json(file=file)
def read_update_csv_sync(self, *, file: Path):
"""Read and update csv trade records synchronously.
Args:
file: Trade record file in csv format
"""
try:
with open(file, mode="r", newline="") as fr:
reader: Iterable[dict] | csv.DictReader = csv.DictReader(fr)
rows = [row for row in reader]
rows = self.update_rows_sync(rows=rows)
with open(file, mode="w", newline="") as fw:
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")
def read_update_json_sync(self, *, file: Path):
"""Read and update json trade records synchronously.
Args:
file: Trade record file in json format
"""
try:
with open(file, mode="r") as fh:
data = json.load(fh)
rows = [row for row in data]
rows = self.update_rows_sync(rows=rows)
with open(file, mode="w") as fh:
json.dump(rows, fh, indent=2)
except Exception as err:
logger.error(f"Error: {err}. Unable to read and update json trade records")
def update_rows_sync(self, *, rows: list[dict]) -> list[dict]:
"""Update the rows of entered trades in the csv or json file with the actual profit synchronously.
Args:
rows: A list of dictionaries.
Returns:
list[dict]: A list of dictionaries with the actual profit and win status.
"""
rows.sort(key=lambda _row: _row["time"])
start = datetime.fromtimestamp(float(rows[0]["time"]) / 1000).replace(hour=0, minute=0)
end = datetime.now().replace(hour=23, minute=59)
deals = self.mt5._history_deals_get(date_from=start, date_to=end)
closing_deals = {deal.position_id: deal for deal in deals if
deal.order != deal.position_id and deal.entry == self.mt5.DEAL_ENTRY_OUT}
for row in rows:
if (self.str_to_bool(row["closed"])) is False and (deal := closing_deals.get(int(row["order"]),
0)) and deal.order != deal.position_id and deal.entry == self.mt5.DEAL_ENTRY_OUT:
row.update(profit=deal.profit, win=deal.profit > 0, closed=True, time_close=deal.time_msc / 1000,
price_close=deal.price)
return rows
def update_csv_records_sync(self):
"""Update csv trade records in the records_dir folder synchronously."""
for record in self.get_csv_records():
self.read_update_csv_sync(file=record)
def update_json_records_sync(self):
"""Update json trade records in the records_dir folder synchronously."""
for record in self.get_json_records():
self.read_update_json_sync(file=record)
def update_csv_record_sync(self, *, file: Path | str):
"""Update a single trade record csv file synchronously."""
self.read_update_csv_sync(file=file)
def update_json_record_sync(self, *, file: Path | str):
"""Update a single json trade record file synchronously."""
self.read_update_json_sync(file=file)
+67 -39
View File
@@ -1,3 +1,21 @@
"""Trader module for order creation and trade execution.
This module provides the Trader base class for creating and managing
trade orders. It includes methods for setting stop levels, calculating
volumes based on risk, and recording trades.
Example:
Creating a custom trader::
class MyTrader(Trader):
async def place_trade(self, order_type, sl, tp):
await self.create_order_with_stops(
order_type=order_type, sl=sl, tp=tp
)
result = await self.send_order()
return result
"""
from abc import ABC, abstractmethod
from datetime import datetime, UTC
from typing import TypeVar
@@ -11,7 +29,6 @@ from .order import Order
from .symbol import Symbol as _Symbol
from .ram import RAM
from ..utils import error_handler
# from .result_db import ResultDB
logger = getLogger(__name__)
Symbol = TypeVar("Symbol", bound=_Symbol)
@@ -49,57 +66,63 @@ class Trader(ABC):
def set_trade_stop_levels_pips(self, *, pips: float, risk_to_reward: float = None):
"""Sets the stop loss and take profit for the order.
This method uses pips as defined for forex instruments. It is assumed that order_type and price are already
set before calling this method.
This method uses pips as defined for forex instruments. It is assumed
that order_type and price are already set before calling this method.
Args:
pips (float): Target pips
risk_to_reward (float): Optional risk to reward ratio
pips: Target pips for stop loss distance.
risk_to_reward: Optional risk to reward ratio. If not provided,
uses the ratio from the RAM instance.
"""
pips = pips * self.symbol.pip
sl, tp = pips, pips * (risk_to_reward or self.ram.risk_to_reward)
price = self.order.price
if self.order.type == OrderType.BUY:
if self.order.type.is_long:
self.order.sl, self.order.tp = round(price - sl, self.symbol.digits), round(price + tp, self.symbol.digits)
elif self.order.type == OrderType.SELL:
elif self.order.type.is_short:
self.order.sl, self.order.tp = round(price + sl, self.symbol.digits), round(price - tp, self.symbol.digits)
def set_trade_stop_levels_points(self, *, points: float, risk_to_reward: float = None):
"""Set the stop loss and take profit levels of the order based on the points and the risk to reward ratio.
It is assumed that order_type and price are already set before calling this method.
"""Set the stop loss and take profit based on points and risk to reward.
It is assumed that order_type and price are already set before calling
this method.
Args:
points (float): Target points
risk_to_reward (float): Risk to reward ratio
points: Target points for stop loss distance.
risk_to_reward: Risk to reward ratio. If not provided, uses the
ratio from the RAM instance.
"""
points = points * self.symbol.point
sl, tp = points, points * (risk_to_reward or self.ram.risk_to_reward)
price, digits = self.order.price, self.symbol.digits
if self.order.type == OrderType.BUY:
if self.order.type.is_long:
self.order.sl, self.order.tp = round(price - sl, self.symbol.digits), round(price + tp, digits)
elif self.order.type == OrderType.SELL:
elif self.order.type.is_short == OrderType.SELL:
self.order.sl, self.order.tp = round(price + sl, self.symbol.digits), round(price - tp, digits)
async def create_order_with_stops(
self, *, order_type: OrderType, sl: float, tp: float, amount_to_risk: float = None
):
"""Create an order with stop loss and take profit levels. Use the amount to risk per trade to
calculate the volume.
"""Create an order with stop loss and take profit levels.
Uses the amount to risk per trade to calculate the volume.
Args:
order_type (OrderType): Order type
sl (float): Stop loss in price
tp (float): Take profit in price
amount_to_risk (float): Amount to risk per trade in terms of the account currency. Optional parameter,
default is the amount as computed by the RAM instance.
order_type: Order type (BUY or SELL).
sl: Stop loss price level.
tp: Take profit price level.
amount_to_risk: Amount to risk per trade in account currency.
If not provided, uses the amount from the RAM instance.
"""
amount = amount_to_risk or await self.ram.get_amount()
amount = await self.symbol.amount_in_quote_currency(amount=amount)
tick = await self.symbol.info_tick()
price = tick.ask if order_type == OrderType.BUY else tick.bid
volume = await self.symbol.compute_volume_sl(amount=amount, price=price, sl=sl)
price = tick.ask if order_type.is_long else tick.bid
volume = self.symbol.compute_volume_sl(amount=amount, price=price, sl=sl)
self.order.set_attributes(sl=sl, tp=tp, volume=volume, price=price, type=order_type)
async def create_order_with_sl(
@@ -123,7 +146,7 @@ class Trader(ABC):
dsl = abs(price - sl)
dtp = dsl * (risk_to_reward or self.ram.risk_to_reward)
tp = price + dtp if order_type == OrderType.BUY else price - dtp
volume = await self.symbol.compute_volume_sl(amount=amount, price=price, sl=sl)
volume = self.symbol.compute_volume_sl(amount=amount, price=price, sl=sl)
self.order.set_attributes(sl=sl, tp=tp, volume=volume, price=price, type=order_type)
async def create_order_with_points(
@@ -144,7 +167,7 @@ class Trader(ABC):
amount = await self.symbol.amount_in_quote_currency(amount=amount)
tick = await self.symbol.info_tick()
self.order.price = tick.ask if order_type == OrderType.BUY else tick.bid
volume = await self.symbol.compute_volume_points(amount=amount, points=points)
volume = self.symbol.compute_volume_points(amount=amount, points=points)
self.order.volume = volume
self.set_trade_stop_levels_points(points=points, risk_to_reward=risk_to_reward)
@@ -189,26 +212,31 @@ class Trader(ABC):
return result
@error_handler
async def record_trade(self, *, result: OrderSendResult, parameters: dict = None, name: str = ""):
"""Record the trade in csv or json.
async def record_trade(self, *, result: OrderSendResult, parameters: dict = None, name: str = "",
expected_profit: float = None, use_task_queue=True):
"""Record the trade in csv, json, or sql database.
Args:
result (OrderSendResult): Result of the order send
parameters (dict): parameters of the trading strategy used to place the trade
name (str): Name of the trading strategy
result: Result of the order send operation.
parameters: Parameters of the trading strategy used to place the trade.
name: Name of the trading strategy.
expected_profit: Expected profit for the trade. If not provided,
calculates using order.calc_profit().
use_task_queue: If True, adds save operation to task queue.
If False, saves immediately. Defaults to True.
"""
if self.config.record_trades is False or result.retcode != 10009:
return
params = {**parameters} if isinstance(parameters, dict) else {}
profit = await self.order.calc_profit()
# params["expected_profit"] = profit
date = (
datetime.now()
if self.config.mode == "live"
else datetime.fromtimestamp(self.config.backtest_engine.cursor.time, tz=UTC)
)
# params["date"] = date.strftime("%Y-%m-%d %H:%M:%S.%f")
res = Result(result=result, parameters=params, name=name, date=date, expected=profit)
self.config.task_queue.add(item=QueueItem(res.save), must_complete=True)
expected_profit = expected_profit or await self.order.calc_profit() or 0
order = await self.order.get_history_order_by_ticket(ticket=result.order)
result.request.sl = order.sl
result.request.tp = order.tp
res = Result(result=result, parameters=params, name=name, time=order.time_setup_msc, expected_profit=expected_profit)
if use_task_queue:
self.config.task_queue.add(item=QueueItem(res.save), must_complete=True)
else:
await res.save()
@abstractmethod
async def place_trade(self, *args, **kwargs):
@@ -0,0 +1,28 @@
name = "pandas-ta-classic"
"""
.. moduleauthor:: Kevin Johnson
"""
# Import metadata from _meta module to avoid circular imports
from ._meta import (
Category,
Imports,
version,
CANGLE_AGG,
EXCHANGE_TZ,
RATE,
)
# Import core functionality
from .core import *
__version__ = version
__description__ = (
"An easy to use Python 3 Pandas Extension with 130+ Technical Analysis Indicators. "
"Can be called from a Pandas DataFrame or standalone like TA-Lib. Correlation tested with TA-Lib. "
"This is the classic/community maintained version."
)
__all__ = [
]
@@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
"""
Meta information for pandas-ta-classic
Contains Category definitions, version information, and import checks.
"""
from importlib.util import find_spec
from pathlib import Path
# Version information - dynamically determined from git tags via setuptools_scm
try:
# Try to import version from setuptools_scm generated file
from pandas_ta_classic._version import version as __version__
except ImportError:
# Fallback: try to get version from installed package metadata
try:
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("pandas-ta-classic")
except PackageNotFoundError:
__version__ = "0.0.0" # Fallback if package not installed
except ImportError:
# Fallback for Python < 3.8
try:
from pkg_resources import get_distribution, DistributionNotFound
try:
_dist = get_distribution("pandas-ta-classic")
__version__ = _dist.version
except DistributionNotFound:
__version__ = "0.0.0" # Fallback if package not installed
except ImportError:
__version__ = "0.0.0" # Final fallback
version = __version__
# Import availability checks
# These correspond to the optional dependencies defined in pyproject.toml
Imports = {
"alphaVantage-api": find_spec("alphaVantageAPI") is not None,
"backtrader": find_spec("backtrader") is not None,
"cython": find_spec("cython") is not None,
"matplotlib": find_spec("matplotlib") is not None,
"mplfinance": find_spec("mplfinance") is not None,
"numba": find_spec("numba") is not None,
"scipy": find_spec("scipy") is not None,
"sklearn": find_spec("sklearn") is not None,
"statsmodels": find_spec("statsmodels") is not None,
"stochastic": find_spec("stochastic") is not None,
"talib": find_spec("talib") is not None,
"tqdm": find_spec("tqdm") is not None,
"vectorbt": find_spec("vectorbt") is not None,
"yaml": find_spec("yaml") is not None,
"yfinance": find_spec("yfinance") is not None,
}
def _build_category_dict():
"""
Dynamically build the Category dictionary by scanning the package directory structure.
This function automatically discovers all indicator modules by:
1. Finding all subdirectories in pandas_ta_classic (except special ones like __pycache__)
2. For each subdirectory, listing all .py files (except __init__.py)
3. Building a dictionary mapping category names to lists of indicator names
Returns:
dict: Category dictionary mapping category names to lists of indicator function names
"""
categories = {}
# Get the directory containing this file (pandas_ta_classic/)
package_dir = Path(__file__).parent
# Define categories that should be included (subdirectories with indicators)
# This excludes utility directories that don't contain indicators
valid_categories = {
"candles",
"cycles",
"momentum",
"overlap",
"performance",
"statistics",
"trend",
"volatility",
"volume",
}
# Scan each subdirectory
for category_path in package_dir.iterdir():
# Skip if not a directory or not a valid category
if not category_path.is_dir():
continue
category_name = category_path.name
# Skip special directories and non-indicator directories
if category_name.startswith("_") or category_name.startswith("."):
continue
if category_name == "__pycache__":
continue
if category_name not in valid_categories:
continue
# Find all .py files in this category (excluding __init__.py)
indicators = []
for file_path in category_path.glob("*.py"):
if file_path.name != "__init__.py":
# Remove .py extension to get the indicator name
indicators.append(file_path.stem)
# Sort indicators alphabetically for consistency
if indicators:
categories[category_name] = sorted(indicators)
return categories
# Dynamically build the Category dictionary
# This replaces the previous hardcoded dictionary and automatically
# stays in sync with the filesystem structure
Category = _build_category_dict()
CANGLE_AGG = {
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
}
# https://www.worldtimezone.com/markets24.php
EXCHANGE_TZ = {
"NZSX": 12,
"ASX": 11,
"TSE": 9,
"HKE": 8,
"SSE": 8,
"SGX": 8,
"NSE": 5.5,
"DIFX": 4,
"RTS": 3,
"JSE": 2,
"FWB": 1,
"LSE": 1,
"BMF": -2,
"NYSE": -4,
"TSX": -4,
}
RATE = {
"DAYS_PER_MONTH": 21,
"MINUTES_PER_HOUR": 60,
"MONTHS_PER_YEAR": 12,
"QUARTERS_PER_YEAR": 4,
"TRADING_DAYS_PER_YEAR": 252, # Keep even
"TRADING_HOURS_PER_DAY": 6.5,
"WEEKS_PER_YEAR": 52,
"YEARLY": 1,
}
@@ -4,13 +4,3 @@ from .cdl_inside import cdl_inside
from .cdl_pattern import cdl_pattern, cdl, ALL_PATTERNS as CDL_PATTERN_NAMES
from .cdl_z import cdl_z
from .ha import ha
__all__ = [
"cdl_doji",
"cdl_inside",
"cdl_pattern",
"cdl",
"CDL_PATTERN_NAMES",
"cdl_z",
"ha",
]
@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# Candle Doji (CDL_DOJI)
from ..overlap.sma import sma
from ..utils import get_offset, high_low_range, is_percent
from ..utils import real_body, verify_series
def cdl_doji(
open_,
high,
low,
close,
length=None,
factor=None,
scalar=None,
asint=True,
offset=None,
**kwargs,
):
"""Indicator: Candle Type - Doji"""
# Validate Arguments
length = int(length) if length and length > 0 else 10
factor = float(factor) if is_percent(factor) else 10
scalar = float(scalar) if scalar else 100
open_ = verify_series(open_, length)
high = verify_series(high, length)
low = verify_series(low, length)
close = verify_series(close, length)
offset = get_offset(offset)
naive = kwargs.pop("naive", False)
if open_ is None or high is None or low is None or close is None:
return
# Calculate Result
body = real_body(open_, close).abs()
hl_range = high_low_range(high, low).abs()
hl_range_avg = sma(hl_range, length)
doji = body < 0.01 * factor * hl_range_avg
if naive:
doji.iloc[:length] = body < 0.01 * factor * hl_range
if asint:
doji = scalar * doji.astype(int)
# Offset
if offset != 0:
doji = doji.shift(offset)
# Handle fills
if "fillna" in kwargs:
doji.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
doji.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
doji.bfill(inplace=True)
# Name and Categorize it
doji.name = f"CDL_DOJI_{length}_{0.01 * factor}"
doji.category = "candles"
return doji
cdl_doji.__doc__ = """Candle Type: Doji
A candle body is Doji, when it's shorter than 10% of the
average of the 10 previous candles' high-low range.
Sources:
TA-Lib: 96.56% Correlation
Calculation:
Default values:
length=10, percent=10 (0.1), scalar=100
ABS = Absolute Value
SMA = Simple Moving Average
BODY = ABS(close - open)
HL_RANGE = ABS(high - low)
DOJI = scalar IF BODY < 0.01 * percent * SMA(HL_RANGE, length) ELSE 0
Args:
open_ (pd.Series): Series of 'open's
high (pd.Series): Series of 'high's
low (pd.Series): Series of 'low's
close (pd.Series): Series of 'close's
length (int): The period. Default: 10
factor (float): Doji value. Default: 100
scalar (float): How much to magnify. Default: 100
asint (bool): Keep results numerical instead of boolean. Default: True
Kwargs:
naive (bool, optional): If True, prefills potential Doji less than
the length if less than a percentage of it's high-low range.
Default: False
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: CDL_DOJI column.
"""
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Candle Inside (CDL_INSIDE)
from ..utils import candle_color, get_offset
from ..utils import verify_series
def cdl_inside(open_, high, low, close, asbool=False, offset=None, **kwargs):
"""Indicator: Candle Type - Inside Bar"""
# Validate arguments
open_ = verify_series(open_)
high = verify_series(high)
low = verify_series(low)
close = verify_series(close)
offset = get_offset(offset)
# Calculate Result
inside = (high.diff() < 0) & (low.diff() > 0)
if not asbool:
inside *= candle_color(open_, close)
# Offset
if offset != 0:
inside = inside.shift(offset)
# Handle fills
if "fillna" in kwargs:
inside.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
inside.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
inside.bfill(inplace=True)
# Name and Categorize it
inside.name = f"CDL_INSIDE"
inside.category = "candles"
return inside
cdl_inside.__doc__ = """Candle Type: Inside Bar
An Inside Bar is a bar that is engulfed by the prior highs and lows of it's
previous bar. In other words, the current bar is smaller than it's previous bar.
Set asbool=True if you want to know if it is an Inside Bar. Note by default
asbool=False so this returns a 0 if it is not an Inside Bar, 1 if it is an
Inside Bar and close > open, and -1 if it is an Inside Bar but close < open.
Sources:
https://www.tradingview.com/script/IyIGN1WO-Inside-Bar/
Calculation:
Default Inputs:
asbool=False
inside = (high.diff() < 0) & (low.diff() > 0)
if not asbool:
inside *= candle_color(open_, close)
Args:
open_ (pd.Series): Series of 'open's
high (pd.Series): Series of 'high's
low (pd.Series): Series of 'low's
close (pd.Series): Series of 'close's
asbool (bool): Returns the boolean result. Default: False
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature
"""
@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
# Candle Pattern (CDL_PATTERN)
from typing import Sequence, Union
from pandas import Series, DataFrame
from . import cdl_doji, cdl_inside
from ..utils import get_offset, verify_series
from .. import Imports
ALL_PATTERNS = [
"2crows",
"3blackcrows",
"3inside",
"3linestrike",
"3outside",
"3starsinsouth",
"3whitesoldiers",
"abandonedbaby",
"advanceblock",
"belthold",
"breakaway",
"closingmarubozu",
"concealbabyswall",
"counterattack",
"darkcloudcover",
"doji",
"dojistar",
"dragonflydoji",
"engulfing",
"eveningdojistar",
"eveningstar",
"gapsidesidewhite",
"gravestonedoji",
"hammer",
"hangingman",
"harami",
"haramicross",
"highwave",
"hikkake",
"hikkakemod",
"homingpigeon",
"identical3crows",
"inneck",
"inside",
"invertedhammer",
"kicking",
"kickingbylength",
"ladderbottom",
"longleggeddoji",
"longline",
"marubozu",
"matchinglow",
"mathold",
"morningdojistar",
"morningstar",
"onneck",
"piercing",
"rickshawman",
"risefall3methods",
"separatinglines",
"shootingstar",
"shortline",
"spinningtop",
"stalledpattern",
"sticksandwich",
"takuri",
"tasukigap",
"thrusting",
"tristar",
"unique3river",
"upsidegap2crows",
"xsidegap3methods",
]
def cdl_pattern(
open_,
high,
low,
close,
name: Union[str, Sequence[str]] = "all",
scalar=None,
offset=None,
**kwargs,
) -> DataFrame:
"""Indicator: Candle Pattern"""
# Validate Arguments
open_ = verify_series(open_)
high = verify_series(high)
low = verify_series(low)
close = verify_series(close)
offset = get_offset(offset)
scalar = float(scalar) if scalar else 100
# Patterns that implemented in pandas-ta
pta_patterns = {
"doji": cdl_doji,
"inside": cdl_inside,
}
if name == "all":
name = ALL_PATTERNS
if type(name) is str:
name = [name]
if Imports["talib"]:
import talib.abstract as tala
result = {}
for n in name:
if n not in ALL_PATTERNS:
print(f"[X] There is no candle pattern named {n} available!")
continue
if n in pta_patterns:
pattern_result = pta_patterns[n](
open_, high, low, close, offset=offset, scalar=scalar, **kwargs
)
result[pattern_result.name] = pattern_result
else:
if not Imports["talib"]:
print(f"[X] Please install TA-Lib to use {n}. (pip install TA-Lib)")
continue
pattern_func = tala.Function(f"CDL{n.upper()}")
pattern_result = Series(
pattern_func(open_, high, low, close, **kwargs) / 100 * scalar
)
pattern_result.index = close.index
# Offset
if offset != 0:
pattern_result = pattern_result.shift(offset)
# Handle fills
if "fillna" in kwargs:
pattern_result.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
pattern_result.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
pattern_result.bfill(inplace=True)
result[f"CDL_{n.upper()}"] = pattern_result
if len(result) == 0:
return
# Prepare DataFrame to return
df = DataFrame(result)
df.name = "CDL_PATTERN"
df.category = "candles"
return df
cdl_pattern.__doc__ = """Candle Pattern
A wrapper around all candle patterns.
Examples:
Get all candle patterns (This is the default behaviour)
>>> df = df.ta.cdl_pattern(name="all")
Or
>>> df.ta.cdl("all", append=True) # = df.ta.cdl_pattern("all", append=True)
Get only one pattern
>>> df = df.ta.cdl_pattern(name="doji")
Or
>>> df.ta.cdl("doji", append=True)
Get some patterns
>>> df = df.ta.cdl_pattern(name=["doji", "inside"])
Or
>>> df.ta.cdl(["doji", "inside"], append=True)
Args:
open_ (pd.Series): Series of 'open's
high (pd.Series): Series of 'high's
low (pd.Series): Series of 'low's
close (pd.Series): Series of 'close's
name: (Union[str, Sequence[str]]): name of the patterns
scalar (float): How much to magnify. Default: 100
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.DataFrame: one column for each pattern.
"""
cdl = cdl_pattern
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# Candle Z (CDL_Z)
from pandas import DataFrame
from ..statistics import zscore
from ..utils import get_offset, verify_series
def cdl_z(
open_, high, low, close, length=None, full=None, ddof=None, offset=None, **kwargs
):
"""Indicator: Candle Type - Z Score"""
# Validate Arguments
length = int(length) if length and length > 0 else 30
ddof = int(ddof) if ddof and ddof >= 0 and ddof < length else 1
open_ = verify_series(open_, length)
high = verify_series(high, length)
low = verify_series(low, length)
close = verify_series(close, length)
offset = get_offset(offset)
full = bool(full) if full is not None and full else False
if open_ is None or high is None or low is None or close is None:
return
# Calculate Result
if full:
length = close.size
z_open = zscore(open_, length=length, ddof=ddof)
z_high = zscore(high, length=length, ddof=ddof)
z_low = zscore(low, length=length, ddof=ddof)
z_close = zscore(close, length=length, ddof=ddof)
_full = "a" if full else ""
_props = _full if full else f"_{length}_{ddof}"
df = DataFrame(
{
f"open_Z{_props}": z_open,
f"high_Z{_props}": z_high,
f"low_Z{_props}": z_low,
f"close_Z{_props}": z_close,
}
)
if full:
df.fillna(method="backfill", axis=0, inplace=True)
# Offset
if offset != 0:
df = df.shift(offset)
# Handle fills
if "fillna" in kwargs:
df.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
df.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
df.bfill(inplace=True)
# Name and Categorize it
df.name = f"CDL_Z{_props}"
df.category = "candles"
return df
cdl_z.__doc__ = """Candle Type: Z
Normalizes OHLC Candles with a rolling Z Score.
Source: Kevin Johnson
Calculation:
Default values:
length=30, full=False, ddof=1
Z = ZSCORE
open = Z( open, length, ddof)
high = Z( high, length, ddof)
low = Z( low, length, ddof)
close = Z(close, length, ddof)
Args:
open_ (pd.Series): Series of 'open's
high (pd.Series): Series of 'high's
low (pd.Series): Series of 'low's
close (pd.Series): Series of 'close's
length (int): The period. Default: 10
Kwargs:
naive (bool, optional): If True, prefills potential Doji less than
the length if less than a percentage of it's high-low range.
Default: False
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: CDL_DOJI column.
"""
@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# Heikin Ashi (HA)
from pandas import DataFrame
from ..utils import get_offset, verify_series
def ha(open_, high, low, close, offset=None, **kwargs):
"""Indicator: Candle Type - Heikin Ashi"""
# Validate Arguments
open_ = verify_series(open_)
high = verify_series(high)
low = verify_series(low)
close = verify_series(close)
offset = get_offset(offset)
# Calculate Result
m = close.size
df = DataFrame(
{
"HA_open": 0.5 * (open_.iloc[0] + close.iloc[0]),
"HA_high": high,
"HA_low": low,
"HA_close": 0.25 * (open_ + high + low + close),
}
)
for i in range(1, m):
df["HA_open"][i] = 0.5 * (df["HA_open"][i - 1] + df["HA_close"][i - 1])
df["HA_high"] = df[["HA_open", "HA_high", "HA_close"]].max(axis=1)
df["HA_low"] = df[["HA_open", "HA_low", "HA_close"]].min(axis=1)
# Offset
if offset != 0:
df = df.shift(offset)
# Handle fills
if "fillna" in kwargs:
df.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
df.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
df.bfill(inplace=True)
# Name and Categorize it
df.name = "Heikin-Ashi"
df.category = "candles"
return df
ha.__doc__ = """Heikin Ashi Candles (HA)
The Heikin-Ashi technique averages price data to create a Japanese
candlestick chart that filters out market noise. Heikin-Ashi charts,
developed by Munehisa Homma in the 1700s, share some characteristics
with standard candlestick charts but differ based on the values used
to create each candle. Instead of using the open, high, low, and close
like standard candlestick charts, the Heikin-Ashi technique uses a
modified formula based on two-period averages. This gives the chart a
smoother appearance, making it easier to spots trends and reversals,
but also obscures gaps and some price data.
Sources:
https://www.investopedia.com/terms/h/heikinashi.asp
Calculation:
HA_OPEN[0] = (open[0] + close[0]) / 2
HA_CLOSE = (open[0] + high[0] + low[0] + close[0]) / 4
for i > 1 in df.index:
HA_OPEN = (HA_OPEN[i1] + HA_CLOSE[i1]) / 2
HA_HIGH = MAX(HA_OPEN, HA_HIGH, HA_CLOSE)
HA_LOW = MIN(HA_OPEN, HA_LOW, HA_CLOSE)
How to Calculate Heikin-Ashi
Use one period to create the first Heikin-Ashi (HA) candle, using
the formulas. For example use the high, low, open, and close to
create the first HA close price. Use the open and close to create
the first HA open. The high of the period will be the first HA high,
and the low will be the first HA low. With the first HA calculated,
it is now possible to continue computing the HA candles per the formulas.
Args:
open_ (pd.Series): Series of 'open's
high (pd.Series): Series of 'high's
low (pd.Series): Series of 'low's
close (pd.Series): Series of 'close's
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.DataFrame: ha_open, ha_high,ha_low, ha_close columns.
"""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
# -*- coding: utf-8 -*-
import importlib
import os
import sys
import types
from os.path import abspath, join, exists, basename, splitext
from glob import glob
# import pandas_ta_classic
from . import AnalysisIndicators
pandas_ta_classic = importlib.import_module(__package__)
def bind(function_name, function, method):
"""
Helper function to bind the function and class method defined in a custom
indicator module to the active pandas_ta_classic instance.
Args:
function_name (str): The name of the indicator within pandas_ta_classic
function (fcn): The indicator function
method (fcn): The class method corresponding to the passed function
"""
setattr(pandas_ta_classic, function_name, function)
setattr(AnalysisIndicators, function_name, method)
def create_dir(path, create_categories=True, verbose=True):
"""
Helper function to setup a suitable folder structure for working with
custom indicators. You only need to call this once whenever you want to
setup a new custom indicators folder.
Args:
path (str): Full path to where you want your indicator tree
create_categories (bool): If True create category sub-folders
verbose (bool): If True print verbose output of results
"""
# ensure that the passed directory exists / is readable
if not exists(path):
os.makedirs(path)
if verbose:
print(f"[i] Created main directory '{path}'.")
# list the contents of the directory
# dirs = glob(abspath(join(path, '*')))
# optionally add any missing category subdirectories
if create_categories:
for sd in [*pandas_ta_classic.Category]:
d = abspath(join(path, sd))
if not exists(d):
os.makedirs(d)
if verbose:
dirname = basename(d)
print(f"[i] Created an empty sub-directory '{dirname}'.")
def get_module_functions(module):
"""
Helper function to get the functions of an imported module as a dictionary.
Args:
module: python module
Returns:
dict: module functions mapping
{
"func1_name": func1,
"func2_name": func2,...
}
"""
module_functions = {}
for name, item in vars(module).items():
if isinstance(item, types.FunctionType):
module_functions[name] = item
return module_functions
def import_dir(path, verbose=True):
# ensure that the passed directory exists / is readable
if not exists(path):
print(f"[X] Unable to read the directory '{path}'.")
return
# list the contents of the directory
dirs = glob(abspath(join(path, "*")))
# traverse full directory, importing all modules found there
for d in dirs:
dirname = basename(d)
# only look in directories which are valid pandas_ta_classic categories
if dirname not in [*pandas_ta_classic.Category]:
if verbose:
print(
f"[i] Skipping the sub-directory '{dirname}' since it's not a valid pandas_ta_classic category."
)
continue
# for each module found in that category (directory)...
for module in glob(abspath(join(path, dirname, "*.py"))):
module_name = splitext(basename(module))[0]
# ensure that the supplied path is included in our python path
if d not in sys.path:
sys.path.append(d)
# (re)load the indicator module
module_functions = load_indicator_module(module_name)
# figure out which of the modules functions to bind to pandas_ta_classic
fcn_callable = module_functions.get(module_name, None)
fcn_method_callable = module_functions.get(f"{module_name}_method", None)
if fcn_callable == None:
print(
f"[X] Unable to find a function named '{module_name}' in the module '{module_name}.py'."
)
continue
if fcn_method_callable == None:
missing_method = f"{module_name}_method"
print(
f"[X] Unable to find a method function named '{missing_method}' in the module '{module_name}.py'."
)
continue
# add it to the correct category if it's not there yet
if module_name not in pandas_ta_classic.Category[dirname]:
pandas_ta_classic.Category[dirname].append(module_name)
bind(module_name, fcn_callable, fcn_method_callable)
if verbose:
print(
f"[i] Successfully imported the custom indicator '{module}' into category '{dirname}'."
)
import_dir.__doc__ = """
Import a directory of custom indicators into pandas_ta_classic
Args:
path (str): Full path to your indicator tree
verbose (bool): If True verbose output of results
This method allows you to experiment and develop your own technical analysis
indicators in a separate local directory of your choice but use them seamlessly
together with the existing pandas_ta_classic functions just like if they were part of
pandas_ta_classic.
If you at some late point would like to push them into the pandas_ta_classic library
you can do so very easily by following the step by step instruction here
https://github.com/xgboosted/pandas-ta-classic/issues.
A brief example of usage:
1. Loading the 'ta' module:
>>> import pandas as pd
>>> import pandas_ta_classic as ta
2. Create an empty directory on your machine where you want to work with your
indicators. Invoke pandas_ta_classic.custom.import_dir once to pre-populate it with
sub-folders for all available indicator categories, e.g.:
>>> import os
>>> from os.path import abspath, join, expanduser
>>> from pandas_ta_classic.custom import create_dir, import_dir
>>> ta_dir = abspath(join(expanduser("~"), "my_indicators"))
>>> create_dir(ta_dir)
3. You can now create your own custom indicator e.g. by copying existing
ones from pandas_ta_classic core module and modifying them.
IMPORTANT: Each custom indicator should have a unique name and have both
a) a function named exactly as the module, e.g. 'ni' if the module is ni.py
b) a matching method used by AnalysisIndicators named as the module but
ending with '_method'. E.g. 'ni_method'
In essence these modules should look exactly like the standard indicators
available in categories under the pandas_ta_classic-folder. The only difference will
be an addition of a matching class method.
For an example of the correct structure, look at the example ni.py in the
examples folder.
The ni.py indicator is a trend indicator so therefore we drop it into the
sub-folder named trend. Thus we have a folder structure like this:
~/my_indicators/
candles/
.
.
trend/
. ni.py
.
volume/
4. We can now dynamically load all our custom indicators located in our
designated indicators directory like this:
>>> import_dir(ta_dir)
If your custom indicator(s) loaded succesfully then it should behave exactly
like all other native indicators in pandas_ta_classic, including help functions.
"""
def load_indicator_module(name):
"""
Helper function to (re)load an indicator module.
Returns:
dict: module functions mapping
{
"func1_name": func1,
"func2_name": func2,...
}
"""
# load module
try:
module = importlib.import_module(name)
except Exception as ex:
print(f"[X] An error occurred when attempting to load module {name}: {ex}")
sys.exit(1)
# reload to refresh previously loaded module
module = importlib.reload(module)
return get_module_functions(module)
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from .dsp import dsp
from .ebsw import ebsw
@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
# Detrended Synthetic Price (DSP)
from ..overlap.ema import ema
from ..utils import get_offset, verify_series
def dsp(close, length=None, offset=None, **kwargs):
"""Indicator: Detrended Synthetic Price (DSP)"""
# Validate arguments
length = int(length) if length and length > 0 else 14
close = verify_series(close, length)
offset = get_offset(offset)
if close is None:
return
# Calculate Result
# Calculate EMA
ema_value = ema(close, length=length)
# Detrend by subtracting EMA
dsp = close - ema_value
# Offset
if offset != 0:
dsp = dsp.shift(offset)
# Handle fills
if "fillna" in kwargs:
dsp.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
dsp.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
dsp.bfill(inplace=True)
# Name and Categorize it
dsp.name = f"DSP_{length}"
dsp.category = "cycles"
return dsp
dsp.__doc__ = """Detrended Synthetic Price (DSP)
Detrended Synthetic Price removes the trend component from price data to reveal
the cyclical component. It's useful for cycle analysis and identifying periodic
patterns in price movement.
Sources:
https://www.mesasoftware.com/papers/TheInverseFisherTransform.pdf
Cycle Analytics for Traders by John F. Ehlers
Calculation:
Default Inputs:
length=14
EMA = EMA(close, length)
DSP = close - EMA
Args:
close (pd.Series): Series of 'close's
length (int): The EMA period. Default: 14
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
# Even Better Sine Wave (EBSW)
import numpy as np
from numpy import cos as npCos
from numpy import exp as npExp
from numpy import pi as npPi
from numpy import sin as npSin
from numpy import sqrt as npSqrt
from pandas import Series
npNaN = np.nan
from ..utils import get_offset, verify_series
def ebsw(close, length=None, bars=None, offset=None, **kwargs):
"""Indicator: Even Better SineWave (EBSW)"""
# Validate arguments
length = int(length) if length and length > 38 else 40
bars = int(bars) if bars and bars > 0 else 10
close = verify_series(close, length)
offset = get_offset(offset)
if close is None:
return
# variables
alpha1 = HP = 0 # alpha and HighPass
a1 = b1 = c1 = c2 = c3 = 0
Filt = Pwr = Wave = 0
lastClose = lastHP = 0
FilterHist = [0, 0] # Filter history
# Calculate Result
m = close.size
result = [npNaN for _ in range(0, length - 1)] + [0]
for i in range(length, m):
# HighPass filter cyclic components whose periods are shorter than Duration input
alpha1 = (1 - npSin(360 / length)) / npCos(360 / length)
HP = 0.5 * (1 + alpha1) * (close[i] - lastClose) + alpha1 * lastHP
# Smooth with a Super Smoother Filter from equation 3-3
a1 = npExp(-npSqrt(2) * npPi / bars)
b1 = 2 * a1 * npCos(npSqrt(2) * 180 / bars)
c2 = b1
c3 = -1 * a1 * a1
c1 = 1 - c2 - c3
Filt = c1 * (HP + lastHP) / 2 + c2 * FilterHist[1] + c3 * FilterHist[0]
# Filt = float("{:.8f}".format(float(Filt))) # to fix for small scientific notations, the big ones fail
# 3 Bar average of Wave amplitude and power
Wave = (Filt + FilterHist[1] + FilterHist[0]) / 3
Pwr = (
Filt * Filt + FilterHist[1] * FilterHist[1] + FilterHist[0] * FilterHist[0]
) / 3
# Normalize the Average Wave to Square Root of the Average Power
Wave = Wave / npSqrt(Pwr)
# update storage, result
FilterHist.append(Filt) # append new Filt value
FilterHist.pop(0) # remove first element of list (left) -> updating/trim
lastHP = HP
lastClose = close[i]
result.append(Wave)
ebsw = Series(result, index=close.index)
# Offset
if offset != 0:
ebsw = ebsw.shift(offset)
# Handle fills
if "fillna" in kwargs:
ebsw.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
ebsw.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
ebsw.bfill(inplace=True)
# Name and Categorize it
ebsw.name = f"EBSW_{length}_{bars}"
ebsw.category = "cycles"
return ebsw
ebsw.__doc__ = """Even Better SineWave (EBSW) *beta*
This indicator measures market cycles and uses a low pass filter to remove noise.
Its output is bound signal between -1 and 1 and the maximum length of a detected
trend is limited by its length input.
Written by rengel8 for Pandas TA based on a publication at 'prorealcode.com' and
a book by J.F.Ehlers.
* This implementation seems to be logically limited. It would make sense to
implement exactly the version from prorealcode and compare the behaviour.
Sources:
https://www.prorealcode.com/prorealtime-indicators/even-better-sinewave/
J.F.Ehlers 'Cycle Analytics for Traders', 2014
Calculation:
refer to 'sources' or implementation
Args:
close (pd.Series): Series of 'close's
length (int): It's max cycle/trend period. Values between 40-48 work like
expected with minimum value: 39. Default: 40.
bars (int): Period of low pass filtering. Default: 10
drift (int): The difference period. Default: 1
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""
@@ -9,85 +9,38 @@ from .cfo import cfo
from .cg import cg
from .cmo import cmo
from .coppock import coppock
from .crsi import crsi
from .cti import cti
from .dm import dm
from .er import er
from .eri import eri
from .exhc import exhc
from .fisher import fisher
from .inertia import inertia
from .kdj import kdj
from .kst import kst
from .lrsi import lrsi
from .macd import macd
from .mom import mom
from .pgo import pgo
from .po import po
from .ppo import ppo
from .psl import psl
from .pvo import pvo
from .qqe import qqe
from .roc import roc
from .rsi import rsi
from .rsx import rsx
from .rvgi import rvgi
from .slope import slope
from .smc import smc
from .smi import smi
from .squeeze import squeeze
from .squeeze_pro import squeeze_pro
from .stc import stc
from .stoch import stoch
from .stochf import stochf
from .stochrsi import stochrsi
from .tmo import tmo
from .td_seq import td_seq
from .trix import trix
from .trixh import trixh
from .tsi import tsi
from .uo import uo
from .vwmacd import vwmacd
from .willr import willr
__all__ = [
"ao",
"apo",
"bias",
"bop",
"brar",
"cci",
"cfo",
"cg",
"cmo",
"coppock",
"crsi",
"cti",
"dm",
"er",
"eri",
"exhc",
"fisher",
"inertia",
"kdj",
"kst",
"macd",
"mom",
"pgo",
"ppo",
"psl",
"qqe",
"roc",
"rsi",
"rsx",
"rvgi",
"slope",
"smc",
"smi",
"squeeze",
"squeeze_pro",
"stc",
"stoch",
"stochf",
"stochrsi",
"tmo",
"trix",
"tsi",
"uo",
"willr",
]
@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# Awesome Oscillator (AO)
from ..overlap.sma import sma
from ..utils import get_offset, verify_series
def ao(high, low, fast=None, slow=None, offset=None, **kwargs):
"""Indicator: Awesome Oscillator (AO)"""
# Validate Arguments
fast = int(fast) if fast and fast > 0 else 5
slow = int(slow) if slow and slow > 0 else 34
if slow < fast:
fast, slow = slow, fast
_length = max(fast, slow)
high = verify_series(high, _length)
low = verify_series(low, _length)
offset = get_offset(offset)
if high is None or low is None:
return
# Calculate Result
median_price = 0.5 * (high + low)
fast_sma = sma(median_price, fast)
slow_sma = sma(median_price, slow)
ao = fast_sma - slow_sma
# Offset
if offset != 0:
ao = ao.shift(offset)
# Handle fills
if "fillna" in kwargs:
ao.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
ao.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
ao.bfill(inplace=True)
# Name and Categorize it
ao.name = f"AO_{fast}_{slow}"
ao.category = "momentum"
return ao
ao.__doc__ = """Awesome Oscillator (AO)
The Awesome Oscillator is an indicator used to measure a security's momentum.
AO is generally used to affirm trends or to anticipate possible reversals.
Sources:
https://www.tradingview.com/wiki/Awesome_Oscillator_(AO)
https://www.ifcm.co.uk/ntx-indicators/awesome-oscillator
Calculation:
Default Inputs:
fast=5, slow=34
SMA = Simple Moving Average
median = (high + low) / 2
AO = SMA(median, fast) - SMA(median, slow)
Args:
high (pd.Series): Series of 'high's
low (pd.Series): Series of 'low's
fast (int): The short period. Default: 5
slow (int): The long period. Default: 34
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""
@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# Absolute Price Oscillator (APO)
from .. import Imports
from ..overlap.ma import ma
from ..utils import get_offset, tal_ma, verify_series
def apo(close, fast=None, slow=None, mamode=None, talib=None, offset=None, **kwargs):
"""Indicator: Absolute Price Oscillator (APO)"""
# Validate Arguments
fast = int(fast) if fast and fast > 0 else 12
slow = int(slow) if slow and slow > 0 else 26
if slow < fast:
fast, slow = slow, fast
close = verify_series(close, max(fast, slow))
mamode = mamode if isinstance(mamode, str) else "sma"
offset = get_offset(offset)
mode_tal = bool(talib) if isinstance(talib, bool) else True
if close is None:
return
# Calculate Result
if Imports["talib"] and mode_tal:
from talib import APO
apo = APO(close, fast, slow, tal_ma(mamode))
else:
fastma = ma(mamode, close, length=fast)
slowma = ma(mamode, close, length=slow)
apo = fastma - slowma
# Offset
if offset != 0:
apo = apo.shift(offset)
# Handle fills
if "fillna" in kwargs:
apo.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
apo.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
apo.bfill(inplace=True)
# Name and Categorize it
apo.name = f"APO_{fast}_{slow}"
apo.category = "momentum"
return apo
apo.__doc__ = """Absolute Price Oscillator (APO)
The Absolute Price Oscillator is an indicator used to measure a security's
momentum. It is simply the difference of two Exponential Moving Averages
(EMA) of two different periods. Note: APO and MACD lines are equivalent.
Sources:
https://www.tradingtechnologies.com/xtrader-help/x-study/technical-indicator-definitions/absolute-price-oscillator-apo/
Calculation:
Default Inputs:
fast=12, slow=26
SMA = Simple Moving Average
APO = SMA(close, fast) - SMA(close, slow)
Args:
close (pd.Series): Series of 'close's
fast (int): The short period. Default: 12
slow (int): The long period. Default: 26
mamode (str): See ```help(ta.ma)```. Default: 'sma'
talib (bool): If TA Lib is installed and talib is True, Returns the TA Lib
version. Default: True
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
# Bias (BIAS)
from ..overlap.ma import ma
from ..utils import get_offset, verify_series
def bias(close, length=None, mamode=None, offset=None, **kwargs):
"""Indicator: Bias (BIAS)"""
# Validate Arguments
length = int(length) if length and length > 0 else 26
mamode = mamode if isinstance(mamode, str) else "sma"
close = verify_series(close, length)
offset = get_offset(offset)
if close is None:
return
# Calculate Result
bma = ma(mamode, close, length=length, **kwargs)
bias = (close / bma) - 1
# Offset
if offset != 0:
bias = bias.shift(offset)
# Handle fills
if "fillna" in kwargs:
bias.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
bias.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
bias.bfill(inplace=True)
# Name and Categorize it
bias.name = f"BIAS_{bma.name}"
bias.category = "momentum"
return bias
bias.__doc__ = """Bias (BIAS)
Rate of change between the source and a moving average.
Sources:
Few internet resources on definitive definition.
Request by Github user homily, issue #46
Calculation:
Default Inputs:
length=26, MA='sma'
BIAS = (close - MA(close, length)) / MA(close, length)
= (close / MA(close, length)) - 1
Args:
close (pd.Series): Series of 'close's
length (int): The period. Default: 26
mamode (str): See ```help(ta.ma)```. Default: 'sma'
drift (int): The short period. Default: 1
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
# Balance of Power (BOP)
from .. import Imports
from ..utils import get_offset, non_zero_range, verify_series
def bop(open_, high, low, close, scalar=None, talib=None, offset=None, **kwargs):
"""Indicator: Balance of Power (BOP)"""
# Validate Arguments
open_ = verify_series(open_)
high = verify_series(high)
low = verify_series(low)
close = verify_series(close)
scalar = float(scalar) if scalar else 1
offset = get_offset(offset)
mode_tal = bool(talib) if isinstance(talib, bool) else True
# Calculate Result
if Imports["talib"] and mode_tal:
from talib import BOP
bop = BOP(open_, high, low, close)
else:
high_low_range = non_zero_range(high, low)
close_open_range = non_zero_range(close, open_)
bop = scalar * close_open_range / high_low_range
# Offset
if offset != 0:
bop = bop.shift(offset)
# Handle fills
if "fillna" in kwargs:
bop.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
bop.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
bop.bfill(inplace=True)
# Name and Categorize it
bop.name = f"BOP"
bop.category = "momentum"
return bop
bop.__doc__ = """Balance of Power (BOP)
Balance of Power measure the market strength of buyers against sellers.
Sources:
http://www.worden.com/TeleChartHelp/Content/Indicators/Balance_of_Power.htm
Calculation:
BOP = scalar * (close - open) / (high - low)
Args:
open (pd.Series): Series of 'open's
high (pd.Series): Series of 'high's
low (pd.Series): Series of 'low's
close (pd.Series): Series of 'close's
scalar (float): How much to magnify. Default: 1
talib (bool): If TA Lib is installed and talib is True, Returns the TA Lib
version. Default: True
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""
@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
# BRAR (Bull and Bear Ratio)
from pandas import DataFrame
from ..utils import get_drift, get_offset, non_zero_range, verify_series
def brar(
open_, high, low, close, length=None, scalar=None, drift=None, offset=None, **kwargs
):
"""Indicator: BRAR (BRAR)"""
# Validate Arguments
length = int(length) if length and length > 0 else 26
scalar = float(scalar) if scalar else 100
high_open_range = non_zero_range(high, open_)
open_low_range = non_zero_range(open_, low)
open_ = verify_series(open_, length)
high = verify_series(high, length)
low = verify_series(low, length)
close = verify_series(close, length)
drift = get_drift(drift)
offset = get_offset(offset)
if open_ is None or high is None or low is None or close is None:
return
# Calculate Result
hcy = non_zero_range(high, close.shift(drift))
cyl = non_zero_range(close.shift(drift), low)
hcy[hcy < 0] = 0 # Zero negative values
cyl[cyl < 0] = 0 # ""
ar = scalar * high_open_range.rolling(length).sum()
ar /= open_low_range.rolling(length).sum()
br = scalar * hcy.rolling(length).sum()
br /= cyl.rolling(length).sum()
# Offset
if offset != 0:
ar = ar.shift(offset)
br = ar.shift(offset)
# Handle fills
if "fillna" in kwargs:
ar.fillna(kwargs["fillna"], inplace=True)
br.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
ar.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
ar.bfill(inplace=True)
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
br.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
br.bfill(inplace=True)
# Name and Categorize it
_props = f"_{length}"
ar.name = f"AR{_props}"
br.name = f"BR{_props}"
ar.category = br.category = "momentum"
# Prepare DataFrame to return
brardf = DataFrame({ar.name: ar, br.name: br})
brardf.name = f"BRAR{_props}"
brardf.category = "momentum"
return brardf
brar.__doc__ = """BRAR (BRAR)
BR and AR
Sources:
No internet resources on definitive definition.
Request by Github user homily, issue #46
Calculation:
Default Inputs:
length=26, scalar=100
SUM = Sum
HO_Diff = high - open
OL_Diff = open - low
HCY = high - close[-1]
CYL = close[-1] - low
HCY[HCY < 0] = 0
CYL[CYL < 0] = 0
AR = scalar * SUM(HO, length) / SUM(OL, length)
BR = scalar * SUM(HCY, length) / SUM(CYL, length)
Args:
open_ (pd.Series): Series of 'open's
high (pd.Series): Series of 'high's
low (pd.Series): Series of 'low's
close (pd.Series): Series of 'close's
length (int): The period. Default: 26
scalar (float): How much to magnify. Default: 100
drift (int): The difference period. Default: 1
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.DataFrame: ar, br columns.
"""
@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
# Commodity Channel Index (CCI)
from .. import Imports
from ..overlap.hlc3 import hlc3
from ..overlap.sma import sma
from ..statistics import mad, stdev
from ..utils import get_offset, verify_series
def cci(high, low, close, length=None, c=None, talib=None, offset=None, **kwargs):
"""Indicator: Commodity Channel Index (CCI)"""
# Validate Arguments
length = int(length) if length and length > 0 else 14
c = float(c) if c and c > 0 else 0.015
high = verify_series(high, length)
low = verify_series(low, length)
close = verify_series(close, length)
offset = get_offset(offset)
mode_tal = bool(talib) if isinstance(talib, bool) else True
if high is None or low is None or close is None:
return
# Calculate Result
if Imports["talib"] and mode_tal:
from talib import CCI
cci = CCI(high, low, close, length)
else:
typical_price = hlc3(high=high, low=low, close=close)
mean_typical_price = sma(typical_price, length=length)
mad_typical_price = mad(typical_price, length=length)
cci = typical_price - mean_typical_price
cci /= c * mad_typical_price
# Offset
if offset != 0:
cci = cci.shift(offset)
# Handle fills
if "fillna" in kwargs:
cci.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
cci.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
cci.bfill(inplace=True)
# Name and Categorize it
cci.name = f"CCI_{length}_{c}"
cci.category = "momentum"
return cci
cci.__doc__ = """Commodity Channel Index (CCI)
Commodity Channel Index is a momentum oscillator used to primarily identify
overbought and oversold levels relative to a mean.
Sources:
https://www.tradingview.com/wiki/Commodity_Channel_Index_(CCI)
Calculation:
Default Inputs:
length=14, c=0.015
SMA = Simple Moving Average
MAD = Mean Absolute Deviation
tp = typical_price = hlc3 = (high + low + close) / 3
mean_tp = SMA(tp, length)
mad_tp = MAD(tp, length)
CCI = (tp - mean_tp) / (c * mad_tp)
Args:
high (pd.Series): Series of 'high's
low (pd.Series): Series of 'low's
close (pd.Series): Series of 'close's
length (int): It's period. Default: 14
c (float): Scaling Constant. Default: 0.015
talib (bool): If TA Lib is installed and talib is True, Returns the TA Lib
version. Default: True
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
# Chande Forecast Oscillator (CFO)
from ..overlap.linreg import linreg
from ..utils import get_drift, get_offset, verify_series
def cfo(close, length=None, scalar=None, drift=None, offset=None, **kwargs):
"""Indicator: Chande Forcast Oscillator (CFO)"""
# Validate Arguments
length = int(length) if length and length > 0 else 9
scalar = float(scalar) if scalar else 100
close = verify_series(close, length)
drift = get_drift(drift)
offset = get_offset(offset)
if close is None:
return
# Finding linear regression of Series
cfo = scalar * (close - linreg(close, length=length, tsf=True))
cfo /= close
# Offset
if offset != 0:
cfo = cfo.shift(offset)
# Handle fills
if "fillna" in kwargs:
cfo.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
cfo.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
cfo.bfill(inplace=True)
# Name and Categorize it
cfo.name = f"CFO_{length}"
cfo.category = "momentum"
return cfo
cfo.__doc__ = """Chande Forcast Oscillator (CFO)
The Forecast Oscillator calculates the percentage difference between the actual
price and the Time Series Forecast (the endpoint of a linear regression line).
Sources:
https://www.fmlabs.com/reference/default.htm?url=ForecastOscillator.htm
Calculation:
Default Inputs:
length=9, drift=1, scalar=100
LINREG = Linear Regression
CFO = scalar * (close - LINERREG(length, tdf=True)) / close
Args:
close (pd.Series): Series of 'close's
length (int): The period. Default: 9
scalar (float): How much to magnify. Default: 100
drift (int): The short period. Default: 1
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Center of Gravity (CG)
from ..utils import get_offset, verify_series, weights
def cg(close, length=None, offset=None, **kwargs):
"""Indicator: Center of Gravity (CG)"""
# Validate Arguments
length = int(length) if length and length > 0 else 10
close = verify_series(close, length)
offset = get_offset(offset)
if close is None:
return
# Calculate Result
coefficients = [length - i for i in range(0, length)]
numerator = -close.rolling(length).apply(weights(coefficients), raw=True)
cg = numerator / close.rolling(length).sum()
# Offset
if offset != 0:
cg = cg.shift(offset)
# Handle fills
if "fillna" in kwargs:
cg.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
cg.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
cg.bfill(inplace=True)
# Name and Categorize it
cg.name = f"CG_{length}"
cg.category = "momentum"
return cg
cg.__doc__ = """Center of Gravity (CG)
The Center of Gravity Indicator by John Ehlers attempts to identify turning
points while exhibiting zero lag and smoothing.
Sources:
http://www.mesasoftware.com/papers/TheCGOscillator.pdf
Calculation:
Default Inputs:
length=10
Args:
close (pd.Series): Series of 'close's
length (int): The length of the period. Default: 10
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""
@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# Chande Momentum Oscillator (CMO)
from .. import Imports
from ..overlap.rma import rma
from ..utils import get_drift, get_offset, verify_series
def cmo(close, length=None, scalar=None, talib=None, drift=None, offset=None, **kwargs):
"""Indicator: Chande Momentum Oscillator (CMO)"""
# Validate Arguments
length = int(length) if length and length > 0 else 14
scalar = float(scalar) if scalar else 100
close = verify_series(close, length)
drift = get_drift(drift)
offset = get_offset(offset)
mode_tal = bool(talib) if isinstance(talib, bool) else True
if close is None:
return
# Calculate Result
if Imports["talib"] and mode_tal:
from talib import CMO
cmo = CMO(close, length)
else:
mom = close.diff(drift)
positive = mom.copy().clip(lower=0)
negative = mom.copy().clip(upper=0).abs()
if mode_tal:
pos_ = rma(positive, length)
neg_ = rma(negative, length)
else:
pos_ = positive.rolling(length).sum()
neg_ = negative.rolling(length).sum()
cmo = scalar * (pos_ - neg_) / (pos_ + neg_)
# Offset
if offset != 0:
cmo = cmo.shift(offset)
# Handle fills
if "fillna" in kwargs:
cmo.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
cmo.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
cmo.bfill(inplace=True)
# Name and Categorize it
cmo.name = f"CMO_{length}"
cmo.category = "momentum"
return cmo
cmo.__doc__ = """Chande Momentum Oscillator (CMO)
Attempts to capture the momentum of an asset with overbought at 50 and
oversold at -50.
Sources:
https://www.tradingtechnologies.com/help/x-study/technical-indicator-definitions/chande-momentum-oscillator-cmo/
https://www.tradingview.com/script/hdrf0fXV-Variable-Index-Dynamic-Average-VIDYA/
Calculation:
Default Inputs:
drift=1, scalar=100
# Same Calculation as RSI except for this step
CMO = scalar * (PSUM - NSUM) / (PSUM + NSUM)
Args:
close (pd.Series): Series of 'close's
scalar (float): How much to magnify. Default: 100
talib (bool): If TA Lib is installed and talib is True, Returns the TA Lib
version. If TA Lib is not installed but talib is True, it runs the Python
version TA Lib. Default: True
drift (int): The short period. Default: 1
offset (int): How many periods to offset the result. Default: 0
Kwargs:
talib (bool): If True, uses TA-Libs implementation. Otherwise uses EMA version. Default: True
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""
@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
# Coppock Curve (COPC)
from .roc import roc
from ..overlap.wma import wma
from ..utils import get_offset, verify_series
def coppock(close, length=None, fast=None, slow=None, offset=None, **kwargs):
"""Indicator: Coppock Curve (COPC)"""
# Validate Arguments
length = int(length) if length and length > 0 else 10
fast = int(fast) if fast and fast > 0 else 11
slow = int(slow) if slow and slow > 0 else 14
close = verify_series(close, max(length, fast, slow))
offset = get_offset(offset)
if close is None:
return
# Calculate Result
total_roc = roc(close, fast) + roc(close, slow)
coppock = wma(total_roc, length)
# Offset
if offset != 0:
coppock = coppock.shift(offset)
# Handle fills
if "fillna" in kwargs:
coppock.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
coppock.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
coppock.bfill(inplace=True)
# Name and Categorize it
coppock.name = f"COPC_{fast}_{slow}_{length}"
coppock.category = "momentum"
return coppock
coppock.__doc__ = """Coppock Curve (COPC)
Coppock Curve (originally called the "Trendex Model") is a momentum indicator
is designed for use on a monthly time scale. Although designed for monthly
use, a daily calculation over the same period can be made, converting the
periods to 294-day and 231-day rate of changes, and a 210-day weighted
moving average.
Sources:
https://en.wikipedia.org/wiki/Coppock_curve
Calculation:
Default Inputs:
length=10, fast=11, slow=14
SMA = Simple Moving Average
MAD = Mean Absolute Deviation
tp = typical_price = hlc3 = (high + low + close) / 3
mean_tp = SMA(tp, length)
mad_tp = MAD(tp, length)
CCI = (tp - mean_tp) / (c * mad_tp)
Args:
close (pd.Series): Series of 'close's
length (int): WMA period. Default: 10
fast (int): Fast ROC period. Default: 11
slow (int): Slow ROC period. Default: 14
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""
@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Correlation Trend Indicator (CTI)
from pandas import Series
from ..overlap.linreg import linreg
from ..utils import get_offset, verify_series
def cti(close, length=None, offset=None, **kwargs) -> Series:
"""Indicator: Correlation Trend Indicator"""
length = int(length) if length and length > 0 else 12
close = verify_series(close, length)
offset = get_offset(offset)
if close is None:
return
cti = linreg(close, length=length, r=True)
# Offset
if offset != 0:
cti = cti.shift(offset)
# Handle fills
if "fillna" in kwargs:
cti.fillna(method=kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
cti.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
cti.bfill(inplace=True)
cti.name = f"CTI_{length}"
cti.category = "momentum"
return cti
cti.__doc__ = """Correlation Trend Indicator (CTI)
The Correlation Trend Indicator is an oscillator created by John Ehler in 2020.
It assigns a value depending on how close prices in that range are to following
a positively- or negatively-sloping straight line. Values range from -1 to 1.
This is a wrapper for ta.linreg(close, r=True).
Args:
close (pd.Series): Series of 'close's
length (int): It's period. Default: 12
offset (int): How many periods to offset the result. Default: 0
Returns:
pd.Series: Series of the CTI values for the given period.
"""
@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
# Directional Movement (DM)
from pandas import DataFrame
from .. import Imports
from ..overlap.ma import ma
from ..utils import get_offset, verify_series, get_drift, zero
def dm(
high, low, length=None, mamode=None, talib=None, drift=None, offset=None, **kwargs
):
"""Indicator: DM"""
# Validate Arguments
length = int(length) if length and length > 0 else 14
mamode = mamode.lower() if mamode and isinstance(mamode, str) else "rma"
high = verify_series(high)
low = verify_series(low)
drift = get_drift(drift)
offset = get_offset(offset)
mode_tal = bool(talib) if isinstance(talib, bool) else True
if high is None or low is None:
return
if Imports["talib"] and mode_tal:
from talib import MINUS_DM, PLUS_DM
pos = PLUS_DM(high, low, length)
neg = MINUS_DM(high, low, length)
else:
up = high - high.shift(drift)
dn = low.shift(drift) - low
pos_ = ((up > dn) & (up > 0)) * up
neg_ = ((dn > up) & (dn > 0)) * dn
pos_ = pos_.apply(zero)
neg_ = neg_.apply(zero)
# Not the same values as TA Lib's -+DM (Good First Issue)
pos = ma(mamode, pos_, length=length)
neg = ma(mamode, neg_, length=length)
# Offset
if offset != 0:
pos = pos.shift(offset)
neg = neg.shift(offset)
_params = f"_{length}"
data = {
f"DMP{_params}": pos,
f"DMN{_params}": neg,
}
dmdf = DataFrame(data)
# print(dmdf.head(20))
# print()
dmdf.name = f"DM{_params}"
dmdf.category = "trend"
return dmdf
dm.__doc__ = """Directional Movement (DM)
The Directional Movement was developed by J. Welles Wilder in 1978 attempts to
determine which direction the price of an asset is moving. It compares prior
highs and lows to yield to two series +DM and -DM.
Sources:
https://www.tradingview.com/pine-script-reference/#fun_dmi
https://www.sierrachart.com/index.php?page=doc/StudiesReference.php&ID=24&Name=Directional_Movement_Index
Calculation:
Default Inputs:
length=14, mamode="rma", drift=1
up = high - high.shift(drift)
dn = low.shift(drift) - low
pos_ = ((up > dn) & (up > 0)) * up
neg_ = ((dn > up) & (dn > 0)) * dn
pos_ = pos_.apply(zero)
neg_ = neg_.apply(zero)
# Not the same values as TA Lib's -+DM
pos = ma(mamode, pos_, length=length)
neg = ma(mamode, neg_, length=length)
Args:
high (pd.Series): Series of 'high's
low (pd.Series): Series of 'low's
mamode (str): See ```help(ta.ma)```. Default: 'rma'
talib (bool): If TA Lib is installed and talib is True, Returns the TA Lib
version. Default: True
drift (int): The difference period. Default: 1
offset (int): How many periods to offset the result. Default: 0
Returns:
pd.DataFrame: DMP (+DM) and DMN (-DM) columns.
"""
@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
# Efficiency Ratio (ER)
from pandas import DataFrame, concat
from ..utils import get_drift, get_offset, verify_series, signals
def er(close, length=None, drift=None, offset=None, **kwargs):
"""Indicator: Efficiency Ratio (ER)"""
# Validate arguments
length = int(length) if length and length > 0 else 10
close = verify_series(close, length)
offset = get_offset(offset)
drift = get_drift(drift)
if close is None:
return
# Calculate Result
abs_diff = close.diff(length).abs()
abs_volatility = close.diff(drift).abs()
er = abs_diff
er /= abs_volatility.rolling(window=length).sum()
# Offset
if offset != 0:
er = er.shift(offset)
# Handle fills
if "fillna" in kwargs:
er.fillna(kwargs["fillna"], inplace=True)
if "fill_method" in kwargs:
if "fill_method" in kwargs:
if kwargs["fill_method"] == "ffill":
er.ffill(inplace=True)
elif kwargs["fill_method"] == "bfill":
er.bfill(inplace=True)
# Name and Categorize it
er.name = f"ER_{length}"
er.category = "momentum"
signal_indicators = kwargs.pop("signal_indicators", False)
if signal_indicators:
signalsdf = concat(
[
DataFrame({er.name: er}),
signals(
indicator=er,
xa=kwargs.pop("xa", 80),
xb=kwargs.pop("xb", 20),
xserie=kwargs.pop("xserie", None),
xserie_a=kwargs.pop("xserie_a", None),
xserie_b=kwargs.pop("xserie_b", None),
cross_values=kwargs.pop("cross_values", False),
cross_series=kwargs.pop("cross_series", True),
offset=offset,
),
],
axis=1,
)
return signalsdf
else:
return er
er.__doc__ = """Efficiency Ratio (ER)
The Efficiency Ratio was invented by Perry J. Kaufman and presented in his book "New Trading Systems and Methods". It is designed to account for market noise or volatility.
It is calculated by dividing the net change in price movement over N periods by the sum of the absolute net changes over the same N periods.
Sources:
https://help.tc2000.com/m/69404/l/749623-kaufman-efficiency-ratio
Calculation:
Default Inputs:
length=10
ABS = Absolute Value
EMA = Exponential Moving Average
abs_diff = ABS(close.diff(length))
volatility = ABS(close.diff(1))
ER = abs_diff / SUM(volatility, length)
Args:
close (pd.Series): Series of 'close's
length (int): It's period. Default: 1
offset (int): How many periods to offset the result. Default: 0
Kwargs:
fillna (value, optional): pd.DataFrame.fillna(value)
fill_method (value, optional): Type of fill method
Returns:
pd.Series: New feature generated.
"""

Some files were not shown because too many files have changed in this diff Show More