mirror of
https://github.com/Ichinga-Samuel/aiomql.git
synced 2026-08-14 20:38:07 +00:00
Update tests and docs across core, lib, and contrib modules
This commit is contained in:
@@ -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 +0,0 @@
|
||||
{"balance": 100, "profit": -11.76, "equity": 88.24, "margin": 88.87000000000005, "margin_free": -0.6300000000000523, "margin_level": 99.29109935861365}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"balance": 131.67,
|
||||
"profit": 0,
|
||||
"equity": 131.67,
|
||||
"margin": 0.0,
|
||||
"margin_free": 131.67,
|
||||
"margin_level": 0
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"balance": 590.45,
|
||||
"profit": 0,
|
||||
"equity": 590.45,
|
||||
"margin": 0.0,
|
||||
"margin_free": 590.45,
|
||||
"margin_level": 0
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 @@
|
||||
from .track import close_after
|
||||
from .track import close_after, hedge_position, track_hedges
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
@@ -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,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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -12,3 +12,4 @@ from .utils import *
|
||||
from .db import DB
|
||||
from .state import State
|
||||
from .store import Store
|
||||
from .sync import *
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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}
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 @@
|
||||
from .meta_trader import MetaTrader
|
||||
from .meta_trader import MetaTrader as MetaTraderSync
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
-10
@@ -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[i−1] + HA_CLOSE[i−1]) / 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.
|
||||
"""
|
||||
+6
-53
@@ -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
Reference in New Issue
Block a user