From eed7e80c0d0f3de2413289e1a80930a2f36d82bf Mon Sep 17 00:00:00 2001 From: Ichinga Samuel Date: Fri, 27 Feb 2026 04:11:38 +0100 Subject: [PATCH] v4.1.0 --- =2.0.0 | 0 docs/contrib/symbols/forex_symbol.md | 15 +- docs/lib/order.md | 40 ++- docs/lib/symbol.md | 67 +++- examples/sample_app/emaxover.py | 2 +- pyproject.toml | 2 +- src/aiomql/contrib/symbols/forex_symbol.py | 4 +- .../contrib/trackers/position_trackers.py | 1 - src/aiomql/core/utils.py | 1 - src/aiomql/lib/executor.py | 9 +- src/aiomql/lib/symbol.py | 6 +- src/aiomql/lib/sync/trader.py | 3 + src/aiomql/lib/trader.py | 8 +- tests/live/unit/async/test_symbol.py | 333 ++++++++++++++++-- uv.lock | 17 +- 15 files changed, 430 insertions(+), 78 deletions(-) delete mode 100644 =2.0.0 diff --git a/=2.0.0 b/=2.0.0 deleted file mode 100644 index e69de29..0000000 diff --git a/docs/contrib/symbols/forex_symbol.md b/docs/contrib/symbols/forex_symbol.md index 8378993..f1d6e91 100644 --- a/docs/contrib/symbols/forex_symbol.md +++ b/docs/contrib/symbols/forex_symbol.md @@ -1,11 +1,11 @@ # forex_symbol -`aiomql.contrib.symbols.forex_symbol` — Forex-specific symbol with pip calculations. +`aiomql.contrib.symbols.forex_symbol` — Forex-specific symbol with pip and volume calculations. ## Overview -Extends [`Symbol`](../../lib/symbol.md) with forex-specific logic for pip size, -pip value, and volume calculations based on currency pairs. +Extends [`Symbol`](../../lib/symbol.md) with forex-specific logic for pip size +and volume calculations based on price movements and stop-loss levels. ## Classes @@ -17,13 +17,12 @@ Inherits from [`Symbol`](../../lib/symbol.md). | Attribute | Type | Description | |-----------|------|-------------| -| `pip` | `float` | Pip size for the pair | +| `pip` | `float` | Pip size for the pair (`point * 10`) | #### Methods | Method | Returns | Description | |--------|---------|-------------| -| `pip_value(volume)` | `float` | Value of one pip for a given lot size | -| `pips_to_price(pips)` | `float` | Converts a pip count to a price delta | -| `price_to_pips(price_delta)` | `float` | Converts a price delta to pips | -| `calc_volume(amount, pips)` | `float` | Calculates lot size from risk amount and pip distance | +| `compute_points(amount, volume)` | `float` | Computes points of price movement needed for a given amount and volume | +| `compute_volume_points(amount, points, round_down=False)` | `float` | Computes lot size from risk amount and point distance | +| `compute_volume_sl(amount, price, sl, round_down=False)` | `float` | Computes lot size from risk amount, entry price, and stop-loss price | diff --git a/docs/lib/order.md b/docs/lib/order.md index c7e5f47..2546c02 100644 --- a/docs/lib/order.md +++ b/docs/lib/order.md @@ -7,7 +7,7 @@ The `Order` class creates and manages trade orders for the MetaTrader 5 terminal. It handles margin calculations, profit projections, order validation, modification, and cancellation. -Inherits from [`_Base`](../core/base.md). +Inherits from [`_Base`](../core/base.md) and `TradeRequest`. ## Classes @@ -30,32 +30,54 @@ Inherits from [`_Base`](../core/base.md). | `type_filling` | `OrderFilling` | Filling policy | | `type_time` | `OrderTime` | Time-in-force policy | +#### Initialisation + +| Method | Description | +|--------|-------------| +| `__init__(**kwargs)` | Requires `symbol`. Defaults: `action=TradeAction.DEAL`, `type_time=OrderTime.DAY`, `type_filling=OrderFilling.FOK` | + #### `request` *(property)* -Returns the trade request as a dict, filtering out `None` values. +Returns the trade request as a dict, filtering to keys valid for `TradeRequest`. #### Validation | Method | Returns | Description | |--------|---------|-------------| -| `check()` | `OrderCheckResult` | Validates the order, raises `OrderError` on failure | +| `check(**kwargs)` | `OrderCheckResult` | Validates the order; raises `OrderError` on failure | #### Execution | Method | Returns | Description | |--------|---------|-------------| -| `send()` | `OrderSendResult` | Sends the order; retries on requote/timeout | +| `send()` | `OrderSendResult` | Sends the order via `send_order()` | +| `send_order(request, connection_retries=0)` | `OrderSendResult` | Class method. Sends a trade request; retries up to 3 times on connection loss (retcode 10031). Raises `OrderError` on failure | #### Calculations | Method | Returns | Description | |--------|---------|-------------| | `calc_margin()` | `float \| None` | Required margin for the order | -| `calc_profit(close_price)` | `float \| None` | Projected profit at a given close price | +| `calc_profit()` | `float \| None` | Projected profit at `tp` (take profit) price | +| `calc_loss()` | `float \| None` | Projected loss at `sl` (stop loss) price | +| `profit_to_price(profit, order_type, volume, symbol, price_open)` | `float` | Class method. Reverse-calculates the close price needed to achieve a target profit | #### Modification -| Method | Description | -|--------|-------------| -| `modify(**kwargs)` | Modifies a pending order's parameters | -| `cancel()` | Cancels a pending order | +| Method | Returns | Description | +|--------|---------|-------------| +| `modify(**kwargs)` | — | Updates the order's attributes | +| `cancel_order(order, symbol="")` | `OrderSendResult` | Class method. Cancels a pending order by ticket; raises `OrderError` on failure | + +#### Pending & Historical Orders + +| Method | Returns | Description | +|--------|---------|-------------| +| `orders_total()` | `int` | Class method. Total number of active pending orders | +| `get_pending_order(ticket)` | `TradeOrder \| None` | Class method. Gets a single pending order by ticket | +| `get_pending_orders(ticket, symbol, group)` | `tuple[TradeOrder, ...]` | Class method. Gets pending orders filtered by ticket, symbol, or group | +| `get_history_order_by_ticket(ticket)` | `TradeOrder \| None` | Class method. Gets a historical order by ticket | + +## Synchronous API + +Available in `aiomql.lib.sync.order`. All async methods become synchronous with the same signatures. diff --git a/docs/lib/symbol.md b/docs/lib/symbol.md index 7e4a8dc..609b0a5 100644 --- a/docs/lib/symbol.md +++ b/docs/lib/symbol.md @@ -7,7 +7,7 @@ The `Symbol` class represents a financial instrument (forex pair, stock, etc.) and provides methods for querying market data, selecting symbols, and retrieving rates and ticks. -Inherits from [`_Base`](../core/base.md). +Inherits from [`_Base`](../core/base.md) and `SymbolInfo`. ## Classes @@ -19,33 +19,76 @@ Inherits from [`_Base`](../core/base.md). |-----------|------|-------------| | `name` | `str` | Symbol name (e.g. `"EURUSD"`) | | `select` | `bool` | Whether the symbol is selected in Market Watch | +| `tick` | `Tick` | Current price tick for the instrument | +| `account` | `Account` | Trading account instance | +| `initialized` | `bool` | Whether the symbol has been successfully initialized | All `SymbolInfo` fields are available as instance attributes after initialisation. #### Initialisation -| Method | Description | -|--------|-------------| -| `init()` | Fetches symbol info from the terminal and sets all attributes | +| Method | Returns | Description | +|--------|---------|-------------| +| `__init__(**kwargs)` | — | Requires `name` keyword argument | +| `initialize()` | `bool` | Async. Fetches symbol info, tick, and selects the symbol | +| `initialize_sync()` | `bool` | Synchronous version of `initialize()` | + +#### Symbol Info + +| Method | Returns | Description | +|--------|---------|-------------| +| `info()` | `SymbolInfo \| None` | Fetches and updates all symbol properties | +| `info_tick(name="")` | `Tick \| None` | Gets the current price tick | +| `symbol_select(enable=True)` | `bool` | Selects or removes the symbol from Market Watch | + +#### Market Depth + +| Method | Returns | Description | +|--------|---------|-------------| +| `book_add()` | `bool` | Subscribes to Market Depth events | +| `book_get()` | `tuple[BookInfo, ...]` | Returns Market Depth entries | +| `book_release()` | `bool` | Cancels Market Depth subscription | + +#### Volume Helpers + +| Method | Returns | Description | +|--------|---------|-------------| +| `check_volume(volume)` | `tuple[bool, float]` | Checks if volume is within min/max limits | +| `round_off_volume(volume, round_down=False)` | `float` | Rounds volume to nearest volume step | +| `compute_volume(*args, **kwargs)` | `float` | Returns `volume_min` (override in subclasses) | + +#### Currency Conversion + +| Method | Returns | Description | +|--------|---------|-------------| +| `amount_in_quote_currency(amount)` | `float` | Converts amount to the symbol's quote currency | +| `convert_currency(amount, from_currency, to_currency)` | `float \| None` | Converts between two currencies via tick data | #### Market Data | Method | Returns | Description | |--------|---------|-------------| -| `info_tick()` | `Tick` | Current tick for the symbol | | `copy_rates_from(timeframe, date_from, count)` | `Candles` | Historical bars from a date | -| `copy_rates_from_pos(timeframe, start_pos, count)` | `Candles` | Historical bars from a position | -| `copy_rates_range(timeframe, date_from, date_to)` | `Candles` | Historical bars in a range | +| `copy_rates_from_pos(timeframe, count, start_position)` | `Candles` | Historical bars from a position | +| `copy_rates_range(timeframe, date_from, date_to)` | `Candles` | Historical bars in a date range | | `copy_ticks_from(date_from, count, flags)` | `Ticks` | Historical ticks from a date | -| `copy_ticks_range(date_from, date_to, flags)` | `Ticks` | Historical ticks in a range | +| `copy_ticks_range(date_from, date_to, flags)` | `Ticks` | Historical ticks in a date range | -#### Helpers +#### Properties | Property | Returns | Description | |----------|---------|-------------| -| `pip` | `float` | The pip size for the symbol | -| `spread` | `float` | Current bid-ask spread | +| `pip` | `float` | Pip size (`point * 10`) | + +#### Overridable Methods + +These methods raise `NotImplementedError` in the base `Symbol` and are meant to be implemented by subclasses such as [`ForexSymbol`](../contrib/symbols/forex_symbol.md). + +| Method | Returns | Description | +|--------|---------|-------------| +| `compute_volume_sl(amount, price, sl, round_down)` | `float` | Compute volume from stop-loss distance | +| `compute_volume_points(amount, points, round_down)` | `float` | Compute volume from point distance | ## Synchronous API -Available in `aiomql.lib.sync.symbol`. +Available in `aiomql.lib.sync.symbol`. All async methods become synchronous with the same signatures. diff --git a/examples/sample_app/emaxover.py b/examples/sample_app/emaxover.py index 2efba4f..13867a8 100644 --- a/examples/sample_app/emaxover.py +++ b/examples/sample_app/emaxover.py @@ -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, "macd": 87, "sma": 90} + 'timeout': 120} def __init__(self, *, symbol: ForexSymbol, params: dict | None = None, trader: Trader = None, sessions: Sessions = None, name: str = "EMAXOver"): diff --git a/pyproject.toml b/pyproject.toml index 23030e5..8a6f0a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aiomql" -version = "4.0.17" +version = "4.1.0" readme = "README.md" requires-python = ">=3.13" classifiers = [ diff --git a/src/aiomql/contrib/symbols/forex_symbol.py b/src/aiomql/contrib/symbols/forex_symbol.py index 2e98321..6044e01 100644 --- a/src/aiomql/contrib/symbols/forex_symbol.py +++ b/src/aiomql/contrib/symbols/forex_symbol.py @@ -85,7 +85,7 @@ class ForexSymbol(Symbol): points = amount / (volume * self.point * self.trade_contract_size) return points - def compute_volume_points(self, *, amount: float, points: float, round_down: bool = False) -> float: + async 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 @@ -116,7 +116,7 @@ class ForexSymbol(Symbol): volume = amount / (self.point * points * self.trade_contract_size) return self.round_off_volume(volume=volume, round_down=round_down) - def compute_volume_sl(self, *, amount: float, price: float, sl: float, round_down: bool = False) -> float: + async 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 diff --git a/src/aiomql/contrib/trackers/position_trackers.py b/src/aiomql/contrib/trackers/position_trackers.py index 91683f7..9687393 100644 --- a/src/aiomql/contrib/trackers/position_trackers.py +++ b/src/aiomql/contrib/trackers/position_trackers.py @@ -12,7 +12,6 @@ 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 diff --git a/src/aiomql/core/utils.py b/src/aiomql/core/utils.py index 0deb783..0820e1e 100644 --- a/src/aiomql/core/utils.py +++ b/src/aiomql/core/utils.py @@ -41,7 +41,6 @@ async def auto_commit(): 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: diff --git a/src/aiomql/lib/executor.py b/src/aiomql/lib/executor.py index 48c8341..684f1bb 100644 --- a/src/aiomql/lib/executor.py +++ b/src/aiomql/lib/executor.py @@ -151,6 +151,7 @@ class Executor: signum: The signal number received. frame: The current stack frame. """ + print("shutting down") self.config.shutdown = True def exit(self): @@ -162,12 +163,16 @@ class Executor: """ start = time.time() try: - while self.config.shutdown is False and self.config.force_shutdown is False: + while True: if self.timeout is not None and self.timeout < (time.time() - start): self.config.shutdown = True break timeout = self.timeout or 1 time.sleep(timeout) + if self.config.shutdown or self.config.force_shutdown: + break + if all(strategy.running is False for strategy in self.strategy_runners): + break for strategy in self.strategy_runners: strategy.running = False self.config.task_queue.cancel() @@ -188,9 +193,11 @@ class Executor: Notes: No matter the number specified, the executor will always use a minimum of 5 workers. """ + signal(SIGINT, self.sigint_handle) workers_ = len(self.strategy_runners) + len(self.functions) + len(self.coroutine_threads) + 3 workers = max(workers, workers_) with ThreadPoolExecutor(max_workers=workers) as executor: + signal(SIGINT, self.sigint_handle) self.executor = executor [self.executor.submit(self.run_strategy, strategy) for strategy in self.strategy_runners] [self.executor.submit(function, **kwargs) for function, kwargs in self.functions.items()] diff --git a/src/aiomql/lib/symbol.py b/src/aiomql/lib/symbol.py index df0b09f..10eac2a 100644 --- a/src/aiomql/lib/symbol.py +++ b/src/aiomql/lib/symbol.py @@ -233,7 +233,7 @@ class Symbol(_Base, SymbolInfo): ) return amount - def compute_volume(self) -> float: + async def compute_volume(self, *args, **kwargs) -> float: """Computes the volume required for a trade usually based on the amount and any other keyword arguments. This is a dummy method that returns the minimum volume of the symbol. It is meant to be overridden by a subclass that implements the computation of volume. @@ -387,10 +387,10 @@ class Symbol(_Base, SymbolInfo): 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: + async 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: + async def compute_volume_points(self, *, amount: float, points: float, round_down: bool = False) -> float: raise NotImplementedError @property diff --git a/src/aiomql/lib/sync/trader.py b/src/aiomql/lib/sync/trader.py index 8f2587d..90f61c9 100644 --- a/src/aiomql/lib/sync/trader.py +++ b/src/aiomql/lib/sync/trader.py @@ -237,6 +237,9 @@ class Trader(ABC): else: res.save_sync() + def reset_order(self): + self.order = Order(symbol=self.symbol) + @abstractmethod def place_trade(self, *args, **kwargs): """Places a trade based on the order_type.""" diff --git a/src/aiomql/lib/trader.py b/src/aiomql/lib/trader.py index 91503ae..29b2534 100644 --- a/src/aiomql/lib/trader.py +++ b/src/aiomql/lib/trader.py @@ -17,7 +17,6 @@ Example: """ from abc import ABC, abstractmethod -from datetime import datetime, UTC from typing import TypeVar from logging import getLogger @@ -146,7 +145,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 = self.symbol.compute_volume_sl(amount=amount, price=price, sl=sl) + volume = await 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( @@ -167,7 +166,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 = self.symbol.compute_volume_points(amount=amount, points=points) + volume = await 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) @@ -238,6 +237,9 @@ class Trader(ABC): else: await res.save() + def reset_order(self): + self.order = Order(symbol=self.symbol) + @abstractmethod async def place_trade(self, *args, **kwargs): """Places a trade based on the order_type.""" diff --git a/tests/live/unit/async/test_symbol.py b/tests/live/unit/async/test_symbol.py index 23744bb..94b8d39 100644 --- a/tests/live/unit/async/test_symbol.py +++ b/tests/live/unit/async/test_symbol.py @@ -1,52 +1,323 @@ +"""Comprehensive tests for the Symbol class. + +This module contains live tests for the Symbol class, which provides +the interface for interacting with trading instruments in MetaTrader 5. +""" + from datetime import datetime, timedelta import pytest from aiomql.lib.symbol import Symbol from aiomql.lib.candle import Candles -from aiomql.lib.ticks import Ticks +from aiomql.lib.ticks import Tick, Ticks +from aiomql.core.models import SymbolInfo, BookInfo -class TestSymbol: - @pytest.fixture(scope="class", autouse=True) +class TestSymbolInit: + """Tests for Symbol initialisation.""" + + async def test_requires_name(self): + """Test that Symbol raises AssertionError without a name.""" + with pytest.raises(AssertionError): + Symbol() + + async def test_initialized_starts_false(self): + """Test that a newly created Symbol is not initialized.""" + sym = Symbol(name="BTCUSD") + assert sym.initialized is False + + async def test_has_account(self): + """Test that a newly created Symbol has an account attribute.""" + sym = Symbol(name="BTCUSD") + assert sym.account is not None + + async def test_name_attribute(self): + """Test that the name attribute is set correctly.""" + sym = Symbol(name="BTCUSD") + assert sym.name == "BTCUSD" + + +class TestSymbolInitialize: + """Tests for Symbol.initialize() and Symbol.initialize_sync().""" + + @pytest.fixture(scope="class") async def btc(self): symbol = Symbol(name="BTCUSD") - if symbol.initialized is False: - await symbol.initialize() + await symbol.initialize() return symbol - async def test_symbol_attributes(self, btc): - assert btc.name == "BTCUSD" + async def test_initialize_returns_true(self, btc): + """Test that initialize() returns True for a valid symbol.""" + assert btc.initialized is True + + async def test_initialize_sets_select(self, btc): + """Test that initialize() sets the select attribute.""" assert btc.select is True + + async def test_initialize_sets_tick(self, btc): + """Test that initialize() sets the tick attribute.""" assert btc.tick is not None + assert isinstance(btc.tick, Tick) - async def test_volume(self, btc): + async def test_initialize_sets_symbol_properties(self, btc): + """Test that initialize() populates SymbolInfo properties.""" + assert btc.volume_min > 0 + assert btc.volume_max > 0 + assert btc.volume_step > 0 + assert btc.point > 0 + + async def test_initialize_sync(self): + """Test that initialize_sync() works correctly.""" + sym = Symbol(name="BTCUSD") + result = sym.initialize_sync() + assert result is True + assert sym.initialized is True + assert sym.tick is not None + + +class TestSymbolInfo: + """Tests for Symbol.info() and Symbol.info_tick().""" + + @pytest.fixture(scope="class") + async def btc(self): + symbol = Symbol(name="BTCUSD") + await symbol.initialize() + return symbol + + async def test_info_returns_symbol_info(self, btc): + """Test that info() returns a SymbolInfo object.""" + result = await btc.info() + assert result is not None + assert isinstance(result, SymbolInfo) + + async def test_info_tick_returns_tick(self, btc): + """Test that info_tick() returns a Tick object.""" + tick = await btc.info_tick() + assert tick is not None + assert isinstance(tick, Tick) + + async def test_info_tick_updates_tick_attribute(self, btc): + """Test that info_tick() updates the symbol's tick attribute.""" + tick = await btc.info_tick() + assert btc.tick is tick + + async def test_info_tick_with_name(self, btc): + """Test info_tick() with an explicit symbol name.""" + tick = await btc.info_tick(name="ETHUSD") + assert tick is not None + assert isinstance(tick, Tick) + + +class TestSymbolSelect: + """Tests for Symbol.symbol_select().""" + + @pytest.fixture(scope="class") + async def btc(self): + symbol = Symbol(name="BTCUSD") + await symbol.initialize() + return symbol + + async def test_symbol_select_enable(self, btc): + """Test that symbol_select(enable=True) selects the symbol.""" + result = await btc.symbol_select(enable=True) + assert result is True + assert btc.select is True + + async def test_symbol_select_disable_and_renable(self, btc): + """Test toggling symbol selection off and on.""" + await btc.symbol_select(enable=False) + assert btc.select is False + await btc.symbol_select(enable=True) + assert btc.select is True + + +class TestSymbolBook: + """Tests for Symbol market depth (book) methods.""" + + @pytest.fixture(scope="class") + async def btc(self): + symbol = Symbol(name="BTCUSD") + await symbol.initialize() + return symbol + + async def test_book_add(self, btc): + """Test that book_add() returns a boolean.""" + result = await btc.book_add() + assert isinstance(result, bool) + + async def test_book_get(self, btc): + """Test that book_get() returns a tuple of BookInfo.""" + await btc.book_add() + try: + books = await btc.book_get() + assert isinstance(books, tuple) + if len(books) > 0: + assert isinstance(books[0], BookInfo) + except ValueError: + # Market depth may not be available for all symbols + pass + + async def test_book_release(self, btc): + """Test that book_release() returns a boolean.""" + result = await btc.book_release() + assert isinstance(result, bool) + + +class TestSymbolVolume: + """Tests for Symbol volume-related methods.""" + + @pytest.fixture(scope="class") + async def btc(self): + symbol = Symbol(name="BTCUSD") + await symbol.initialize() + return symbol + + async def test_check_volume_below_min(self, btc): + """Test check_volume with volume below minimum.""" volume = btc.volume_min - btc.volume_step - success, volume = btc.check_volume(volume=volume) + success, result_vol = btc.check_volume(volume=volume) assert success is False - volume = btc.volume_min + btc.volume_step * 2 - success, volume = btc.check_volume(volume=volume) - assert success is True - volume = btc.volume_min + btc.volume_step * 2.5 - volume = btc.round_off_volume(volume=volume, round_down=True) - assert volume == btc.volume_min + btc.volume_step * 2 + assert result_vol == btc.volume_min - async def test_rates(self, btc): + async def test_check_volume_valid(self, btc): + """Test check_volume with a valid volume.""" + volume = btc.volume_min + btc.volume_step * 2 + success, result_vol = btc.check_volume(volume=volume) + assert success is True + assert result_vol == volume + + async def test_check_volume_above_max(self, btc): + """Test check_volume with volume above maximum.""" + volume = btc.volume_max + btc.volume_step + success, result_vol = btc.check_volume(volume=volume) + assert success is False + assert result_vol == btc.volume_max + + async def test_round_off_volume(self, btc): + """Test round_off_volume rounds to nearest step.""" + volume = btc.volume_min + btc.volume_step * 2.5 + rounded = btc.round_off_volume(volume=volume, round_down=True) + assert rounded == btc.volume_min + btc.volume_step * 2 + + async def test_round_off_volume_up(self, btc): + """Test round_off_volume rounding up.""" + volume = btc.volume_min + btc.volume_step * 2.5 + rounded = btc.round_off_volume(volume=volume, round_down=False) + assert rounded == btc.volume_min + btc.volume_step * 3 + + async def test_compute_volume_returns_min(self, btc): + """Test that the base compute_volume() returns volume_min.""" + result = await btc.compute_volume() + assert result == btc.volume_min + + +class TestSymbolCurrency: + """Tests for Symbol currency conversion methods.""" + + @pytest.fixture(scope="class") + async def btc(self): + symbol = Symbol(name="BTCUSD") + await symbol.initialize() + return symbol + + async def test_amount_in_quote_currency(self, btc): + """Test amount_in_quote_currency returns a float.""" + result = await btc.amount_in_quote_currency(amount=100.0) + assert isinstance(result, (int, float)) + + async def test_convert_currency(self, btc): + """Test convert_currency between two currencies.""" + result = await btc.convert_currency( + amount=100.0, from_currency="USD", to_currency="EUR" + ) + # Result may be None if the conversion pair is not available + if result is not None: + assert isinstance(result, float) + assert result > 0 + + +class TestSymbolRates: + """Tests for Symbol copy rates and ticks methods.""" + + @pytest.fixture(scope="class") + async def btc(self): + symbol = Symbol(name="BTCUSD") + await symbol.initialize() + return symbol + + async def test_copy_rates_from(self, btc): + """Test copy_rates_from returns Candles.""" + start = datetime(year=2023, month=10, day=5) + rates = await btc.copy_rates_from( + timeframe=btc.mt5.TIMEFRAME_H1, date_from=start, count=10 + ) + assert isinstance(rates, Candles) + assert len(rates) == 10 + + async def test_copy_rates_from_pos(self, btc): + """Test copy_rates_from_pos returns Candles.""" + rates = await btc.copy_rates_from_pos( + timeframe=btc.mt5.TIMEFRAME_H1, count=10, start_position=0 + ) + assert isinstance(rates, Candles) + assert len(rates) == 10 + + async def test_copy_rates_range(self, btc): + """Test copy_rates_range returns Candles.""" start = datetime(year=2023, month=10, day=5) end = start + timedelta(hours=9) - rates_from = await btc.copy_rates_from(timeframe=btc.mt5.TIMEFRAME_H1, date_from=start, count=10) - assert isinstance(rates_from, Candles) - assert len(rates_from) == 10 - rates_from_pos = await btc.copy_rates_from_pos(timeframe=btc.mt5.TIMEFRAME_H1, count=10, start_position=0) - assert isinstance(rates_from_pos, Candles) - assert len(rates_from_pos) == 10 - rates_range = await btc.copy_rates_range(timeframe=btc.mt5.TIMEFRAME_H1, date_from=start, date_to=end) - assert isinstance(rates_range, Candles) - assert len(rates_range) == 10 - ticks_from = await btc.copy_ticks_from(date_from=start, count=10) - assert isinstance(ticks_from, Ticks) - assert len(ticks_from) == 10 + rates = await btc.copy_rates_range( + timeframe=btc.mt5.TIMEFRAME_H1, date_from=start, date_to=end + ) + assert isinstance(rates, Candles) + assert len(rates) == 10 + + async def test_copy_ticks_from(self, btc): + """Test copy_ticks_from returns Ticks.""" + start = datetime(year=2023, month=10, day=5) + ticks = await btc.copy_ticks_from(date_from=start, count=10) + assert isinstance(ticks, Ticks) + assert len(ticks) == 10 + + async def test_copy_ticks_range(self, btc): + """Test copy_ticks_range returns Ticks.""" + start = datetime(year=2023, month=10, day=5) end = start + timedelta(seconds=20) - ticks_from_pos = await btc.copy_ticks_range(date_from=start, date_to=end) - assert isinstance(ticks_from_pos, Ticks) - assert len(ticks_from_pos) >= 10 + ticks = await btc.copy_ticks_range(date_from=start, date_to=end) + assert isinstance(ticks, Ticks) + assert len(ticks) >= 10 + + +class TestSymbolProperties: + """Tests for Symbol properties.""" + + @pytest.fixture(scope="class") + async def btc(self): + symbol = Symbol(name="BTCUSD") + await symbol.initialize() + return symbol + + async def test_pip_property(self, btc): + """Test that pip equals point * 10.""" + assert btc.pip == btc.point * 10 + + +class TestSymbolAbstract: + """Tests for Symbol abstract/overridable methods.""" + + @pytest.fixture(scope="class") + async def btc(self): + symbol = Symbol(name="BTCUSD") + await symbol.initialize() + return symbol + + async def test_compute_volume_sl_raises(self, btc): + """Test that compute_volume_sl raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + await btc.compute_volume_sl(amount=100.0, price=50000.0, sl=49500.0) + + async def test_compute_volume_points_raises(self, btc): + """Test that compute_volume_points raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + await btc.compute_volume_points(amount=100.0, points=500.0) diff --git a/uv.lock b/uv.lock index d25c9a3..4e30d93 100644 --- a/uv.lock +++ b/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "aiomql" -version = "4.0.17" +version = "4.1.0" source = { virtual = "." } dependencies = [ { name = "metatrader5" }, @@ -22,6 +22,12 @@ dependencies = [ [package.optional-dependencies] all = [ + { name = "cython" }, + { name = "numba" }, + { name = "ta-lib" }, + { name = "tqdm" }, +] +optional = [ { name = "cython" }, { name = "numba" }, { name = "tqdm" }, @@ -40,15 +46,16 @@ dev = [ [package.metadata] requires-dist = [ - { name = "cython", marker = "extra == 'all'", specifier = ">=3.2.4" }, + { name = "aiomql", extras = ["talib", "optional"], marker = "extra == 'all'" }, + { name = "cython", marker = "extra == 'optional'", specifier = ">=3.2.4" }, { name = "metatrader5", specifier = ">=5.0.5640" }, { name = "mplfinance", specifier = ">=0.12.10b0" }, - { name = "numba", marker = "extra == 'all'", specifier = ">=0.64.0" }, + { name = "numba", marker = "extra == 'optional'", specifier = ">=0.64.0" }, { name = "pandas", specifier = ">=3.0.1" }, { name = "ta-lib", marker = "extra == 'talib'", specifier = ">=0.6.8" }, - { name = "tqdm", marker = "extra == 'all'", specifier = ">=4.67.3" }, + { name = "tqdm", marker = "extra == 'optional'", specifier = ">=4.67.3" }, ] -provides-extras = ["all", "talib"] +provides-extras = ["optional", "talib", "all"] [package.metadata.requires-dev] dev = [