Repository: rotki/rotki

Issue:
Inquirer doesn't use cached price for groupped assets
We have collections of assets that use the same oracle id. The problem is that the inquirer doesn't use this information and if it has already queried the USDC price for the ethereum token it queries it again for the USDC in optimism.

This has a negative impact due to rate limits and slower queries.

## Task

Make use of the cache when querying prices of assets that belong to a collection and any of the members has already been queried


Discussion:

Resolving patch:
diff --git a/rotkehlchen/globaldb/handler.py b/rotkehlchen/globaldb/handler.py
--- a/rotkehlchen/globaldb/handler.py
+++ b/rotkehlchen/globaldb/handler.py
@@ -1936,3 +1936,23 @@ def get_or_write_abi(serialized_abi: str, abi_name: str | None = None) -> int:
                     (abi_name, serialized_abi),
                 )
                 return cursor.lastrowid
+
+    @staticmethod
+    def get_assets_in_same_collection(identifier: str) -> tuple[Asset, ...]:
+        """
+        Query the assets that belong to the collection of the queried asset. If the
+        asset isn't in any collection we return a list with the asset queried.
+        """
+        with GlobalDBHandler().conn.read_ctx() as cursor:
+            cursor.execute(
+                'SELECT MM.asset FROM multiasset_mappings AS MM JOIN asset_collections AS AC '
+                'ON MM.collection_id = AC.id WHERE MM.collection_id = (SELECT collection_id '
+                'FROM multiasset_mappings WHERE asset=?)',
+                (identifier,),
+            )
+            collection_assets = tuple(Asset(row[0]) for row in cursor)
+
+        if len(collection_assets) == 0:
+            return (Asset(identifier),)
+
+        return collection_assets
diff --git a/rotkehlchen/inquirer.py b/rotkehlchen/inquirer.py
--- a/rotkehlchen/inquirer.py
+++ b/rotkehlchen/inquirer.py
@@ -394,6 +394,13 @@ def set_oracles_order(oracles: Sequence[CurrentPriceOracle]) -> None:
                 instance._oracles_not_onchain.append(oracle)
                 instance._oracle_instances_not_onchain.append(oracle_instance)
 
+    @staticmethod
+    def set_cached_price(cache_key: tuple[Asset, Asset], cached_price: CachedPriceEntry) -> None:
+        """Save cached price for the key provided and all the assets in the same collection"""
+        related_assets = GlobalDBHandler().get_assets_in_same_collection(cache_key[0].identifier)
+        for related_asset in related_assets:
+            Inquirer._cached_current_price[(related_asset, cache_key[1])] = cached_price
+
     @staticmethod
     def _query_oracle_instances(
             from_asset: Asset,
@@ -479,11 +486,14 @@ def _query_oracle_instances(
                 )
                 break
 
-        Inquirer._cached_current_price[cache_key] = CachedPriceEntry(
-            price=price,
-            time=ts_now(),
-            oracle=oracle_queried,
-            used_main_currency=used_main_currency,
+        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
 
@@ -664,18 +674,29 @@ def _find_usd_price(
                 )
                 price = ZERO_PRICE if usd_price is None else Price(usd_price)
 
-                Inquirer._cached_current_price[cache_key] = CachedPriceEntry(price=price, time=ts_now(), oracle=CurrentPriceOracle.BLOCKCHAIN, used_main_currency=False)  # noqa: E501
+                Inquirer.set_cached_price(
+                    cache_key=cache_key,
+                    cached_price=CachedPriceEntry(
+                        price=price,
+                        time=ts_now(),
+                        oracle=CurrentPriceOracle.BLOCKCHAIN,
+                        used_main_currency=False,
+                    ),
+                )
                 return price, oracle, False
 
             if is_known_protocol is True or underlying_tokens is not None:
                 result, oracle = get_underlying_asset_price(asset)
                 if result is not None:
                     usd_price = Price(result)
-                    Inquirer._cached_current_price[cache_key] = CachedPriceEntry(
-                        price=usd_price,
-                        time=ts_now(),
-                        oracle=oracle,
-                        used_main_currency=False,  # function is for usd only, so it doesn't matter
+                    Inquirer.set_cached_price(
+                        cache_key=cache_key,
+                        cached_price=CachedPriceEntry(
+                            price=usd_price,
+                            time=ts_now(),
+                            oracle=oracle,
+                            used_main_currency=False,  # function is for usd only, so it doesn't matter  # noqa: E501
+                        ),
                     )
                     return usd_price, oracle, False
                 # else known protocol on-chain query failed. Continue to external oracles
@@ -692,11 +713,14 @@ def _find_usd_price(
                 price_in_btc = get_bisq_market_price(bsq)
                 btc_price, oracle, _ = Inquirer().find_usd_price_and_oracle(A_BTC)
                 usd_price = Price(price_in_btc * btc_price)
-                Inquirer._cached_current_price[cache_key] = CachedPriceEntry(
-                    price=usd_price,
-                    time=ts_now(),
-                    oracle=oracle,
-                    used_main_currency=False,  # this is for usd only, so it doesn't matter
+                Inquirer.set_cached_price(
+                    cache_key=cache_key,
+                    cached_price=CachedPriceEntry(
+                        price=usd_price,
+                        time=ts_now(),
+                        oracle=oracle,
+                        used_main_currency=False,  # this is for usd only, so it doesn't matter
+                    ),
                 )
             except (RemoteError, DeserializationError) as e:
                 msg = f'Could not find price for BSQ. {e!s}'


Test patch:
diff --git a/rotkehlchen/tests/unit/globaldb/test_globaldb.py b/rotkehlchen/tests/unit/globaldb/test_globaldb.py
--- a/rotkehlchen/tests/unit/globaldb/test_globaldb.py
+++ b/rotkehlchen/tests/unit/globaldb/test_globaldb.py
@@ -1285,3 +1285,18 @@ def test_get_evm_tokens(globaldb):
     tokens = globaldb.get_evm_tokens(chain_id=ChainID.ETHEREUM, protocol=CPT_COMPOUND, exceptions=(exception_address,))  # noqa: E501
     assert len(tokens) == tokens_without_exception - 1
     assert not any(token.evm_address == exception_address for token in tokens)
+
+
+def test_assets_in_same_collection(globaldb: GlobalDBHandler):
+    """Check that we get the expected related assets when querying assets in a collection"""
+    wsteth = Asset('eip155:1/erc20:0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0')
+    realted_assets = globaldb.get_assets_in_same_collection(identifier=wsteth.identifier)
+
+    assert realted_assets == (
+        wsteth,
+        Asset('eip155:10/erc20:0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb'),
+        Asset('eip155:42161/erc20:0x5979D7b546E38E414F7E9822514be443A4800529'),
+    )
+
+    # check an asset with no related assets
+    assert globaldb.get_assets_in_same_collection(identifier=A_ETH.identifier) == (A_ETH,)
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
@@ -1,6 +1,7 @@
 import datetime
 import os
 from http import HTTPStatus
+from unittest import mock
 from unittest.mock import MagicMock, patch
 
 import pytest
@@ -611,3 +612,21 @@ def test_find_protocol_price_falllback_to_oracle(inquirer_defi):
     with yearn_patch:
         price = inquirer_defi.find_usd_price(yvusdc)
     assert price is not None and price != ZERO
+
+
+@pytest.mark.vcr()
+@pytest.mark.parametrize('should_mock_current_price_queries', [False])
+def test_cache_is_hit_for_collection(inquirer: Inquirer):
+    """Test that the price for a collection is saved to cache and not query for every asset"""
+    wsteth = Asset('eip155:1/erc20:0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0')
+    wsteth_op = Asset('eip155:10/erc20:0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb')
+    with mock.patch.object(
+        Inquirer,
+        '_query_oracle_instances',
+        wraps=inquirer._query_oracle_instances,
+    ) as oracle_query:
+        inquirer.find_usd_price(wsteth)
+        assert (wsteth_op, A_USD) in inquirer._cached_current_price
+        inquirer.find_usd_price(wsteth_op)
+
+    assert oracle_query.call_count == 1