diff --git a/src/aiomql/core/config.py b/src/aiomql/core/config.py index 2bf1e6e..a61afb6 100644 --- a/src/aiomql/core/config.py +++ b/src/aiomql/core/config.py @@ -62,7 +62,8 @@ class Config: use_terminal_for_backtesting: bool _defaults = {"timeout": 60000, "record_trades": True, "trade_record_mode": "csv", "mode": "live", 'filename': "aiomql.json", "records_dir_name": "trade_records", "backtest_dir_name": "backtester", - "use_terminal_for_backtesting": True, 'path': '', 'login': 0, 'password': '', 'server': ''} + "use_terminal_for_backtesting": True, 'path': '', 'login': 0, 'password': '', + 'server': '', 'records_dir': None} def __new__(cls, *args, **kwargs): if not hasattr(cls, "_instance"): @@ -161,7 +162,7 @@ class Config: if self.path: self.path = self.root / self.path if not Path(self.path).resolve().exists() else self.path - if self.record_trades and not hasattr(self, "records_dir"): + if self.record_trades and (hasattr(self, "records_dir") is False or self.records_dir is None): self.records_dir = self.root / self.records_dir_name self.records_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/aiomql/lib/trade_records.py b/src/aiomql/lib/trade_records.py index 78d7ab8..30f26c6 100644 --- a/src/aiomql/lib/trade_records.py +++ b/src/aiomql/lib/trade_records.py @@ -7,6 +7,8 @@ import csv import logging from typing import Iterable +from MetaTrader5 import TradePosition + from ..core.config import Config from ..core.meta_trader import MetaTrader from ..core.meta_backtester import MetaBackTester @@ -24,6 +26,7 @@ class TradeRecords: """ config: Config mt5: MetaTrader | MetaBackTester + positions: list[TradePosition] | None = None def __init__(self, *, records_dir: Path | str = ''): """Initialize the Records class. The main method of this class is update_records which you should call to update @@ -33,10 +36,10 @@ class TradeRecords: records_dir (Path): Absolute path to directory containing record of placed trades. """ self.config = Config() - self.mt5 = MetaTrader() if self.config.mode == 'live' else MetaBackTester() + self.mt5 = MetaTrader() if self.config.mode != 'backtest' else MetaBackTester() self.records_dir = records_dir or self.config.records_dir - async def get_csv_records(self): + def get_csv_records(self): """Get trade records saved as csv from records_dir folder Yields: @@ -46,11 +49,11 @@ class TradeRecords: if file.is_file() and file.name.endswith('.csv'): yield file - async def get_json_records(self): + def get_json_records(self): """Get trade records from records_dir folder Yields: - files: Trade record files + files (Path): Trade record files """ for file in self.records_dir.iterdir(): if file.is_file() and file.name.endswith('.json'): @@ -68,7 +71,7 @@ class TradeRecords: rows = [row for row in reader] rows = await self.update_rows(rows=rows) - with open(file, mode='w', newline='') as fw: # type: SupportsWrite[str] + with open(file, mode='w', newline='') as fw: writer = csv.DictWriter(fw, fieldnames=reader.fieldnames, extrasaction='ignore', restval=None) writer.writeheader() writer.writerows(rows) @@ -81,12 +84,12 @@ class TradeRecords: file: Trade record file in csv format """ try: - with open(file, mode='r') as fh: # type: SupportsRead[str | bytes] + with open(file, mode='r') as fh: data = json.load(fh) rows = [row for row in data] rows = await self.update_rows(rows=rows) - with open(file, mode='w') as fh: # type: SupportsWrite[str] + 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') @@ -102,11 +105,13 @@ class TradeRecords: """ 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.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) @@ -124,6 +129,7 @@ class TradeRecords: Returns: list[dict]: A list of dictionaries with the actual profit and win status. """ + self.positions = await self.mt5.positions_get() closed, unclosed = [], [] for row in rows: closed_ = row.get('closed', False) @@ -137,12 +143,12 @@ class TradeRecords: async def update_csv_records(self): """Update csv trade records in the records_dir folder.""" - records = [self.read_update_csv(file=record) async for record in self.get_csv_records()] + records = [self.read_update_csv(file=record) for record in self.get_csv_records()] await asyncio.gather(*records) async def update_json_records(self): """Update json trade records in the records_dir folder.""" - records = [self.read_update_json(file=record) async for record in self.get_json_records()] + records = [self.read_update_json(file=record) for record in self.get_json_records()] await asyncio.gather(*records) async def update_csv_record(self, *, file: Path | str): diff --git a/tests/conftest.py b/tests/conftest.py index 5a03298..23f5e0b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,7 +77,7 @@ async def buy_order(mt): 'type': mt.ORDER_TYPE_BUY, 'price': sym_info.ask, 'sl': sl, 'tp': tp} @pytest.fixture(scope='class') -async def make_orders(mt): +async def make_buy_sell_orders(mt): sym = 'BTCUSD' sym_info = await mt.symbol_info(sym) dsl = (sym_info.trade_stops_level + sym_info.spread) * 2 * sym_info.point diff --git a/tests/integration/test_results_records.py b/tests/integration/test_results_records.py new file mode 100644 index 0000000..2546e26 --- /dev/null +++ b/tests/integration/test_results_records.py @@ -0,0 +1,94 @@ +import asyncio +import json +from csv import DictReader + +import pytest + +from aiomql.lib.result import Result +from aiomql.core.models import OrderSendResult +from aiomql.lib.trade_records import TradeRecords +from aiomql.lib.positions import Positions + + +class TestRecordsAndResults: + @classmethod + def setup_class(cls): + cls.trade_records = TradeRecords() + + @pytest.fixture(scope='class') + async def buy(self, mt): + sym = 'BTCUSD' + sym_info = await mt.symbol_info(sym) + dsl = (sym_info.trade_stops_level + sym_info.spread) * 2 * sym_info.point + sl = sym_info.ask - dsl + tp = sym_info.ask + dsl + return {'action': mt.TRADE_ACTION_DEAL, 'symbol': sym, 'volume': sym_info.volume_min, + 'type': mt.ORDER_TYPE_BUY, 'price': sym_info.ask, 'sl': sl, 'tp': tp} + + @pytest.fixture(scope='class') + async def sell(self, mt): + sym = 'BTCUSD' + sym_info = await mt.symbol_info(sym) + return {'action': mt.TRADE_ACTION_DEAL, 'symbol': sym, 'volume': sym_info.volume_min, + 'type': mt.ORDER_TYPE_SELL, 'price': sym_info.bid} + + @pytest.fixture(scope='class', autouse=True) + async def setup(self, sell, buy, mt): + buy_res = await mt.order_send(buy) + buy_res_2 = await mt.order_send(buy) + sell_res = await mt.order_send(sell) + sell_res_2 = await mt.order_send(sell) + buy_res = Result(result=OrderSendResult(**buy_res._asdict()), name='test_result') + sell_res = Result(result=OrderSendResult(**sell_res._asdict()), name='test_result') + sell_res_2 = Result(result=OrderSendResult(**sell_res_2._asdict()), name='test_result') + buy_res_2 = Result(result=OrderSendResult(**buy_res_2._asdict()), name='test_result') + await asyncio.gather(buy_res.save(), sell_res.save(), buy_res_2.save(trade_record_mode='json'), + sell_res_2.save(trade_record_mode='json')) + await Positions().close_all() + + def test_records_dir(self): + records_dir = self.trade_records.records_dir + assert records_dir.is_dir() + recs = list(records_dir.iterdir()) + assert len(recs) >= 2 + csvs, jsons = [], [] + matched_recs = list(records_dir.glob("test_result.*")) + assert len(matched_recs) == 2 + for rec in matched_recs: + if rec.match("test_result.json"): + jsons.append(rec) + elif rec.match("test_result.csv"): + csvs.append(rec) + else: + continue + assert len(csvs) == 1 + assert len(jsons) == 1 + + async def test_json_records(self): + json_records = self.trade_records.get_json_records() + matched_recs = [record for record in json_records if record.match('test_result.json')] + assert len(matched_recs) == 1 + record = matched_recs[0] + record_data = json.load(record.open()) + assert isinstance(record_data, list) + assert len(record_data) == 2 + is_open = [data['closed'] is False for data in record_data] + assert all(is_open) + await self.trade_records.update_json_records() + is_close = [data['closed'] is True for data in record_data] + assert len(is_close) == 2 + + async def test_csv_records(self): + csv_records = self.trade_records.get_csv_records() + matched_recs = [record for record in csv_records if record.match('test_result.csv')] + assert len(matched_recs) == 1 + record = matched_recs[0] + record_data = DictReader(record.open()) + record_data = [row for row in record_data] + assert isinstance(record_data, list) + assert len(record_data) == 2 + is_open = [data['closed'].title() == 'False' for data in record_data] + assert all(is_open) + await self.trade_records.update_json_records() + is_close = [data['closed'].title() == 'True' for data in record_data] + assert len(is_close) == 2 diff --git a/tests/unittests/test_account.py b/tests/unit/test_account.py similarity index 100% rename from tests/unittests/test_account.py rename to tests/unit/test_account.py diff --git a/tests/unittests/test_base.py b/tests/unit/test_base.py similarity index 100% rename from tests/unittests/test_base.py rename to tests/unit/test_base.py diff --git a/tests/unittests/test_candles.py b/tests/unit/test_candles.py similarity index 100% rename from tests/unittests/test_candles.py rename to tests/unit/test_candles.py diff --git a/tests/unittests/test_config.py b/tests/unit/test_config.py similarity index 100% rename from tests/unittests/test_config.py rename to tests/unit/test_config.py diff --git a/tests/unittests/test_history.py b/tests/unit/test_history.py similarity index 97% rename from tests/unittests/test_history.py rename to tests/unit/test_history.py index ab19bc6..40cf9b4 100644 --- a/tests/unittests/test_history.py +++ b/tests/unit/test_history.py @@ -5,7 +5,7 @@ from aiomql.lib.history import History class TestHistory: @pytest.fixture(scope='class', autouse=True) - async def init(self, make_orders): + async def init(self, make_buy_sell_orders): await self.history.init() @classmethod diff --git a/tests/unittests/test_meta_trader.py b/tests/unit/test_meta_trader.py similarity index 100% rename from tests/unittests/test_meta_trader.py rename to tests/unit/test_meta_trader.py diff --git a/tests/unittests/test_order.py b/tests/unit/test_order.py similarity index 100% rename from tests/unittests/test_order.py rename to tests/unit/test_order.py diff --git a/tests/unittests/test_positions.py b/tests/unit/test_positions.py similarity index 95% rename from tests/unittests/test_positions.py rename to tests/unit/test_positions.py index dcf4057..75364f3 100644 --- a/tests/unittests/test_positions.py +++ b/tests/unit/test_positions.py @@ -5,7 +5,7 @@ from aiomql.lib.positions import Positions class TestPositions: @pytest.fixture(scope='class', autouse=True) - async def init(self, make_orders): + async def init(self, make_buy_sell_orders): await self.positions.get_positions() @classmethod diff --git a/tests/unittests/test_ram.py b/tests/unit/test_ram.py similarity index 100% rename from tests/unittests/test_ram.py rename to tests/unit/test_ram.py diff --git a/tests/unittests/test_result.py b/tests/unit/test_result.py similarity index 100% rename from tests/unittests/test_result.py rename to tests/unit/test_result.py diff --git a/tests/unittests/test_sessions.py b/tests/unit/test_sessions.py similarity index 100% rename from tests/unittests/test_sessions.py rename to tests/unit/test_sessions.py diff --git a/tests/unittests/test_symbol.py b/tests/unit/test_symbol.py similarity index 100% rename from tests/unittests/test_symbol.py rename to tests/unit/test_symbol.py diff --git a/tests/unittests/test_terminal.py b/tests/unit/test_terminal.py similarity index 100% rename from tests/unittests/test_terminal.py rename to tests/unit/test_terminal.py diff --git a/tests/unittests/test_ticks.py b/tests/unit/test_ticks.py similarity index 100% rename from tests/unittests/test_ticks.py rename to tests/unit/test_ticks.py