diff --git a/packages/flow/octobot_flow/logic/dsl/dsl_executor.py b/packages/flow/octobot_flow/logic/dsl/dsl_executor.py index 2c662c9879..f788f28342 100644 --- a/packages/flow/octobot_flow/logic/dsl/dsl_executor.py +++ b/packages/flow/octobot_flow/logic/dsl/dsl_executor.py @@ -47,6 +47,7 @@ def get_flow_operator_classes( return ( octobot_commons.dsl_interpreter.get_all_operators() + dsl_operators.create_ohlcv_operators(self._exchange_manager, None, None) + + dsl_operators.create_price_operators(self._exchange_manager, None) + dsl_operators.create_portfolio_operators(self._exchange_manager) + dsl_operators.create_create_order_operators( self._exchange_manager, trading_mode=None, dependencies=self._dependencies diff --git a/packages/tentacles/Automation/conditions/scripted_condition/scripted_condition.py b/packages/tentacles/Automation/conditions/scripted_condition/scripted_condition.py index 38f5e21fe9..7a6a68e268 100644 --- a/packages/tentacles/Automation/conditions/scripted_condition/scripted_condition.py +++ b/packages/tentacles/Automation/conditions/scripted_condition/scripted_condition.py @@ -85,16 +85,20 @@ def _validate_script(self): def _create_dsl_interpreter(self): exchange_manager = self._get_exchange_manager() ohlcv_operators = [] + price_operators = [] portfolio_operators = [] if exchange_manager is not None: ohlcv_operators = dsl_operators.exchange_operators.create_ohlcv_operators( exchange_manager, None, None ) + price_operators = dsl_operators.exchange_operators.create_price_operators( + exchange_manager, None + ) portfolio_operators = dsl_operators.exchange_operators.create_portfolio_operators( exchange_manager ) return dsl_interpreter.Interpreter( - dsl_interpreter.get_all_operators() + ohlcv_operators + portfolio_operators + dsl_interpreter.get_all_operators() + ohlcv_operators + price_operators + portfolio_operators ) def _get_exchange_manager(self): diff --git a/packages/tentacles/Meta/DSL_operators/exchange_operators/__init__.py b/packages/tentacles/Meta/DSL_operators/exchange_operators/__init__.py index c8d929c329..381658f664 100644 --- a/packages/tentacles/Meta/DSL_operators/exchange_operators/__init__.py +++ b/packages/tentacles/Meta/DSL_operators/exchange_operators/__init__.py @@ -22,6 +22,8 @@ create_ohlcv_operators, create_ticker_operators, create_symbol_operators, + PriceOperator, + create_price_operators, ) import tentacles.Meta.DSL_operators.exchange_operators.exchange_personal_data_operators from tentacles.Meta.DSL_operators.exchange_operators.exchange_personal_data_operators import ( @@ -43,6 +45,8 @@ "create_ohlcv_operators", "create_ticker_operators", "create_symbol_operators", + "PriceOperator", + "create_price_operators", "create_portfolio_operators", "create_cancel_order_operators", "create_fetch_order_operators", diff --git a/packages/tentacles/Meta/DSL_operators/exchange_operators/exchange_public_data_operators/__init__.py b/packages/tentacles/Meta/DSL_operators/exchange_operators/exchange_public_data_operators/__init__.py index 5e93951893..48ab0e109e 100644 --- a/packages/tentacles/Meta/DSL_operators/exchange_operators/exchange_public_data_operators/__init__.py +++ b/packages/tentacles/Meta/DSL_operators/exchange_operators/exchange_public_data_operators/__init__.py @@ -29,6 +29,11 @@ from tentacles.Meta.DSL_operators.exchange_operators.exchange_public_data_operators.symbol_operators import ( create_symbol_operators, ) +import tentacles.Meta.DSL_operators.exchange_operators.exchange_public_data_operators.price_operators +from tentacles.Meta.DSL_operators.exchange_operators.exchange_public_data_operators.price_operators import ( + PriceOperator, + create_price_operators, +) __all__ = [ "OHLCVOperator", @@ -36,4 +41,6 @@ "create_ohlcv_operators", "create_ticker_operators", "create_symbol_operators", + "PriceOperator", + "create_price_operators", ] \ No newline at end of file diff --git a/packages/tentacles/Meta/DSL_operators/exchange_operators/exchange_public_data_operators/ohlcv_operators.py b/packages/tentacles/Meta/DSL_operators/exchange_operators/exchange_public_data_operators/ohlcv_operators.py index 34e54d40c8..42501083e6 100644 --- a/packages/tentacles/Meta/DSL_operators/exchange_operators/exchange_public_data_operators/ohlcv_operators.py +++ b/packages/tentacles/Meta/DSL_operators/exchange_operators/exchange_public_data_operators/ohlcv_operators.py @@ -36,6 +36,12 @@ class ExchangeDataDependency(octobot_trading.dsl.SymbolDependency): data_source: str = octobot_trading.constants.OHLCV_CHANNEL + def resolve_symbol( + self, exchange_manager: typing.Optional[octobot_trading.exchanges.ExchangeManager] + ): + if exchange_manager is not None: + self.symbol = exchange_manager.get_exchange_symbol(self.symbol) + def __hash__(self) -> int: return hash((self.symbol, self.time_frame, self.data_source)) @@ -87,6 +93,7 @@ def _get_candles_values_with_latest_kline_if_available( candles_manager = candle_manager_by_time_frame_by_symbol[_time_frame][_symbol] symbol_data = None else: + _symbol = exchange_manager.get_exchange_symbol(_symbol) symbol_data = octobot_trading.api.get_symbol_data( exchange_manager, _symbol, allow_creation=False ) @@ -135,6 +142,8 @@ def get_dependencies(self) -> typing.List[dsl_interpreter.InterpreterDependency] ) if symbol_dep not in local_dependencies: local_dependencies.append(symbol_dep) + for dependency in local_dependencies: + dependency.resolve_symbol(exchange_manager) return super().get_dependencies() + local_dependencies async def pre_compute(self) -> None: diff --git a/packages/tentacles/Meta/DSL_operators/exchange_operators/exchange_public_data_operators/price_operators.py b/packages/tentacles/Meta/DSL_operators/exchange_operators/exchange_public_data_operators/price_operators.py new file mode 100644 index 0000000000..186343acac --- /dev/null +++ b/packages/tentacles/Meta/DSL_operators/exchange_operators/exchange_public_data_operators/price_operators.py @@ -0,0 +1,112 @@ +# pylint: disable=missing-class-docstring,missing-function-docstring +# Drakkar-Software OctoBot-Commons +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import typing + +import octobot_commons.constants +import octobot_commons.errors +import octobot_commons.dsl_interpreter as dsl_interpreter +import octobot_trading.exchanges +import octobot_trading.api +import octobot_trading.constants + +import tentacles.Meta.DSL_operators.exchange_operators.exchange_operator as exchange_operator +import tentacles.Meta.DSL_operators.exchange_operators.exchange_public_data_operators.ohlcv_operators as ohlcv_operators + + +class PriceOperator(exchange_operator.ExchangeOperator): + @staticmethod + def get_library() -> str: + # this is a contextual operator, so it should not be included by default in the get_all_operators function return values + return octobot_commons.constants.CONTEXTUAL_OPERATORS_LIBRARY + + @classmethod + def get_parameters(cls) -> list[dsl_interpreter.OperatorParameter]: + return [ + dsl_interpreter.OperatorParameter( + name="symbol", + description="the symbol to get the latest mark price for", + required=False, + type=str, + ), + ] + + def get_symbol(self) -> typing.Optional[str]: + if parameters := self.get_computed_parameters(): + symbol = parameters[0] if len(parameters) > 0 else None + return str(symbol) if symbol is not None else None + return None + + +def create_price_operators( + exchange_manager: typing.Optional[octobot_trading.exchanges.ExchangeManager], + symbol: typing.Optional[str], +) -> typing.List[type[PriceOperator]]: + + def _get_latest_price(input_symbol: typing.Optional[str]) -> float: + if exchange_manager is None: + raise octobot_commons.errors.DSLInterpreterError( + "exchange_manager must be provided" + ) + resolved_symbol = exchange_manager.get_exchange_symbol(input_symbol or symbol) + symbol_data = octobot_trading.api.get_symbol_data( + exchange_manager, resolved_symbol, allow_creation=False + ) + try: + mark_price = symbol_data.prices_manager.get_mark_price_no_wait() + except ValueError as err: + raise octobot_commons.errors.DSLInterpreterError( + f"No up to date mark price for {resolved_symbol}" + ) from err + return float(mark_price) + + def _static_get_dependencies() -> typing.List[ohlcv_operators.ExchangeDataDependency]: + return [ + ohlcv_operators.ExchangeDataDependency( + symbol=symbol, + time_frame=None, + data_source=octobot_trading.constants.MARK_PRICE_CHANNEL, + ) + ] if symbol else [] + + class _LocalPriceOperator(PriceOperator): + DESCRIPTION = "Returns the latest mark price for the symbol" + EXAMPLE = "price('BTC/USDT')" + + @staticmethod + def get_name() -> str: + return "price" + + def get_dependencies(self) -> typing.List[dsl_interpreter.InterpreterDependency]: + local_dependencies = _static_get_dependencies() + param_by_name = self.get_input_value_by_parameter() + if param_symbol := param_by_name.get("symbol"): + symbol_dep = ohlcv_operators.ExchangeDataDependency( + symbol=param_symbol, + time_frame=None, + data_source=octobot_trading.constants.MARK_PRICE_CHANNEL, + ) + if symbol_dep not in local_dependencies: + local_dependencies.append(symbol_dep) + for dependency in local_dependencies: + dependency.resolve_symbol(exchange_manager) + return super().get_dependencies() + local_dependencies + + async def pre_compute(self) -> None: + await super().pre_compute() + self.value = _get_latest_price(self.get_symbol()) + + return [_LocalPriceOperator] diff --git a/packages/tentacles/Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/__init__.py b/packages/tentacles/Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/__init__.py index 4aa037e5d7..11e6dd9d6c 100644 --- a/packages/tentacles/Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/__init__.py +++ b/packages/tentacles/Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/__init__.py @@ -28,6 +28,8 @@ SYMBOL = "BTC/USDT" SYMBOL2 = "ETH/USDT" +RESOLVED_SYMBOL = "BTCUSDT" +RESOLVED_SYMBOL2 = "ETHUSDT" TIME_FRAME = "1h" TIME_FRAME2 = "4h" KLINE_SIGNATURE = 0.00666 @@ -125,10 +127,23 @@ def _get_kline(candles_manager: mock.Mock, signature: float, kline_time_delta: t return kline +def _identity_get_exchange_symbol(symbol): + return symbol + + +def _normalize_symbol(symbol: str) -> str: + symbol_aliases = { + RESOLVED_SYMBOL: SYMBOL, + RESOLVED_SYMBOL2: SYMBOL2, + } + return symbol_aliases.get(symbol, symbol) + + def _get_symbol_data_factory( btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager, kline_type: str ): def _get_symbol_data(symbol: str, **kwargs): + symbol = _normalize_symbol(symbol) symbol_candles = {} one_h_candles_manager = btc_1h_candles_manager if symbol == SYMBOL else eth_1h_candles_manager if symbol == SYMBOL2 else None four_h_candles_manager = btc_4h_candles_manager if symbol == SYMBOL else None # no 4h eth candles @@ -174,6 +189,7 @@ def exchange_manager_with_candles(historical_prices, historical_volume, historic return mock.Mock( id="exchange_manager_id", exchange_name="binance", + get_exchange_symbol=mock.Mock(side_effect=_identity_get_exchange_symbol), exchange_symbols_data=mock.Mock( get_exchange_symbol_data=_get_symbol_data_factory( btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager, "no_kline" @@ -190,6 +206,7 @@ def exchange_manager_with_candles_and_klines(historical_prices, historical_volum return mock.Mock( id="exchange_manager_id", exchange_name="binance", + get_exchange_symbol=mock.Mock(side_effect=_identity_get_exchange_symbol), exchange_symbols_data=mock.Mock( get_exchange_symbol_data=_get_symbol_data_factory( btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager, "same_time_kline" @@ -206,6 +223,7 @@ def exchange_manager_with_candles_and_new_candle_klines(historical_prices, histo return mock.Mock( id="exchange_manager_id", exchange_name="binance", + get_exchange_symbol=mock.Mock(side_effect=_identity_get_exchange_symbol), exchange_symbols_data=mock.Mock( get_exchange_symbol_data=_get_symbol_data_factory( btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager, "new_time_kline" diff --git a/packages/tentacles/Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/test_ohlcv_operators.py b/packages/tentacles/Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/test_ohlcv_operators.py index ae121cec95..5b99da9e70 100644 --- a/packages/tentacles/Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/test_ohlcv_operators.py +++ b/packages/tentacles/Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/test_ohlcv_operators.py @@ -31,8 +31,10 @@ from tentacles.Meta.DSL_operators.exchange_operators.tests.exchange_public_data_operators import ( SYMBOL, + SYMBOL2, TIME_FRAME, KLINE_SIGNATURE, + RESOLVED_SYMBOL, historical_prices, historical_volume, historical_times, @@ -260,3 +262,56 @@ async def test_ohlcv_operators_dependencies( data_source=octobot_trading.constants.OHLCV_CHANNEL ), ] + + +class TestGetExchangeSymbol: + @pytest.mark.asyncio + async def test_pre_compute_calls_get_exchange_symbol_with_context_symbol( + self, interpreter, exchange_manager_with_candles + ): + exchange_manager_with_candles.get_exchange_symbol.reset_mock() + await interpreter.interprete("close") + exchange_manager_with_candles.get_exchange_symbol.assert_called_with(SYMBOL) + + @pytest.mark.asyncio + async def test_pre_compute_calls_get_exchange_symbol_with_param_symbol( + self, interpreter, exchange_manager_with_candles + ): + exchange_manager_with_candles.get_exchange_symbol.reset_mock() + await interpreter.interprete(f"close('{SYMBOL2}')") + exchange_manager_with_candles.get_exchange_symbol.assert_called_with(SYMBOL2) + + @pytest.mark.asyncio + async def test_get_dependencies_calls_get_exchange_symbol( + self, interpreter, exchange_manager_with_candles + ): + exchange_manager_with_candles.get_exchange_symbol.reset_mock() + interpreter.prepare("close") + interpreter.get_dependencies() + exchange_manager_with_candles.get_exchange_symbol.assert_called_with(SYMBOL) + + @pytest.mark.asyncio + async def test_pre_compute_does_not_call_get_exchange_symbol_without_exchange_manager( + self, interpreter_with_candle_manager_by_time_frame_by_symbol, historical_prices + ): + operator_value = await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("close") + assert np.array_equal(operator_value, historical_prices) + + @pytest.mark.asyncio + async def test_pre_compute_uses_resolved_symbol_downstream( + self, exchange_manager_with_candles + ): + exchange_manager_with_candles.get_exchange_symbol = mock.Mock(return_value=RESOLVED_SYMBOL) + ohlcv_interpreter = dsl_interpreter.Interpreter( + dsl_interpreter.get_all_operators() + + exchange_operators.create_ohlcv_operators( + exchange_manager_with_candles, SYMBOL, TIME_FRAME + ) + ) + with mock.patch.object( + octobot_trading.api, "get_symbol_data", wraps=octobot_trading.api.get_symbol_data + ) as get_symbol_data_spy: + await ohlcv_interpreter.interprete("close") + get_symbol_data_spy.assert_called_once_with( + exchange_manager_with_candles, RESOLVED_SYMBOL, allow_creation=False + ) diff --git a/packages/tentacles/Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/test_price_operators.py b/packages/tentacles/Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/test_price_operators.py new file mode 100644 index 0000000000..69d2e606af --- /dev/null +++ b/packages/tentacles/Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/test_price_operators.py @@ -0,0 +1,228 @@ +# Drakkar-Software OctoBot-Commons +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import decimal + +import mock +import pytest + +import octobot_commons.errors +import octobot_commons.dsl_interpreter as dsl_interpreter +import octobot_trading.api +import octobot_trading.constants +import tentacles.Meta.DSL_operators.exchange_operators as exchange_operators + +from tentacles.Meta.DSL_operators.exchange_operators.tests.exchange_public_data_operators import ( + SYMBOL, + SYMBOL2, + RESOLVED_SYMBOL, +) + + +PRICES_BY_SYMBOL = { + SYMBOL: decimal.Decimal("50000"), + SYMBOL2: decimal.Decimal("3000"), + RESOLVED_SYMBOL: decimal.Decimal("50000"), +} + + +def _identity_get_exchange_symbol(symbol): + return symbol + + +@pytest.fixture +def exchange_manager_with_prices(): + symbol_data_by_symbol = {} + + def get_exchange_symbol_data(symbol, allow_creation=True): + if symbol not in PRICES_BY_SYMBOL: + raise octobot_commons.errors.InvalidParametersError(f"Symbol {symbol} not found") + if symbol not in symbol_data_by_symbol: + prices_manager = mock.Mock() + prices_manager.get_mark_price_no_wait = mock.Mock( + return_value=PRICES_BY_SYMBOL[symbol] + ) + symbol_data_by_symbol[symbol] = mock.Mock(prices_manager=prices_manager) + return symbol_data_by_symbol[symbol] + + return mock.Mock( + get_exchange_symbol=mock.Mock(side_effect=_identity_get_exchange_symbol), + exchange_symbols_data=mock.Mock( + get_exchange_symbol_data=get_exchange_symbol_data + ) + ) + + +@pytest.fixture +def interpreter(exchange_manager_with_prices): + return dsl_interpreter.Interpreter( + dsl_interpreter.get_all_operators() + + exchange_operators.create_price_operators(exchange_manager_with_prices, SYMBOL) + ) + + +@pytest.fixture +def interpreter_without_exchange_data(): + return dsl_interpreter.Interpreter( + dsl_interpreter.get_all_operators() + + exchange_operators.create_price_operators(None, None) + ) + + +@pytest.mark.asyncio +async def test_price_operator_default_symbol(interpreter): + assert await interpreter.interprete("price") == float(PRICES_BY_SYMBOL[SYMBOL]) + + +@pytest.mark.asyncio +async def test_price_operator_with_symbol_param(interpreter): + assert await interpreter.interprete(f"price('{SYMBOL2}')") == float(PRICES_BY_SYMBOL[SYMBOL2]) + assert await interpreter.interprete(f"price('{SYMBOL}')") == float(PRICES_BY_SYMBOL[SYMBOL]) + + +@pytest.mark.asyncio +async def test_price_operator_unknown_symbol(interpreter): + with pytest.raises(octobot_commons.errors.InvalidParametersError, match="Symbol UNKNOWN/PAIR not found"): + await interpreter.interprete("price('UNKNOWN/PAIR')") + + +@pytest.mark.asyncio +async def test_price_operator_no_valid_price(exchange_manager_with_prices): + symbol_data = exchange_manager_with_prices.exchange_symbols_data.get_exchange_symbol_data(SYMBOL) + symbol_data.prices_manager.get_mark_price_no_wait = mock.Mock( + side_effect=ValueError(f"No up to date mark price for {SYMBOL}") + ) + price_interpreter = dsl_interpreter.Interpreter( + dsl_interpreter.get_all_operators() + + exchange_operators.create_price_operators(exchange_manager_with_prices, SYMBOL) + ) + with pytest.raises(octobot_commons.errors.DSLInterpreterError, match="No up to date mark price"): + await price_interpreter.interprete("price") + + +@pytest.mark.asyncio +async def test_price_operator_without_exchange_manager(interpreter_without_exchange_data): + with pytest.raises(octobot_commons.errors.DSLInterpreterError, match="exchange_manager must be provided"): + await interpreter_without_exchange_data.interprete(f"price('{SYMBOL}')") + + +class TestGetDependencies: + @pytest.mark.asyncio + async def test_price_dependencies_with_context_symbol(self, interpreter): + interpreter.prepare("price") + assert interpreter.get_dependencies() == [ + exchange_operators.ExchangeDataDependency( + symbol=SYMBOL, + time_frame=None, + data_source=octobot_trading.constants.MARK_PRICE_CHANNEL, + ) + ] + + @pytest.mark.asyncio + async def test_price_dependencies_with_param_symbol(self, interpreter): + interpreter.prepare(f"price + price('{SYMBOL2}')") + assert interpreter.get_dependencies() == [ + exchange_operators.ExchangeDataDependency( + symbol=SYMBOL, + time_frame=None, + data_source=octobot_trading.constants.MARK_PRICE_CHANNEL, + ), + exchange_operators.ExchangeDataDependency( + symbol=SYMBOL2, + time_frame=None, + data_source=octobot_trading.constants.MARK_PRICE_CHANNEL, + ), + ] + + @pytest.mark.asyncio + async def test_price_dependencies_without_exchange_manager(self, interpreter_without_exchange_data): + interpreter_without_exchange_data.prepare("price") + assert interpreter_without_exchange_data.get_dependencies() == [] + interpreter_without_exchange_data.prepare(f"price + price('{SYMBOL2}')") + assert interpreter_without_exchange_data.get_dependencies() == [ + exchange_operators.ExchangeDataDependency( + symbol=SYMBOL2, + time_frame=None, + data_source=octobot_trading.constants.MARK_PRICE_CHANNEL, + ), + ] + + +class TestGetExchangeSymbol: + @pytest.mark.asyncio + async def test_pre_compute_calls_get_exchange_symbol_with_context_symbol( + self, exchange_manager_with_prices + ): + price_interpreter = dsl_interpreter.Interpreter( + dsl_interpreter.get_all_operators() + + exchange_operators.create_price_operators(exchange_manager_with_prices, SYMBOL) + ) + await price_interpreter.interprete("price") + exchange_manager_with_prices.get_exchange_symbol.assert_called_once_with(SYMBOL) + + @pytest.mark.asyncio + async def test_pre_compute_calls_get_exchange_symbol_with_param_symbol( + self, exchange_manager_with_prices + ): + price_interpreter = dsl_interpreter.Interpreter( + dsl_interpreter.get_all_operators() + + exchange_operators.create_price_operators(exchange_manager_with_prices, SYMBOL) + ) + await price_interpreter.interprete(f"price('{SYMBOL2}')") + exchange_manager_with_prices.get_exchange_symbol.assert_called_once_with(SYMBOL2) + + @pytest.mark.asyncio + async def test_get_dependencies_calls_get_exchange_symbol(self, exchange_manager_with_prices): + price_interpreter = dsl_interpreter.Interpreter( + dsl_interpreter.get_all_operators() + + exchange_operators.create_price_operators(exchange_manager_with_prices, SYMBOL) + ) + price_interpreter.prepare("price") + price_interpreter.get_dependencies() + exchange_manager_with_prices.get_exchange_symbol.assert_called_once_with(SYMBOL) + + @pytest.mark.asyncio + async def test_get_dependencies_does_not_call_get_exchange_symbol_without_exchange_manager( + self, interpreter_without_exchange_data + ): + with mock.patch.object( + exchange_operators.exchange_public_data_operators.ohlcv_operators.ExchangeDataDependency, + "resolve_symbol", + autospec=True, + ) as resolve_symbol_mock: + interpreter_without_exchange_data.prepare(f"price('{SYMBOL2}')") + interpreter_without_exchange_data.get_dependencies() + resolve_symbol_mock.assert_called() + assert all( + call.args[1] is None + for call in resolve_symbol_mock.call_args_list + ) + + @pytest.mark.asyncio + async def test_pre_compute_uses_resolved_symbol_downstream( + self, exchange_manager_with_prices + ): + exchange_manager_with_prices.get_exchange_symbol = mock.Mock(return_value=RESOLVED_SYMBOL) + price_interpreter = dsl_interpreter.Interpreter( + dsl_interpreter.get_all_operators() + + exchange_operators.create_price_operators(exchange_manager_with_prices, SYMBOL) + ) + with mock.patch.object( + octobot_trading.api, "get_symbol_data", wraps=octobot_trading.api.get_symbol_data + ) as get_symbol_data_spy: + await price_interpreter.interprete("price") + get_symbol_data_spy.assert_called_once_with( + exchange_manager_with_prices, RESOLVED_SYMBOL, allow_creation=False + ) diff --git a/packages/tentacles/Services/Interfaces/web_interface/models/dsl.py b/packages/tentacles/Services/Interfaces/web_interface/models/dsl.py index 4783d34a94..0ce6a39fe2 100644 --- a/packages/tentacles/Services/Interfaces/web_interface/models/dsl.py +++ b/packages/tentacles/Services/Interfaces/web_interface/models/dsl.py @@ -23,6 +23,8 @@ def get_dsl_keywords_docs() -> list[dsl_interpreter.OperatorDocs]: dsl_interpreter.get_all_operators() + dsl_operators.create_ohlcv_operators( None, None, None + ) + dsl_operators.create_price_operators( + None, None ) + dsl_operators.create_portfolio_operators( None ) + dsl_operators.create_create_order_operators( diff --git a/packages/tentacles/Trading/Mode/dsl_trading_mode/dsl_trading.py b/packages/tentacles/Trading/Mode/dsl_trading_mode/dsl_trading.py index c795f12687..1321689d98 100644 --- a/packages/tentacles/Trading/Mode/dsl_trading_mode/dsl_trading.py +++ b/packages/tentacles/Trading/Mode/dsl_trading_mode/dsl_trading.py @@ -114,6 +114,7 @@ def _create_interpreter( return dsl_interpreter.Interpreter( dsl_interpreter.get_all_operators() + dsl_operators.create_ohlcv_operators(self.exchange_manager, None, None) + + dsl_operators.create_price_operators(self.exchange_manager, None) + dsl_operators.create_portfolio_operators(self.exchange_manager) + dsl_operators.create_create_order_operators( self.exchange_manager, trading_mode=self, dependencies=dependencies diff --git a/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/advanced_reference_price.py b/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/advanced_reference_price.py index 1d0db44afc..e0232f5764 100644 --- a/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/advanced_reference_price.py +++ b/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/advanced_reference_price.py @@ -69,10 +69,15 @@ def get_dependencies(self, exchange_manager) -> typing.List[exchange_operators.E ) ] if self.formula: - return ( + all_dependencies = ( base_dependencies + self._formula_interpreter.get_dependencies() # type: ignore ) + deduplicated_dependencies = [] + for dependency in all_dependencies: + if dependency not in deduplicated_dependencies: + deduplicated_dependencies.append(dependency) + return deduplicated_dependencies return base_dependencies async def initialize_if_required( @@ -107,6 +112,22 @@ async def _evaluate_formula(self) -> typing.Any: formula_result = await self._formula_interpreter.compute_expression() return formula_result + def _get_formula_interpreter_operators( + self, exchange_manager, + time_frame: commons_enums.TimeFrames, + candle_manager_by_time_frame_by_symbol: typing.Optional[ + typing.Dict[str, typing.Dict[str, exchange_data.CandlesManager]] + ] + ) -> typing.List[type[dsl_interpreter.Operator]]: + base_operators = dsl_interpreter.get_all_operators() + ohlcv_operators = exchange_operators.create_ohlcv_operators( + exchange_manager, self.pair, time_frame.value, candle_manager_by_time_frame_by_symbol + ) + price_operators = exchange_operators.create_price_operators( + exchange_manager, self.pair + ) + return base_operators + ohlcv_operators + price_operators + def _initialize_formula_interpreter( self, exchange_manager, @@ -129,11 +150,10 @@ def _initialize_formula_interpreter( if not time_frame: raise ValueError("No time frame available") time_frame = commons_enums.TimeFrames(time_frame) - ohlcv_operators = exchange_operators.create_ohlcv_operators( - exchange_manager, self.pair, time_frame.value, candle_manager_by_time_frame_by_symbol - ) self._formula_interpreter = dsl_interpreter.Interpreter( - dsl_interpreter.get_all_operators() + ohlcv_operators + self._get_formula_interpreter_operators( + exchange_manager, time_frame, candle_manager_by_time_frame_by_symbol + ) ) logger = logging.get_logger(self.__class__.__name__) try: diff --git a/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/simple_market_making_trading.py b/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/simple_market_making_trading.py index 09d2829486..1314b8fa2b 100644 --- a/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/simple_market_making_trading.py +++ b/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/simple_market_making_trading.py @@ -312,6 +312,7 @@ def init_user_inputs(self, inputs: dict) -> None: self.HEDGING_EXCHANGE: self.UI.user_input( self.HEDGING_EXCHANGE, commons_enums.UserInputTypes.TEXT, "", inputs, parent_input_name=self.HEDGING_ENGINE, + other_schema_values={"minLength": 0}, title="Hedging exchange: exchange to hedge on. This exchange must be enabled in exchange configuration.", ), } diff --git a/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/tests/test_advanced_reference_price.py b/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/tests/test_advanced_reference_price.py index 2f93f0043b..3a01f9bb24 100644 --- a/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/tests/test_advanced_reference_price.py +++ b/packages/tentacles/Trading/Mode/simple_market_making_trading_mode/tests/test_advanced_reference_price.py @@ -39,6 +39,7 @@ def exchange_manager_mock(): exchange_manager = mock.Mock() exchange_manager.id = str(uuid.uuid4()) exchange_manager.exchange_name = "binance" + exchange_manager.get_exchange_symbol = mock.Mock(side_effect=lambda symbol: symbol) return exchange_manager @@ -295,33 +296,52 @@ def test_advanced_price_source_attributes(price_source_no_formula): assert price_source_no_formula._formula_interpreter is None -async def test_get_dependencies_with_formula_dependencies(price_source_with_formula, exchange_manager_mock): - """Test get_dependencies returns dependencies from interpreter when using OHLCV operators.""" - # Use a formula that would use OHLCV operators to get dependencies +@pytest.mark.parametrize("formula, expected_formula_dependencies", [ + pytest.param( + "close", + [ + exchange_operators.ExchangeDataDependency( + symbol="BTC/USDT", + time_frame=commons_enums.TimeFrames.ONE_HOUR.value, + data_source=trading_constants.OHLCV_CHANNEL, + ), + ], + id="close", + ), + pytest.param( + "price", + [], + id="price", + ), +]) +async def test_get_dependencies_with_formula_dependencies( + price_source_with_formula, + exchange_manager_mock, + formula, + expected_formula_dependencies, +): + """Test get_dependencies returns dependencies from interpreter for OHLCV and price operators.""" ohlcv_operators = exchange_operators.create_ohlcv_operators( exchange_manager_mock, "BTC/USDT", commons_enums.TimeFrames.ONE_HOUR.value ) - - # Use a formula that references OHLCV data - price_source_with_formula.formula = "close" - + price_operators = exchange_operators.create_price_operators( + exchange_manager_mock, "BTC/USDT" + ) + price_source_with_formula.formula = formula + with mock.patch.object( trading_api, 'get_watched_timeframes', return_value=[commons_enums.TimeFrames.ONE_HOUR] ), mock.patch.object( exchange_operators, 'create_ohlcv_operators', return_value=ohlcv_operators + ), mock.patch.object( + exchange_operators, 'create_price_operators', return_value=price_operators ): await price_source_with_formula.initialize_if_required(exchange_manager_mock) - + + mark_price_dependency = exchange_operators.ExchangeDataDependency( + symbol="BTC/USDT", + time_frame=None, + data_source=trading_constants.MARK_PRICE_CHANNEL, + ) dependencies = price_source_with_formula.get_dependencies(exchange_manager_mock) - assert dependencies == [ - exchange_operators.ExchangeDataDependency( - symbol="BTC/USDT", - time_frame=None, - data_source=trading_constants.MARK_PRICE_CHANNEL - ), - exchange_operators.ExchangeDataDependency( - symbol="BTC/USDT", - time_frame=commons_enums.TimeFrames.ONE_HOUR.value, - data_source=trading_constants.OHLCV_CHANNEL - ) - ] + assert dependencies == [mark_price_dependency] + expected_formula_dependencies