Repository: rotki/rotki

Issue:
Manual price is ignored if asset has special price calculation
## Problem Definition

If the asset has a special price calculation such as for curve pools, then we don't get to go to the manual price oracle, so any manually input price is ignored. So if our special price calculation method is broken, then there is no way for a user to fix it.

Seen in 1.32.1.

## Task

Fix this and make sure manual price oracle is preferred above all other methods.


Discussion:

Resolving patch:
diff --git a/rotkehlchen/inquirer.py b/rotkehlchen/inquirer.py
--- a/rotkehlchen/inquirer.py
+++ b/rotkehlchen/inquirer.py
@@ -5,7 +5,7 @@
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, NamedTuple, Optional, Union
 
-from rotkehlchen.assets.asset import Asset, EvmToken, FiatAsset, UnderlyingToken
+from rotkehlchen.assets.asset import Asset, AssetWithOracles, EvmToken, FiatAsset, UnderlyingToken
 from rotkehlchen.assets.utils import TokenEncounterInfo, get_or_create_evm_token
 from rotkehlchen.chain.ethereum.defi.price import handle_defi_price_query
 from rotkehlchen.chain.ethereum.utils import token_normalized_value_decimals
@@ -382,8 +382,8 @@ def set_oracles_order(oracles: Sequence[CurrentPriceOracle]) -> None:
             "Oracles can't be empty or have repeated items"
         )
         instance = Inquirer()
-        instance._oracles = oracles
-        instance._oracle_instances = [getattr(instance, f'_{oracle!s}') for oracle in oracles]
+        instance._oracles = [oracle for oracle in oracles if oracle is not CurrentPriceOracle.MANUALCURRENT]  # noqa: E501  # TODO: remove MANUALCURRENT from the settings
+        instance._oracle_instances = [getattr(instance, f'_{oracle!s}') for oracle in instance._oracles]  # noqa: E501
         instance._oracles_not_onchain = []
         instance._oracle_instances_not_onchain = []
         for oracle, oracle_instance in zip(instance._oracles, instance._oracle_instances, strict=True):  # noqa: E501
@@ -398,6 +398,57 @@ def set_cached_price(cache_key: tuple[Asset, Asset], cached_price: CachedPriceEn
         for related_asset in related_assets:
             Inquirer._cached_current_price.add((related_asset, cache_key[1]), cached_price)
 
+    @staticmethod
+    def _try_oracle_price_query(
+            oracle: CurrentPriceOracle,
+            oracle_instance: CurrentPriceOracleInstance,
+            from_asset: AssetWithOracles,
+            to_asset: AssetWithOracles,
+            coming_from_latest_price: bool,
+            match_main_currency: bool,
+    ) -> tuple[Price, bool, bool]:
+        """Tries to query the current price of the asset pair using the provided oracle instance.
+        Returns a tuple of (price, used_main_currency, is_error). The third element is used to
+        indicate whether the error should be ignored or not."""
+        price, used_main_currency, is_error = ZERO_PRICE, False, True
+        try:
+            price, used_main_currency, is_error = *oracle_instance.query_current_price(
+                from_asset=from_asset,
+                to_asset=to_asset,
+                match_main_currency=match_main_currency,
+            ), False
+        except (DefiPoolError, PriceQueryUnsupportedAsset, RemoteError) as e:
+            log.warning(
+                f'Current price oracle {oracle_instance} failed to request {to_asset!s} '
+                f'price for {from_asset.identifier} due to: {e!s}.',
+            )
+        except RecursionError:
+            # We have to catch recursion error only at the top level since otherwise we get to
+            # recursion level MAX - 1, and after calling some other function may run into it again.  # noqa: E501
+            if coming_from_latest_price is True:
+                raise
+
+            # else
+            # Infinite loop can happen if user creates a loop of manual current prices
+            # (e.g. said that 1 BTC costs 2 ETH and 1 ETH costs 5 BTC).
+            Inquirer._msg_aggregator.add_warning(
+                f'Was not able to find price from {from_asset!s} to {to_asset!s} since your '
+                f'manual latest prices form a loop. For now, other oracles will be used.',
+            )
+            is_error = False
+
+        if (price, used_main_currency) != (ZERO_PRICE, False):
+            Inquirer.set_cached_price(
+                cache_key=(from_asset, to_asset),
+                cached_price=CachedPriceEntry(
+                    price=price,
+                    time=ts_now(),
+                    oracle=oracle,
+                    used_main_currency=used_main_currency,
+                ),
+            )
+        return price, used_main_currency, is_error
+
     @staticmethod
     def _query_oracle_instances(
             from_asset: Asset,
@@ -411,7 +462,6 @@ def _query_oracle_instances(
         `coming_from_latest_price` is used by manual latest price oracle to handle price loops.
         """
         instance = Inquirer()
-        cache_key = (from_asset, to_asset)
         assert (
             instance._oracles is not None and
             instance._oracle_instances is not None and
@@ -430,8 +480,7 @@ def _query_oracle_instances(
                 oracles = instance._oracles
                 oracle_instances = instance._oracle_instances
         else:
-            oracles = [CurrentPriceOracle.MANUALCURRENT]
-            oracle_instances = [instance._manualcurrent]
+            return ZERO_PRICE, CurrentPriceOracle.BLOCKCHAIN, False
 
         price = ZERO_PRICE
         oracle_queried = CurrentPriceOracle.BLOCKCHAIN
@@ -446,31 +495,15 @@ def _query_oracle_instances(
             ):
                 continue
 
-            try:
-                price, used_main_currency = oracle_instance.query_current_price(
-                    from_asset=from_asset,  # type: ignore  # type is guaranteed by the if above
-                    to_asset=to_asset,  # type: ignore  # type is guaranteed by the if above
-                    match_main_currency=match_main_currency,
-                )
-            except (DefiPoolError, PriceQueryUnsupportedAsset, RemoteError) as e:
-                log.warning(
-                    f'Current price oracle {oracle} failed to request {to_asset.identifier} '
-                    f'price for {from_asset.identifier} due to: {e!s}.',
-                )
-                continue
-            except RecursionError:
-                # We have to catch recursion error only at the top level since otherwise we get to
-                # recursion level MAX - 1, and after calling some other function may run into it again.  # noqa: E501
-                if coming_from_latest_price is True:
-                    raise
-
-                # else
-                # Infinite loop can happen if user creates a loop of manual current prices
-                # (e.g. said that 1 BTC costs 2 ETH and 1 ETH costs 5 BTC).
-                instance._msg_aggregator.add_warning(
-                    f'Was not able to find price from {from_asset!s} to {to_asset!s} since your '
-                    f'manual latest prices form a loop. For now, other oracles will be used.',
-                )
+            price, used_main_currency, should_continue = Inquirer._try_oracle_price_query(
+                oracle=oracle,
+                oracle_instance=oracle_instance,
+                from_asset=from_asset,
+                to_asset=to_asset,
+                coming_from_latest_price=coming_from_latest_price,
+                match_main_currency=match_main_currency,
+            )
+            if should_continue:
                 continue
 
             if price != ZERO_PRICE:
@@ -483,15 +516,6 @@ def _query_oracle_instances(
                 )
                 break
 
-        Inquirer.set_cached_price(
-            cache_key=cache_key,
-            cached_price=CachedPriceEntry(
-                price=price,
-                time=ts_now(),
-                oracle=oracle_queried,
-                used_main_currency=used_main_currency,
-            ),
-        )
         return price, oracle_queried, used_main_currency
 
     @staticmethod
@@ -529,6 +553,18 @@ def _find_price(
             if cache is not None:
                 return cache.price, cache.oracle, cache.used_main_currency
 
+        # check manual prices
+        if (price_result := Inquirer._try_oracle_price_query(
+            oracle=CurrentPriceOracle.MANUALCURRENT,
+            oracle_instance=Inquirer._manualcurrent,
+            from_asset=from_asset,  # type: ignore[arg-type]  # Manual current oracle can works with Asset type
+            to_asset=to_asset,  # type: ignore[arg-type]  # Manual current oracle can works with Asset type
+            coming_from_latest_price=coming_from_latest_price,
+            match_main_currency=match_main_currency,
+        )) != (ZERO_PRICE, False, False):
+            price, used_main_currency, _ = price_result
+            return price, CurrentPriceOracle.MANUALCURRENT, used_main_currency
+
         oracle_price, oracle_queried, used_main_currency = Inquirer._query_oracle_instances(
             from_asset=from_asset,
             to_asset=to_asset,
@@ -648,7 +684,17 @@ def _find_usd_price(
                 price, oracle = Inquirer._query_fiat_pair(base=asset, quote=Inquirer.usd)
                 return price, oracle, False
 
-        # continue, asset isn't fiat or a price can be found by one of the oracles (CC for example)
+        # continue, asset isn't fiat, check manual prices
+        if (price_result := Inquirer._try_oracle_price_query(
+            oracle=CurrentPriceOracle.MANUALCURRENT,
+            oracle_instance=Inquirer._manualcurrent,
+            from_asset=asset,  # type: ignore[arg-type]  # Manual current oracle can works with Asset type
+            to_asset=A_USD.resolve_to_asset_with_oracles(),
+            coming_from_latest_price=coming_from_latest_price,
+            match_main_currency=match_main_currency,
+        )) != (ZERO_PRICE, False, False):
+            price, used_main_currency, _ = price_result
+            return price, CurrentPriceOracle.MANUALCURRENT, used_main_currency
 
         # Try and check if it is an ethereum token with specified protocol or underlying tokens
         is_known_protocol = False
@@ -728,6 +774,7 @@ def _find_usd_price(
             # KFEE is a kraken special asset where 1000 KFEE = 10 USD
             return Price(FVal(0.01)), CurrentPriceOracle.FIAT, False
 
+        # continue, price can be found by one of the oracles (CC for example)
         price, oracle, used_main_currency = Inquirer._query_oracle_instances(
             from_asset=asset,
             to_asset=A_USD,


Test patch:
diff --git a/rotkehlchen/tests/api/test_current_assets_price.py b/rotkehlchen/tests/api/test_current_assets_price.py
--- a/rotkehlchen/tests/api/test_current_assets_price.py
+++ b/rotkehlchen/tests/api/test_current_assets_price.py
@@ -318,8 +318,8 @@ def test_manual_current_prices_loop(inquirer):
 
 @pytest.mark.vcr()
 @pytest.mark.parametrize('ignore_mocked_prices_for', ['ETH'])
-def test_inquirer_oracles_affect_manual_price(inquirer):
-    """Checks that change of oracles order affects manual current price usage.
+def test_inquirer_oracles_does_not_affect_manual_price(inquirer):
+    """Checks that change of oracles order does not affect manual current price usage.
 
     This test is mocked because we were seeing cases of tests failing due to
     an exception from coingecko API.
@@ -335,7 +335,7 @@ def test_inquirer_oracles_affect_manual_price(inquirer):
     )
     assert inquirer.find_usd_price(A_ETH) == 3  # Should remain the same since cache should be hit
     inquirer.remove_cached_current_price_entry(cache_key=(A_ETH, A_USD))
-    assert inquirer.find_usd_price(A_ETH) == FVal('1667.18')  # it must be equal to the mocked price at the time  # noqa: E501
+    assert inquirer.find_usd_price(A_ETH) == 3  # manual price is still prioritized
 
 
 @pytest.mark.parametrize('should_mock_current_price_queries', [False])
diff --git a/rotkehlchen/tests/unit/test_inquirer.py b/rotkehlchen/tests/unit/test_inquirer.py
--- a/rotkehlchen/tests/unit/test_inquirer.py
+++ b/rotkehlchen/tests/unit/test_inquirer.py
@@ -339,6 +339,21 @@ def test_find_usd_price_via_second_oracle(inquirer):
         assert oracle_instance.query_current_price.call_count == 1
 
 
+@pytest.mark.parametrize('use_clean_caching_directory', [True])
+@pytest.mark.parametrize('should_mock_current_price_queries', [False])
+def test_find_usd_price_manual_prices_preference(inquirer, globaldb):
+    """Test that manual prices is checked first before all other oracles
+    and special price calculations."""
+    inquirer._oracle_instances = [MagicMock() for _ in inquirer._oracles]
+    manual_price = Price(FVal('30000'))
+    globaldb.add_manual_latest_price(from_asset=A_BTC, to_asset=A_USD, price=manual_price)
+
+    assert inquirer.find_usd_price(A_BTC) == manual_price
+
+    for oracle_instance in inquirer._oracle_instances:
+        assert oracle_instance.query_current_price.call_count == 0
+
+
 @pytest.mark.parametrize('use_clean_caching_directory', [True])
 @pytest.mark.parametrize('should_mock_current_price_queries', [True])
 @pytest.mark.parametrize('mocked_current_prices', [UNDERLYING_ASSET_PRICES])