Repository: tellor-io/telliot-feeds (language: python)

Issue:
mimicry feed test failing
- [x] unskip test and fix
```console
tests/feeds/test_mimicry_feed.py::test_fetch_new_datapoint 
---------------------------------------------------------------------------- live log call -----------------------------------------------------------------------------
ERROR    telliot_feeds.sources.mimicry:mimicry.py:55 Mimicry API error: HTTPConnectionPool(host='50.112.84.236', port=3000): Read timed out. (read timeout=10)
WARNING  telliot_feeds.sources.mimicry:mimicry.py:83 No response from Mimicry API
FAILED 
```


Discussion / hints:

Fix patch:
diff --git a/src/telliot_feeds/feeds/mimicry_feed.py b/src/telliot_feeds/feeds/mimicry_feed.py
index 195daafb..07e94b09 100644
--- a/src/telliot_feeds/feeds/mimicry_feed.py
+++ b/src/telliot_feeds/feeds/mimicry_feed.py
@@ -1,7 +1,7 @@
 """DataFeed for MimicryCollectionStat query type. Calculates TAMI index or NFT market cap"""
 from telliot_feeds.datafeed import DataFeed
 from telliot_feeds.queries.mimicry import MimicryCollectionStat
-from telliot_feeds.sources.mimicry import MimicryCollectionStatSource
+from telliot_feeds.sources.mimicry.collection_stat import MimicryCollectionStatSource
 
 chain_id = None
 collection_address = None
diff --git a/src/telliot_feeds/sources/mimicry.py b/src/telliot_feeds/sources/mimicry.py
deleted file mode 100644
index 436d6d79..00000000
--- a/src/telliot_feeds/sources/mimicry.py
+++ /dev/null
@@ -1,117 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Any
-from typing import Optional
-
-import requests
-from requests import JSONDecodeError
-from requests import Response
-from requests.adapters import HTTPAdapter
-from urllib3.util import Retry
-
-from telliot_feeds.datasource import DataSource
-from telliot_feeds.dtypes.datapoint import datetime_now_utc
-from telliot_feeds.dtypes.datapoint import OptionalDataPoint
-from telliot_feeds.utils.log import get_logger
-
-
-logger = get_logger(__name__)
-retry_strategy = Retry(
-    total=3,
-    backoff_factor=1,
-    status_forcelist=[429, 500, 502, 503, 504],
-    allowed_methods=["GET"],
-)
-adapter = HTTPAdapter(max_retries=retry_strategy)
-
-
-@dataclass
-class MimicryCollectionStatSource(DataSource[str]):
-    """DataSource for MimicryCollectionStat expected response data."""
-
-    chainId: Optional[int] = None
-    collectionAddress: Optional[str] = None
-    metric: Optional[int] = None
-
-    async def fetch_mimicry_api(self) -> Optional[Response]:
-        """
-        Request data from hosted api
-
-        see https://github.com/Mimicry-Protocol/TAMI/
-        """
-
-        if self.collectionAddress is not None:
-            self.collectionAddress = self.collectionAddress.lower()
-
-        url = f"http://50.112.84.236:3000/api/stats?address={self.collectionAddress}&stat={self.metric}"
-
-        with requests.Session() as s:
-            s.mount("https://", adapter)
-            try:
-                return s.get(url=url, timeout=10)
-
-            except requests.exceptions.RequestException as e:
-                logger.error(f"Mimicry API error: {e}")
-                return None
-
-            except requests.exceptions.Timeout as e:
-                logger.error(f"Mimicry API timed out: {e}")
-                return None
-
-    async def fetch_new_datapoint(
-        self,
-    ) -> OptionalDataPoint[Any]:
-        """
-        Calculates desired metric for a collection on the chosen chain id.
-
-        Returns:
-            float -- the desired metric
-        """
-
-        if not self.collectionAddress:
-            logger.error("Missing a collection address for Mimicry NFT index calculation")
-            return None, None
-
-        if self.metric is None:
-            logger.error("Missing a metric for Mimicry NFT index calculation")
-            return None, None
-
-        rsp = await self.fetch_mimicry_api()
-
-        if rsp is None:
-            logger.warning("No response from Mimicry API")
-            return None, None
-
-        if rsp.status_code // 100 != 2:
-            logger.warning("Invalid response from Mimicry API: " + str(rsp.status_code))
-            return None, None
-
-        try:
-            mimicry_dict = rsp.json()
-        except JSONDecodeError as e:
-            logger.error("Mimicry API returned invalid JSON:", e.strerror)
-            return None, None
-
-        if mimicry_dict == {}:
-            logger.warning("Mimicry API returned no data.")
-            return None, None
-
-        try:
-            mimicry_stat = mimicry_dict["value"]
-        except KeyError:
-            logger.error("Unable to parse Mimicry API JSON response")
-            return None, None
-
-        datapoint = (mimicry_stat, datetime_now_utc())
-        self.store_datapoint(datapoint)
-
-        logger.info(
-            f"""
-            Mimicry API data retrieved at time
-            {datapoint[1]} for metric {self.metric}
-            on collection {self.collectionAddress}
-            """
-        )
-
-        return datapoint
diff --git a/src/telliot_feeds/sources/mimicry/__init__.py b/src/telliot_feeds/sources/mimicry/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/telliot_feeds/sources/mimicry/collection_stat.py b/src/telliot_feeds/sources/mimicry/collection_stat.py
new file mode 100644
index 00000000..1520ec09
--- /dev/null
+++ b/src/telliot_feeds/sources/mimicry/collection_stat.py
@@ -0,0 +1,232 @@
+from dataclasses import dataclass
+from dataclasses import field
+from datetime import datetime
+from datetime import timezone
+from typing import Any
+from typing import List
+from typing import Optional
+from typing import Union
+
+import requests
+from dateutil.relativedelta import relativedelta
+from requests.adapters import HTTPAdapter
+from requests.exceptions import RequestException
+from requests.exceptions import Timeout
+from urllib3.util import Retry
+
+from telliot_feeds.datasource import DataSource
+from telliot_feeds.dtypes.datapoint import datetime_now_utc
+from telliot_feeds.dtypes.datapoint import OptionalDataPoint
+from telliot_feeds.sources.mimicry.tami import tami
+from telliot_feeds.sources.mimicry.types import Transaction
+from telliot_feeds.sources.mimicry.utils import sort_transactions
+from telliot_feeds.utils.log import get_logger
+
+
+logger = get_logger(__name__)
+retry_strategy = Retry(
+    total=3,
+    backoff_factor=1,
+    status_forcelist=[429, 500, 502, 503, 504],
+    allowed_methods=["GET"],
+)
+adapter = HTTPAdapter(max_retries=retry_strategy)
+
+
+@dataclass
+class TransactionList:
+
+    transactions: List[Transaction] = field(default_factory=list)
+    floor_price: float = 0.0
+
+
+@dataclass
+class MimicryCollectionStatSource(DataSource[float]):
+    """DataSource for MimicryCollectionStat expected response data."""
+
+    chainId: Optional[int] = None
+    collectionAddress: Optional[str] = None
+    metric: Optional[int] = None
+
+    def get_collection_market_cap(self, transaction_history: TransactionList) -> Optional[float]:
+        """calculate the market cap of an NFT series based on a list of Transactions."""
+
+        values: List[Union[int, float]] = []
+
+        sorted_transactions = sort_transactions(transaction_history.transactions)
+
+        sorted_transactions.reverse()
+
+        last_sale_found = []
+
+        for sale in sorted_transactions:
+
+            if sale.itemId in last_sale_found:
+                continue
+
+            # For each token in the collection
+            # calculate its value by taking the greater value
+            # between the collection's floor price and last sale price of that NFT
+            if transaction_history.floor_price < sale.price:
+                values.append(sale.price)
+            else:
+                values.append(transaction_history.floor_price)
+
+            last_sale_found.append(sale.itemId)
+
+        return sum(values)
+
+    async def request_historical_sales_data(self, contract: str, all: bool = True) -> Optional[TransactionList]:
+        """Requests historical sales
+         data of the selected collection.
+         Data retrieved from Reservoir.
+
+        Agruments:
+            all (bool): if True, see all data for the selected collection (if False, only 12 months)
+
+        Returns:
+            TransactionList: formatted historical sales data of a collection retrieved from Reservoir
+
+        """
+        continuation_token = ""
+        tx_list = TransactionList()
+        while True:
+            url = f"https://api.reservoir.tools/sales/v4?contract={contract}"
+            headers = {"accept": "*/*", "x-api-key": "demo-api-key"}
+            with requests.Session() as s:
+                s.mount("https://", adapter)
+                if not all:
+                    one_year_ago = datetime.utcnow() - relativedelta(years=1)
+                    start_timestamp = int(one_year_ago.timestamp())
+                    url += f"&startTimestamp={start_timestamp}"
+                else:
+                    url += "&startTimestamp=0"
+                # paginate
+                if continuation_token:
+                    url += "&continuation=" + continuation_token
+                # 1000 sales per page
+                url += "&limit=1000"
+
+                try:
+                    request = s.get(url, timeout=10, headers=headers)
+                    request.raise_for_status()
+                except (RequestException, Timeout) as e:
+                    logger.error(f"Request to Reservoir Sales API failed: {str(e)}")
+                    return None
+                except Exception as e:
+                    logger.error(f"Reservoir API request unsuccessful: {e}")
+                    return None
+
+                try:
+                    sales_data = request.json()["sales"]
+                    continuation_token = request.json()["continuation"]
+                except requests.exceptions.JSONDecodeError as e:
+                    logger.error(f"Unable to parse Reservoir Sales API response: {str(e)}")
+                    return None
+
+                for sale in sales_data:
+
+                    try:
+                        price = sale["price"]["amount"]["usd"]
+                        item_id = sale["token"]["tokenId"]
+                        timestamp = sale["timestamp"]
+                    except KeyError as e:
+                        logger.error("Mimicry: Reservoir Sales API KeyError: " + str(e))
+                        return None
+                    tx = Transaction(
+                        price=price, itemId=item_id, date=datetime.fromtimestamp(timestamp, tz=timezone.utc)
+                    )
+                    tx_list.transactions.append(tx)
+
+                # if on last page
+                if len(sales_data) < 1000:
+                    break
+
+        if self.metric == 1:
+            url = (
+                "https://api.reservoir.tools/oracle/collections/floor-ask/v4?kind="
+                f"spot&currency=0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48&twapSeconds=1&collection={contract}"
+            )
+            headers = {"accept": "*/*", "x-api-key": "demo-api-key"}
+
+            try:
+                request = s.get(url, timeout=10, headers=headers)
+                request.raise_for_status()
+            except (RequestException, Timeout) as e:
+                logger.error(f"Request to Reservoir FloorPrice API failed: {str(e)}")
+                return None
+
+            try:
+                tx_list.floor_price = request.json()["price"]
+            except requests.exceptions.JSONDecodeError as e:
+                logger.error(f"Unable to parse price from Reservoir FloorPrice API response: {str(e)}")
+                return None
+
+        return tx_list
+
+    async def fetch_new_datapoint(
+        self,
+    ) -> OptionalDataPoint[Any]:
+        """
+        Calculates desired metric for a collection on the chosen chain id.
+
+        Returns:
+            float -- the desired metric
+        """
+
+        if not self.collectionAddress:
+            logger.error("Missing a collection address for Mimicry NFT index calculation")
+            return None, None
+
+        if self.metric == 0:
+            past_year_sales_data = await self.request_historical_sales_data(contract=self.collectionAddress, all=True)
+            if past_year_sales_data:
+                tami_value = tami(past_year_sales_data.transactions)
+
+                if not tami_value:
+                    logger.info(
+                        f"Unable to calculate TAMI index"
+                        f"for collection {self.collectionAddress} on chain id {self.chainId}"
+                    )
+                    return None, None
+                datapoint = (tami_value, datetime_now_utc())
+                self.store_datapoint(datapoint=datapoint)
+                return datapoint
+            else:
+                logger.error(
+                    f"unable to retrieve NFT collection historical sales data for TAMI "
+                    f"for collection {self.collectionAddress} on chain id {self.chainId}"
+                )
+                return None, None
+
+        elif self.metric == 1:
+            all_sales_data = await self.request_historical_sales_data(contract=self.collectionAddress)
+            if all_sales_data:
+                market_cap = self.get_collection_market_cap(all_sales_data)
+
+                if not market_cap:
+                    logger.info(
+                        f"Unable to calculate NFT market cap"
+                        f"for collection {self.collectionAddress} on chain id {self.chainId}"
+                    )
+                    return None, None
+                datapoint = (market_cap, datetime_now_utc())
+                self.store_datapoint(datapoint=datapoint)
+                return datapoint
+            else:
+                logger.error("unable to retrieve NFT collection historical sales data for total market cap")
+                return None, None
+
+        else:
+            logger.info(msg=f"Invalid metric for Mimicry Protocol: {self.metric}")
+            return None, None
+
+
+if __name__ == "__main__":
+
+    import asyncio
+
+    source = MimicryCollectionStatSource(
+        chainId=1, collectionAddress="0x5180db8F5c931aaE63c74266b211F580155ecac8", metric=0
+    )
+    print(asyncio.run(source.fetch_new_datapoint()))
diff --git a/src/telliot_feeds/sources/mimicry/tami.py b/src/telliot_feeds/sources/mimicry/tami.py
new file mode 100644
index 00000000..a0c6933f
--- /dev/null
+++ b/src/telliot_feeds/sources/mimicry/tami.py
@@ -0,0 +1,107 @@
+from typing import Any
+from typing import Dict
+from typing import List
+from typing import Optional
+from typing import Union
+
+from telliot_feeds.sources.mimicry.types import IndexValueHistoryItem
+from telliot_feeds.sources.mimicry.types import Transaction
+from telliot_feeds.sources.mimicry.utils import filter_valid_transactions
+from telliot_feeds.sources.mimicry.utils import sort_transactions
+
+
+def create_index_value_history(transaction_history: List[Transaction]) -> List[IndexValueHistoryItem]:
+    """Given a list of transactions, this creates a list that contains the index value at the
+    time of each transaction, and includes the transaction as well.
+
+    Args:
+    - transaction_history: A list of transactions sorted by date.
+
+    Returns:
+    - A list of IndexValueHistoryItem objects (itemId, price, indexValue, Transaction)."""
+    transaction_map: Dict[Union[float, int, str], Transaction] = {}
+
+    last_index_value = 0.0
+    last_divisor = 1.0
+
+    result = []
+
+    for i in range(len(transaction_history)):
+        transaction = transaction_history[i]
+
+        is_first_sale = transaction_map.get(transaction.itemId) is None
+
+        transaction_map[transaction.itemId] = transaction
+
+        item_count = len(transaction_map)
+
+        all_last_sold_value = sum([transaction_map[item].price for item in transaction_map])
+
+        index_value = all_last_sold_value / (item_count * last_divisor)
+
+        if i == 0:
+            last_index_value = index_value
+
+            result.append(
+                IndexValueHistoryItem(
+                    itemId=transaction.itemId, price=transaction.price, indexValue=index_value, transaction=transaction
+                )
+            )
+
+            continue
+
+        next_divisor = last_divisor * (index_value / last_index_value) if is_first_sale else last_divisor
+
+        weighted_index_value = all_last_sold_value / (item_count * next_divisor)
+
+        last_index_value = weighted_index_value
+        last_divisor = next_divisor
+
+        result.append(
+            IndexValueHistoryItem(
+                itemId=transaction.itemId,
+                price=transaction.price,
+                indexValue=weighted_index_value,
+                transaction=transaction,
+            )
+        )
+
+    return result
+
+
+def get_index_value(index_value_history: List[IndexValueHistoryItem]) -> Union[float, int]:
+    """Given a list of IndexValueHistoryItem, returns the index value of the last item."""
+    return index_value_history[-1].indexValue if index_value_history else 0
+
+
+def get_index_ratios(index_valueHistory: List[IndexValueHistoryItem]) -> List[Dict[str, Any]]:
+    """Given a list of IndexValueHistoryItem, calculates the index ratio for the last transaction
+    of each item in the collection. Returns a list of dict objects where each object is items from
+    IndexValueHistoryItem with an additional `indexRatio` item added."""
+    last_sale_map: Dict[Union[float, int, str], IndexValueHistoryItem] = {}
+
+    for history_item in index_valueHistory:
+        last_sale_map[history_item.itemId] = history_item
+
+    return [{**item.__dict__, "indexRatio": item.price / item.indexValue} for item in last_sale_map.values()]
+
+
+def tami(transaction_history: List[Transaction]) -> Optional[float]:
+    """Given a list of transactions for a given collection, this calculates the
+    Time Adjusted Market Index for that collection.
+
+    Returns:
+    - Optional[float]
+    """
+    sorted_transactions = sort_transactions(transaction_history)
+    valid_transactions = filter_valid_transactions(sorted_transactions)
+    index_value_history = create_index_value_history(valid_transactions)
+
+    if len(index_value_history) == 0:
+        return None
+
+    index_value = get_index_value(index_value_history)
+    index_ratios = get_index_ratios(index_value_history)
+    time_adjusted_values = [index_value * item["indexRatio"] for item in index_ratios]
+    time_adjusted_market_index: float = sum(time_adjusted_values)
+    return time_adjusted_market_index
diff --git a/src/telliot_feeds/sources/mimicry/types.py b/src/telliot_feeds/sources/mimicry/types.py
new file mode 100644
index 00000000..7bc6ffd8
--- /dev/null
+++ b/src/telliot_feeds/sources/mimicry/types.py
@@ -0,0 +1,32 @@
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Union
+
+
+@dataclass
+class Transaction:
+    itemId: Union[float, int]
+    price: Union[float, int]
+    date: datetime
+
+
+@dataclass
+class InclusionMapValue:
+    past_year_sale_count: int
+    has_sale_in_last_six_months: bool
+    is_valid: bool
+
+
+@dataclass
+class IndexRatiosByCollection:
+    price: Union[float, int]
+    indexPrice: Union[float, int]
+    indexRatio: Union[float, int]
+
+
+@dataclass
+class IndexValueHistoryItem:
+    itemId: Union[float, int, str]
+    price: Union[float, int]
+    indexValue: Union[float, int]
+    transaction: Transaction
diff --git a/src/telliot_feeds/sources/mimicry/utils.py b/src/telliot_feeds/sources/mimicry/utils.py
new file mode 100644
index 00000000..1cdaf8c2
--- /dev/null
+++ b/src/telliot_feeds/sources/mimicry/utils.py
@@ -0,0 +1,53 @@
+import operator
+from datetime import datetime
+from datetime import timezone
+from typing import Dict
+from typing import List
+from typing import Union
+
+from dateutil.relativedelta import relativedelta
+
+from telliot_feeds.sources.mimicry.types import InclusionMapValue
+from telliot_feeds.sources.mimicry.types import Transaction
+
+
+def sort_transactions(transaction_history: List[Transaction]) -> List[Transaction]:
+    """Given a list of transactions, this returns those transactions sorted in chronological order."""
+    return sorted(transaction_history, key=operator.attrgetter("date"))
+
+
+def filter_valid_transactions(transaction_history: List[Transaction]) -> List[Transaction]:
+    """Given a list of transactions, this returns only transactions that have at least
+    2 sales in the last year, and at least one sale in the last 6 months."""
+    now = datetime.utcnow()
+    one_year_ago = (now - relativedelta(years=1)).replace(tzinfo=timezone.utc)
+    six_months_ago = (now - relativedelta(months=6)).replace(tzinfo=timezone.utc)
+
+    inclusion_map: Dict[Union[float, int], InclusionMapValue] = {}
+    for transaction in transaction_history:
+
+        item_id = transaction.itemId
+        date = transaction.date
+
+        if item_id not in inclusion_map:
+            inclusion_map[item_id] = InclusionMapValue(0, False, False)
+
+        current_map_item = inclusion_map[item_id]
+
+        if current_map_item.is_valid:
+            continue
+
+        if date < one_year_ago:
+            continue
+
+        current_map_item.past_year_sale_count += 1
+
+        if date < six_months_ago:
+            continue
+
+        current_map_item.has_sale_in_last_six_months = True
+
+        if current_map_item.past_year_sale_count >= 2:
+            current_map_item.is_valid = True
+
+    return [transaction for transaction in transaction_history if inclusion_map[transaction.itemId].is_valid]


Test patch:
diff --git a/tests/feeds/test_mimicry_feed.py b/tests/feeds/test_mimicry_feed.py
index d1f3ef11..b45ca5d3 100644
--- a/tests/feeds/test_mimicry_feed.py
+++ b/tests/feeds/test_mimicry_feed.py
@@ -1,27 +1,25 @@
+from typing import get_type_hints
+
 import pytest
 
 from telliot_feeds.feeds.mimicry_feed import mimicry_collection_stat_datafeed
+from telliot_feeds.queries.mimicry import MimicryCollectionStat
+from telliot_feeds.sources.mimicry.collection_stat import MimicryCollectionStatSource
 
 
-@pytest.mark.skip("TODO: see issue 603")
 @pytest.mark.asyncio
-async def test_fetch_new_datapoint():
-    """Retrieve TAMI index and NFT market cap from source"""
-
-    crypto_coven_address = "0x5180db8F5c931aaE63c74266b211F580155ecac8"
-
-    mimicry_collection_stat_datafeed.source.chainId = 1
-    mimicry_collection_stat_datafeed.source.collectionAddress = crypto_coven_address
-    mimicry_collection_stat_datafeed.source.metric = 0
-
-    tami, _ = await mimicry_collection_stat_datafeed.source.fetch_new_datapoint()
-    print(tami)
-
-    assert tami > 0
-
-    mimicry_collection_stat_datafeed.source.metric = 1
-
-    market_cap, _ = await mimicry_collection_stat_datafeed.source.fetch_new_datapoint()
-    print(market_cap)
-
-    assert market_cap > tami
+async def test_feed():
+    """test mimicry_collection_stat_datafeed"""
+    # source test is in tests/sources/test_mimicry_source.py
+    assert mimicry_collection_stat_datafeed.query.chainId is None
+    assert mimicry_collection_stat_datafeed.query.collectionAddress is None
+    assert mimicry_collection_stat_datafeed.query.metric is None
+    assert mimicry_collection_stat_datafeed.source.chainId is None
+    assert mimicry_collection_stat_datafeed.source.collectionAddress is None
+    assert mimicry_collection_stat_datafeed.source.metric is None
+    assert isinstance(mimicry_collection_stat_datafeed.source, MimicryCollectionStatSource)
+    assert isinstance(mimicry_collection_stat_datafeed.query, MimicryCollectionStat)
+    query_attributes = get_type_hints(mimicry_collection_stat_datafeed.query)
+    source_attributes = get_type_hints(mimicry_collection_stat_datafeed.source)
+    assert ["chainId", "collectionAddress", "metric"] == list(query_attributes.keys())
+    assert ["chainId", "collectionAddress", "metric"] == list(source_attributes.keys())
diff --git a/tests/sources/test_mimicry_source.py b/tests/sources/test_mimicry_source.py
index a1ea0a83..97c1d6d0 100644
--- a/tests/sources/test_mimicry_source.py
+++ b/tests/sources/test_mimicry_source.py
@@ -1,9 +1,8 @@
 import pytest
 
-from telliot_feeds.sources.mimicry import MimicryCollectionStatSource
+from telliot_feeds.sources.mimicry.collection_stat import MimicryCollectionStatSource
 
 
-@pytest.mark.skip("See issue 603")
 @pytest.mark.asyncio
 async def test_fetching_data():
     """Test fetching data from Mimicry API"""
@@ -12,6 +11,7 @@ async def test_fetching_data():
         chainId=1, collectionAddress="0x5180db8F5c931aaE63c74266b211F580155ecac8", metric=0
     )
     val, _ = await source.fetch_new_datapoint()
+    print(val)
     assert isinstance(val, float)
 
     # market cap
diff --git a/tests/sources/test_tami_calcuation.py b/tests/sources/test_tami_calcuation.py
new file mode 100644
index 00000000..fc15f901
--- /dev/null
+++ b/tests/sources/test_tami_calcuation.py
@@ -0,0 +1,126 @@
+from datetime import datetime
+
+from dateutil.relativedelta import relativedelta
+
+from telliot_feeds.sources.mimicry.tami import create_index_value_history
+from telliot_feeds.sources.mimicry.tami import get_index_ratios
+from telliot_feeds.sources.mimicry.tami import get_index_value
+from telliot_feeds.sources.mimicry.tami import tami
+from telliot_feeds.sources.mimicry.types import IndexValueHistoryItem
+from telliot_feeds.sources.mimicry.types import Transaction
+from telliot_feeds.sources.mimicry.utils import filter_valid_transactions
+from telliot_feeds.sources.mimicry.utils import sort_transactions
+
+now = datetime.utcnow()
+yesterday = now - relativedelta(days=1)
+two_days_ago = now - relativedelta(days=2)
+three_days_ago = now - relativedelta(days=3)
+one_month_ago = now - relativedelta(months=1)
+six_weeks_ago = now - relativedelta(weeks=6)
+two_years_ago = now - relativedelta(years=2)
+
+
+mock_transaction_history = [
+    Transaction(itemId="Lavender", price=500, date=three_days_ago),
+    Transaction(itemId="Hyacinth", price=700, date=one_month_ago),
+    Transaction(itemId="Mars", price=1200, date=two_days_ago),
+    # Nyx should be excluded since she did not have two transactions in the past year
+    Transaction(itemId="Nyx", price=612, date=two_years_ago),
+    Transaction(itemId="Hyacinth", price=400, date=three_days_ago),
+    Transaction(itemId="Nyx", price=1200, date=yesterday),
+    Transaction(itemId="Mars", price=612, date=six_weeks_ago),
+]
+
+expected_values = {
+    "sortedTransactions": [
+        {"itemId": "Nyx", "price": 612, "timestamp": two_years_ago},
+        {"itemId": "Mars", "price": 612, "timestamp": six_weeks_ago},
+        {"itemId": "Hyacinth", "price": 700, "timestamp": one_month_ago},
+        {"itemId": "Lavender", "price": 500, "timestamp": three_days_ago},
+        {"itemId": "Hyacinth", "price": 400, "timestamp": three_days_ago},
+        {"itemId": "Mars", "price": 1200, "timestamp": two_days_ago},
+        {"itemId": "Nyx", "price": 1200, "timestamp": yesterday},
+    ],
+    "validTransactions": [
+        {"itemId": "Mars", "price": 612, "timestamp": six_weeks_ago},
+        {"itemId": "Hyacinth", "price": 700, "timestamp": one_month_ago},
+        {"itemId": "Hyacinth", "price": 400, "timestamp": three_days_ago},
+        {"itemId": "Mars", "price": 1200, "timestamp": two_days_ago},
+    ],
+    "indexValueHistory": [
+        IndexValueHistoryItem(
+            itemId="Mars",
+            price=612,
+            indexValue=612.0,
+            transaction=Transaction(itemId="Mars", price=612, date=six_weeks_ago),
+        ),
+        IndexValueHistoryItem(
+            itemId="Hyacinth",
+            price=700,
+            indexValue=612.0,
+            transaction=Transaction(itemId="Hyacinth", price=700, date=one_month_ago),
+        ),
+        IndexValueHistoryItem(
+            itemId="Hyacinth",
+            price=400,
+            indexValue=472.0609756097561,
+            transaction=Transaction(itemId="Hyacinth", price=400, date=three_days_ago),
+        ),
+        IndexValueHistoryItem(
+            itemId="Mars",
+            price=1200,
+            indexValue=746.3414634146342,
+            transaction=Transaction(itemId="Mars", price=1200, date=two_days_ago),
+        ),
+    ],
+    "indexValue": 746.3414634146342,
+    "indexRatios": [
+        {
+            "itemId": "Mars",
+            "price": 1200,
+            "indexValue": 746.3414634146342,
+            "transaction": Transaction(itemId="Mars", price=1200, date=two_days_ago),
+            "indexRatio": 1.6078431372549018,
+        },
+        {
+            "itemId": "Hyacinth",
+            "price": 400,
+            "indexValue": 472.0609756097561,
+            "transaction": Transaction(itemId="Hyacinth", price=400, date=three_days_ago),
+            "indexRatio": 0.847348161926167,
+        },
+    ],
+    "timeAdjustedValues": [1200, 632.4110671936759],
+    "timeAdjustedMarketIndex": 1832.411067193676,
+}
+
+sorted_transactions = sort_transactions(mock_transaction_history)
+valid_transactions = filter_valid_transactions(sorted_transactions)
+
+
+def test_create_index_value_history():
+    index_value_history = create_index_value_history(valid_transactions)
+    assert index_value_history == expected_values["indexValueHistory"]
+
+
+def test_get_index_ratios():
+    index_value_history = create_index_value_history(valid_transactions)
+    index_ratios = get_index_ratios(index_value_history)
+    print(index_ratios)
+    assert index_ratios == expected_values["indexRatios"]
+
+
+def test_get_index_value():
+    index_value_history = create_index_value_history(valid_transactions)
+    index_value = get_index_value(index_value_history)
+    assert index_value == expected_values["indexValue"]
+
+
+def test_tami_with_transaction_data():
+    value = tami(valid_transactions)
+    assert value == expected_values["timeAdjustedMarketIndex"]
+
+
+def test_tami_empty_transaction_data():
+    value = tami([])
+    assert value is None