From 6331ba75190b06101d06681a022e0db6d1859f04 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Thu, 20 Feb 2025 10:39:55 -0800 Subject: [PATCH 01/24] portfolio position for EVM --- alphaswarm/services/alchemy/alchemy_client.py | 11 ++++ alphaswarm/services/portfolio/portfolio.py | 50 ++++++++++++++++++- .../services/portfolio/test_portfolio.py | 31 +++++++++++- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/alphaswarm/services/alchemy/alchemy_client.py b/alphaswarm/services/alchemy/alchemy_client.py index 46078ca6..f1b81ed8 100644 --- a/alphaswarm/services/alchemy/alchemy_client.py +++ b/alphaswarm/services/alchemy/alchemy_client.py @@ -38,6 +38,16 @@ class HistoricalPriceByAddress: class Metadata: block_timestamp: Annotated[str, Field(alias="blockTimestamp")] +@dataclass +class RawContract: + address: str + value: int + decimal: int + + @field_validator("value", "decimal", mode="before") + def convert_hex_to_int(cls, value: str) -> int: + return int(value, 16) + @dataclass class Transfer: @@ -63,6 +73,7 @@ class Transfer: from_address: Annotated[str, Field(validation_alias="from")] to_address: Annotated[str, Field(validation_alias="to")] value: Annotated[Decimal, Field(default=Decimal(0))] + raw_contract: Annotated[RawContract, Field(alias="rawContract")] metadata: Metadata asset: str = "UNKNOWN" category: str = "UNKNOWN" diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index 70c3c3ab..33e51ea6 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -1,16 +1,31 @@ from __future__ import annotations +import logging from abc import abstractmethod +from dataclasses import dataclass from typing import Iterable, List, Optional, Self from solders.pubkey import Pubkey +from ..alchemy.alchemy_client import Transfer from ...config import Config, WalletInfo -from ...core.token import TokenAmount +from ...core.token import TokenAmount, TokenInfo from ..alchemy import AlchemyClient from ..chains import EVMClient, SolanaClient +logger = logging.getLogger(__name__) + +@dataclass +class PortfolioPosition: + base: TokenAmount + asset: TokenAmount + hash: str + block_number: int + + def to_short_string(self) -> str: + return f"{self.base.value} {self.base.token_info.symbol} -> {self.asset.value} {self.asset.token_info.symbol} ({self.base.token_info.chain} {self.block_number} {self.hash})" + class PortfolioBase: def __init__(self, wallet: WalletInfo) -> None: self._wallet = wallet @@ -23,7 +38,6 @@ def get_token_balances(self) -> List[TokenAmount]: def chain(self) -> str: return self._wallet.chain - class Portfolio: def __init__(self, portfolios: Iterable[PortfolioBase]) -> None: self._portfolios = list(portfolios) @@ -63,6 +77,38 @@ def get_token_balances(self) -> List[TokenAmount]: result.append(TokenAmount(value=token_info.convert_from_wei(balance.value), token_info=token_info)) return result + def get_positions(self) -> List[PortfolioPosition]: + transfer_in = self._alchemy_client.get_transfers(wallet=self._wallet.address, chain=self._wallet.chain, incoming=True) + transfer_out = self._alchemy_client.get_transfers(wallet=self._wallet.address, chain=self._wallet.chain, incoming=False) + map_out = {item.tx_hash: item for item in transfer_out} + + result = [] + for transfer in transfer_in: + matched_out = map_out.get(transfer.tx_hash) + if matched_out is None: + logger.debug(f"Transfer {transfer.tx_hash} has no matching output") + continue + result.append(PortfolioPosition( + asset=self.transfer_to_token_amount(transfer), + base=self.transfer_to_token_amount(matched_out), + hash=transfer.tx_hash, + block_number=transfer.block_number, + )) + + return result + + def transfer_to_token_amount(self, transfer: Transfer) -> TokenAmount: + token_info = TokenInfo( + symbol=transfer.asset, + address=EVMClient.to_checksum_address(transfer.raw_contract.address), + decimals=transfer.raw_contract.decimal, + chain=self._wallet.chain, + ) + + value = transfer.value + return TokenAmount(value=value, token_info=token_info) + + class PortfolioSolana(PortfolioBase): def __init__(self, wallet: WalletInfo, solana_client: SolanaClient) -> None: diff --git a/tests/integration/services/portfolio/test_portfolio.py b/tests/integration/services/portfolio/test_portfolio.py index f211f8db..acccff86 100644 --- a/tests/integration/services/portfolio/test_portfolio.py +++ b/tests/integration/services/portfolio/test_portfolio.py @@ -1,12 +1,41 @@ +from typing import List + import pytest +from pydantic import TypeAdapter -from alphaswarm.config import Config +from alphaswarm.config import ChainConfig, Config, WalletInfo from alphaswarm.services.alchemy import AlchemyClient +from alphaswarm.services.chains import EVMClient from alphaswarm.services.portfolio import Portfolio +from alphaswarm.services.portfolio.portfolio import PortfolioEvm, PortfolioPosition + + +@pytest.fixture +def eth_sepolia_config(default_config: Config) -> ChainConfig: + return default_config.get_chain_config("ethereum_sepolia") +@pytest.fixture +def eth_sepolia_portfolio(eth_sepolia_config: ChainConfig, alchemy_client: AlchemyClient) -> PortfolioEvm: + return PortfolioEvm(WalletInfo.from_chain_config(eth_sepolia_config), EVMClient(eth_sepolia_config), alchemy_client) + +@pytest.fixture +def evm_portfolio(chain: str, default_config: Config, alchemy_client: AlchemyClient) -> PortfolioEvm: + chain_config = default_config.get_chain_config(chain) + return PortfolioEvm(WalletInfo.from_chain_config(chain_config), EVMClient(chain_config), alchemy_client) @pytest.mark.skip("Need wallet") def test_portfolio_get_balances(default_config: Config, alchemy_client: AlchemyClient) -> None: portfolio = Portfolio.from_config(default_config) result = portfolio.get_token_balances() assert len(result) > 3 + +chains = [ + "ethereum", + "ethereum_sepolia", + "base" +] +@pytest.mark.parametrize("chain", chains) +def test_portfolio_get_positions(chain: str, evm_portfolio: PortfolioEvm) -> None: + result = evm_portfolio.get_positions() + for item in result: + print(item.to_short_string()) \ No newline at end of file From 5d308e1532c70567e179c8e6f780ac06a28d777c Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Thu, 20 Feb 2025 10:40:25 -0800 Subject: [PATCH 02/24] Update test_portfolio.py --- tests/integration/services/portfolio/test_portfolio.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/services/portfolio/test_portfolio.py b/tests/integration/services/portfolio/test_portfolio.py index acccff86..cf80554c 100644 --- a/tests/integration/services/portfolio/test_portfolio.py +++ b/tests/integration/services/portfolio/test_portfolio.py @@ -35,6 +35,7 @@ def test_portfolio_get_balances(default_config: Config, alchemy_client: AlchemyC "base" ] @pytest.mark.parametrize("chain", chains) +@pytest.mark.skip("Need wallet") def test_portfolio_get_positions(chain: str, evm_portfolio: PortfolioEvm) -> None: result = evm_portfolio.get_positions() for item in result: From 1739225baf357b5f0c2c0e85e9d5c51a2bc9b15e Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Thu, 20 Feb 2025 16:02:38 -0800 Subject: [PATCH 03/24] implement of Fifo PNL --- alphaswarm/services/portfolio/portfolio.py | 114 +++++++++++++++--- .../services/portfolio/test_portfolio.py | 19 ++- tests/unit/services/portfolio/__init__.py | 0 .../unit/services/portfolio/test_portfolio.py | 50 ++++++++ 4 files changed, 156 insertions(+), 27 deletions(-) create mode 100644 tests/unit/services/portfolio/__init__.py create mode 100644 tests/unit/services/portfolio/test_portfolio.py diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index 33e51ea6..fbd62da6 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -2,29 +2,63 @@ import logging from abc import abstractmethod +from collections import defaultdict, deque from dataclasses import dataclass -from typing import Iterable, List, Optional, Self +from decimal import Decimal +from typing import Dict, Iterable, List, Optional, Self, Sequence from solders.pubkey import Pubkey -from ..alchemy.alchemy_client import Transfer from ...config import Config, WalletInfo from ...core.token import TokenAmount, TokenInfo from ..alchemy import AlchemyClient +from ..alchemy.alchemy_client import Transfer from ..chains import EVMClient, SolanaClient - logger = logging.getLogger(__name__) + +class PortfolioPNL: + def __init__(self) -> None: + self._details_per_asset: Dict[str, List[PortfolioPNLDetail]] = {} + + def add_details(self, asset: str, details: Iterable[PortfolioPNLDetail]) -> None: + self._details_per_asset[asset] = list(details) + + def pnl_per_asset(self) -> Dict[str, Decimal]: + result = {} + for asset, details in self._details_per_asset.items(): + result[asset] = sum([item.pnl for item in details], Decimal(0)) + return result + + def pnl(self) -> Decimal: + return sum([pnl for asset, pnl in self.pnl_per_asset().items()], Decimal(0)) + + +class PortfolioPNLDetail: + def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, asset_sold: Decimal) -> None: + self._bought = bought + self._sold = sold + self._asset_sold = asset_sold + self._pnl = ( + sold.bought.value * asset_sold / sold.sold.value - bought.sold.value * asset_sold / bought.bought.value + ) + + @property + def pnl(self) -> Decimal: + return self._pnl + + @dataclass -class PortfolioPosition: - base: TokenAmount - asset: TokenAmount +class PortfolioSwap: + sold: TokenAmount + bought: TokenAmount hash: str block_number: int def to_short_string(self) -> str: - return f"{self.base.value} {self.base.token_info.symbol} -> {self.asset.value} {self.asset.token_info.symbol} ({self.base.token_info.chain} {self.block_number} {self.hash})" + return f"{self.sold.value} {self.sold.token_info.symbol} -> {self.bought.value} {self.bought.token_info.symbol} ({self.sold.token_info.chain} {self.block_number} {self.hash})" + class PortfolioBase: def __init__(self, wallet: WalletInfo) -> None: @@ -38,6 +72,47 @@ def get_token_balances(self) -> List[TokenAmount]: def chain(self) -> str: return self._wallet.chain + @classmethod + def compute_pnl_fifo(cls, positions: Sequence[PortfolioSwap], base_token: TokenInfo) -> PortfolioPNL: + items = sorted(positions, key=lambda x: x.block_number) + purchases: Dict[str, deque[PortfolioSwap]] = defaultdict(deque) + sells: Dict[str, deque[PortfolioSwap]] = defaultdict(deque) + for position in items: + if position.sold.token_info.address == base_token.address: + purchases[position.bought.token_info.address].append(position) + if position.bought.token_info.address == base_token.address: + sells[position.sold.token_info.address].append(position) + + result = PortfolioPNL() + for asset, swaps in sells.items(): + result.add_details(asset, cls.compute_pnl_fifo_for_pair(purchases[asset], swaps)) + + return result + + @classmethod + def compute_pnl_fifo_for_pair( + cls, purchases: deque[PortfolioSwap], sells: deque[PortfolioSwap] + ) -> List[PortfolioPNLDetail]: + purchases_it = iter(purchases) + bought_position: Optional[PortfolioSwap] = None + buy_remaining = Decimal(0) + result: List[PortfolioPNLDetail] = [] + for sell in sells: + sell_remaining = sell.sold.value + while sell_remaining > 0: + if bought_position is None or buy_remaining <= 0: + bought_position = next(purchases_it, None) + if bought_position is None: + return result + buy_remaining = bought_position.bought.value + sold_quantity = min(sell_remaining, buy_remaining) + result.append(PortfolioPNLDetail(bought_position, sell, sold_quantity)) + sell_remaining -= sold_quantity + buy_remaining -= sold_quantity + + return result + + class Portfolio: def __init__(self, portfolios: Iterable[PortfolioBase]) -> None: self._portfolios = list(portfolios) @@ -77,9 +152,13 @@ def get_token_balances(self) -> List[TokenAmount]: result.append(TokenAmount(value=token_info.convert_from_wei(balance.value), token_info=token_info)) return result - def get_positions(self) -> List[PortfolioPosition]: - transfer_in = self._alchemy_client.get_transfers(wallet=self._wallet.address, chain=self._wallet.chain, incoming=True) - transfer_out = self._alchemy_client.get_transfers(wallet=self._wallet.address, chain=self._wallet.chain, incoming=False) + def get_positions(self) -> List[PortfolioSwap]: + transfer_in = self._alchemy_client.get_transfers( + wallet=self._wallet.address, chain=self._wallet.chain, incoming=True + ) + transfer_out = self._alchemy_client.get_transfers( + wallet=self._wallet.address, chain=self._wallet.chain, incoming=False + ) map_out = {item.tx_hash: item for item in transfer_out} result = [] @@ -88,12 +167,14 @@ def get_positions(self) -> List[PortfolioPosition]: if matched_out is None: logger.debug(f"Transfer {transfer.tx_hash} has no matching output") continue - result.append(PortfolioPosition( - asset=self.transfer_to_token_amount(transfer), - base=self.transfer_to_token_amount(matched_out), - hash=transfer.tx_hash, - block_number=transfer.block_number, - )) + result.append( + PortfolioSwap( + bought=self.transfer_to_token_amount(transfer), + sold=self.transfer_to_token_amount(matched_out), + hash=transfer.tx_hash, + block_number=transfer.block_number, + ) + ) return result @@ -109,7 +190,6 @@ def transfer_to_token_amount(self, transfer: Transfer) -> TokenAmount: return TokenAmount(value=value, token_info=token_info) - class PortfolioSolana(PortfolioBase): def __init__(self, wallet: WalletInfo, solana_client: SolanaClient) -> None: super().__init__(wallet) diff --git a/tests/integration/services/portfolio/test_portfolio.py b/tests/integration/services/portfolio/test_portfolio.py index cf80554c..0f641be7 100644 --- a/tests/integration/services/portfolio/test_portfolio.py +++ b/tests/integration/services/portfolio/test_portfolio.py @@ -1,42 +1,41 @@ -from typing import List - import pytest -from pydantic import TypeAdapter from alphaswarm.config import ChainConfig, Config, WalletInfo from alphaswarm.services.alchemy import AlchemyClient from alphaswarm.services.chains import EVMClient from alphaswarm.services.portfolio import Portfolio -from alphaswarm.services.portfolio.portfolio import PortfolioEvm, PortfolioPosition +from alphaswarm.services.portfolio.portfolio import PortfolioEvm @pytest.fixture def eth_sepolia_config(default_config: Config) -> ChainConfig: return default_config.get_chain_config("ethereum_sepolia") + @pytest.fixture def eth_sepolia_portfolio(eth_sepolia_config: ChainConfig, alchemy_client: AlchemyClient) -> PortfolioEvm: return PortfolioEvm(WalletInfo.from_chain_config(eth_sepolia_config), EVMClient(eth_sepolia_config), alchemy_client) + @pytest.fixture def evm_portfolio(chain: str, default_config: Config, alchemy_client: AlchemyClient) -> PortfolioEvm: chain_config = default_config.get_chain_config(chain) return PortfolioEvm(WalletInfo.from_chain_config(chain_config), EVMClient(chain_config), alchemy_client) + @pytest.mark.skip("Need wallet") def test_portfolio_get_balances(default_config: Config, alchemy_client: AlchemyClient) -> None: portfolio = Portfolio.from_config(default_config) result = portfolio.get_token_balances() assert len(result) > 3 -chains = [ - "ethereum", - "ethereum_sepolia", - "base" -] + +chains = ["ethereum", "ethereum_sepolia", "base"] + + @pytest.mark.parametrize("chain", chains) @pytest.mark.skip("Need wallet") def test_portfolio_get_positions(chain: str, evm_portfolio: PortfolioEvm) -> None: result = evm_portfolio.get_positions() for item in result: - print(item.to_short_string()) \ No newline at end of file + print(item.to_short_string()) diff --git a/tests/unit/services/portfolio/__init__.py b/tests/unit/services/portfolio/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/services/portfolio/test_portfolio.py b/tests/unit/services/portfolio/test_portfolio.py new file mode 100644 index 00000000..8db2ad8b --- /dev/null +++ b/tests/unit/services/portfolio/test_portfolio.py @@ -0,0 +1,50 @@ +from decimal import Decimal +from typing import List, Tuple, Union + +import pytest + +from alphaswarm.core.token import TokenAmount, TokenInfo +from alphaswarm.services.portfolio.portfolio import PortfolioBase, PortfolioSwap + + +def make_swaps( + swaps: List[Tuple[Union[int, str, Decimal], TokenInfo, Union[int, str, Decimal], TokenInfo]] +) -> List[PortfolioSwap]: + result = [] + block_number = 0 + for amount_sold, asset_sold, amount_bought, asset_bought in swaps: + result.append( + PortfolioSwap( + sold=TokenAmount(value=Decimal(amount_sold), token_info=asset_sold), + bought=TokenAmount(value=Decimal(amount_bought), token_info=asset_bought), + block_number=block_number, + hash=str(block_number), + ) + ) + block_number += 1 + return result + + +@pytest.fixture +def usdc() -> TokenInfo: + return TokenInfo(symbol="USDC", address="0xUSDC", decimals=6, chain="chain") + + +@pytest.fixture +def weth() -> TokenInfo: + return TokenInfo(symbol="WETH", address="0xWETH", decimals=18, chain="chain") + + +def test_portfolio_compute_pnl_fifo_one_asset(weth: TokenInfo, usdc: TokenInfo) -> None: + positions = make_swaps( + [ + (1, weth, 10, usdc), + (5, usdc, "0.6", weth), + (1, weth, 8, usdc), + (7, usdc, ".65", weth), + (6, usdc, "0.75", weth), + ] + ) + + pnl = PortfolioBase.compute_pnl_fifo(positions, weth) + assert pnl.pnl() == Decimal(0) From b68f928c0afe1a624db0ab454b42800613e86f61 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Thu, 20 Feb 2025 16:37:40 -0800 Subject: [PATCH 04/24] clean up --- alphaswarm/services/alchemy/alchemy_client.py | 1 + tests/unit/services/portfolio/test_portfolio.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/alphaswarm/services/alchemy/alchemy_client.py b/alphaswarm/services/alchemy/alchemy_client.py index f1b81ed8..23520c95 100644 --- a/alphaswarm/services/alchemy/alchemy_client.py +++ b/alphaswarm/services/alchemy/alchemy_client.py @@ -38,6 +38,7 @@ class HistoricalPriceByAddress: class Metadata: block_timestamp: Annotated[str, Field(alias="blockTimestamp")] + @dataclass class RawContract: address: str diff --git a/tests/unit/services/portfolio/test_portfolio.py b/tests/unit/services/portfolio/test_portfolio.py index 8db2ad8b..9fdb309c 100644 --- a/tests/unit/services/portfolio/test_portfolio.py +++ b/tests/unit/services/portfolio/test_portfolio.py @@ -7,7 +7,7 @@ from alphaswarm.services.portfolio.portfolio import PortfolioBase, PortfolioSwap -def make_swaps( +def create_swaps( swaps: List[Tuple[Union[int, str, Decimal], TokenInfo, Union[int, str, Decimal], TokenInfo]] ) -> List[PortfolioSwap]: result = [] @@ -36,7 +36,7 @@ def weth() -> TokenInfo: def test_portfolio_compute_pnl_fifo_one_asset(weth: TokenInfo, usdc: TokenInfo) -> None: - positions = make_swaps( + positions = create_swaps( [ (1, weth, 10, usdc), (5, usdc, "0.6", weth), From 66fa213fffa9a94b3bd7c3c1290d58afeea1213b Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Thu, 20 Feb 2025 17:59:14 -0800 Subject: [PATCH 05/24] unit test --- alphaswarm/services/portfolio/portfolio.py | 18 +++++-- .../unit/services/portfolio/test_portfolio.py | 48 ++++++++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index fbd62da6..877d5b49 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -40,14 +40,26 @@ def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, asset_sold: Decim self._bought = bought self._sold = sold self._asset_sold = asset_sold - self._pnl = ( - sold.bought.value * asset_sold / sold.sold.value - bought.sold.value * asset_sold / bought.bought.value - ) + self._pnl = asset_sold * (self.selling_price - self.buying_price) @property def pnl(self) -> Decimal: return self._pnl + @property + def sold_amount(self) -> Decimal: + return self._asset_sold + + @property + def buying_price(self) -> Decimal: + """Buying price per assert""" + return self._bought.sold.value / self._bought.bought.value + + @property + def selling_price(self) -> Decimal: + """Selling price per assert""" + return self._sold.bought.value / self._sold.sold.value + @dataclass class PortfolioSwap: diff --git a/tests/unit/services/portfolio/test_portfolio.py b/tests/unit/services/portfolio/test_portfolio.py index 9fdb309c..e30ba4c0 100644 --- a/tests/unit/services/portfolio/test_portfolio.py +++ b/tests/unit/services/portfolio/test_portfolio.py @@ -4,7 +4,7 @@ import pytest from alphaswarm.core.token import TokenAmount, TokenInfo -from alphaswarm.services.portfolio.portfolio import PortfolioBase, PortfolioSwap +from alphaswarm.services.portfolio.portfolio import PortfolioBase, PortfolioPNLDetail, PortfolioSwap def create_swaps( @@ -35,16 +35,52 @@ def weth() -> TokenInfo: return TokenInfo(symbol="WETH", address="0xWETH", decimals=18, chain="chain") -def test_portfolio_compute_pnl_fifo_one_asset(weth: TokenInfo, usdc: TokenInfo) -> None: +def assert_pnl_detail( + item: PortfolioPNLDetail, + *, + sold_amount: Union[int, str], + buying_price: Union[int, str], + selling_price: Union[int, str], + pnl: Union[int, str], +) -> None: + assert item.sold_amount == Decimal(sold_amount) + assert item.buying_price == Decimal(buying_price) + assert item.selling_price == Decimal(selling_price) + assert item.pnl == Decimal(pnl) + + +def test_portfolio_compute_pnl_fifo_one_asset__sell_from_first_swap(weth: TokenInfo, usdc: TokenInfo) -> None: positions = create_swaps( [ (1, weth, 10, usdc), - (5, usdc, "0.6", weth), + (5, usdc, 2, weth), (1, weth, 8, usdc), - (7, usdc, ".65", weth), - (6, usdc, "0.75", weth), + (2, usdc, 2, weth), + ] + ) + + pnl = PortfolioBase.compute_pnl_fifo(positions, weth) + usdc_pnl = pnl._details_per_asset[usdc.address] + assert_pnl_detail(usdc_pnl[0], sold_amount=5, buying_price="0.1", selling_price="0.4", pnl="1.5") + assert_pnl_detail(usdc_pnl[1], sold_amount=2, buying_price="0.1", selling_price="1", pnl="1.8") + assert pnl.pnl() == Decimal("3.3") + + +def test_portfolio_compute_pnl_fifo_one_asset__sell_from_multiple_swaps(weth: TokenInfo, usdc: TokenInfo) -> None: + positions = create_swaps( + [ + (1, weth, 10, usdc), + (1, weth, 5, usdc), + (5, usdc, ".75", weth), + (7, usdc, "7", weth), + (3, usdc, "0.03", weth), ] ) pnl = PortfolioBase.compute_pnl_fifo(positions, weth) - assert pnl.pnl() == Decimal(0) + usdc_pnl = pnl._details_per_asset[usdc.address] + assert_pnl_detail(usdc_pnl[0], sold_amount=5, buying_price="0.1", selling_price=".15", pnl=".25") + assert_pnl_detail(usdc_pnl[1], sold_amount=5, buying_price="0.1", selling_price="1", pnl="4.5") + assert_pnl_detail(usdc_pnl[2], sold_amount=2, buying_price="0.2", selling_price="1", pnl="1.6") + assert_pnl_detail(usdc_pnl[3], sold_amount=3, buying_price="0.2", selling_price="0.01", pnl="-0.57") + assert pnl.pnl() == Decimal("5.78") From 2c8d936a49cc36d5f8350f0e2d586f036c444d17 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Thu, 20 Feb 2025 18:01:52 -0800 Subject: [PATCH 06/24] Update alchemy_client.py --- alphaswarm/services/alchemy/alchemy_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/alphaswarm/services/alchemy/alchemy_client.py b/alphaswarm/services/alchemy/alchemy_client.py index 92c4e22d..8116c5a2 100644 --- a/alphaswarm/services/alchemy/alchemy_client.py +++ b/alphaswarm/services/alchemy/alchemy_client.py @@ -3,6 +3,7 @@ import logging import os import time +from dataclasses import dataclass from datetime import datetime, timezone from decimal import Decimal from typing import Annotated, Dict, Final, List, Optional From b0114e2f8b8a501db4078aae547a940cbbebab88 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Thu, 20 Feb 2025 18:17:20 -0800 Subject: [PATCH 07/24] handle error case and add tests --- alphaswarm/services/portfolio/portfolio.py | 5 ++++- .../unit/services/portfolio/test_portfolio.py | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index 877d5b49..1cbb6036 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -37,6 +37,9 @@ def pnl(self) -> Decimal: class PortfolioPNLDetail: def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, asset_sold: Decimal) -> None: + if bought.block_number > sold.block_number: + raise ValueError("bought block number is greater than sold block number") + self._bought = bought self._sold = sold self._asset_sold = asset_sold @@ -115,7 +118,7 @@ def compute_pnl_fifo_for_pair( if bought_position is None or buy_remaining <= 0: bought_position = next(purchases_it, None) if bought_position is None: - return result + raise RuntimeError("Missing bought position to compute PNL") buy_remaining = bought_position.bought.value sold_quantity = min(sell_remaining, buy_remaining) result.append(PortfolioPNLDetail(bought_position, sell, sold_quantity)) diff --git a/tests/unit/services/portfolio/test_portfolio.py b/tests/unit/services/portfolio/test_portfolio.py index e30ba4c0..11c269e3 100644 --- a/tests/unit/services/portfolio/test_portfolio.py +++ b/tests/unit/services/portfolio/test_portfolio.py @@ -84,3 +84,24 @@ def test_portfolio_compute_pnl_fifo_one_asset__sell_from_multiple_swaps(weth: To assert_pnl_detail(usdc_pnl[2], sold_amount=2, buying_price="0.2", selling_price="1", pnl="1.6") assert_pnl_detail(usdc_pnl[3], sold_amount=3, buying_price="0.2", selling_price="0.01", pnl="-0.57") assert pnl.pnl() == Decimal("5.78") + +def test_portfolio_compute_pnl__wrong_ordering_raise_exception(weth: TokenInfo, usdc: TokenInfo) -> None: + positions = create_swaps([ + (10, usdc, 1, weth), + (1, weth, 10, usdc), + (10, usdc, 1, weth), + ] + ) + + with pytest.raises(ValueError): + PortfolioBase.compute_pnl_fifo(positions, weth) + +def test_portoflio_compute_pnl__bought_exhausted_raise_exception(weth: TokenInfo, usdc: TokenInfo) -> None: + positions = create_swaps([ + (1, weth, 10, usdc), + (9, usdc, 1, weth), + (2, usdc, 1, weth), + ]) + + with pytest.raises(RuntimeError): + PortfolioBase.compute_pnl_fifo(positions, weth) From f2324cf25448abdf8e5e156949b50ad3717fc36c Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Fri, 21 Feb 2025 12:32:10 -0800 Subject: [PATCH 08/24] implement unrealisedPNL for fifo --- alphaswarm/services/portfolio/portfolio.py | 85 +++++++++++++------ .../unit/services/portfolio/test_portfolio.py | 70 +++++++++------ 2 files changed, 105 insertions(+), 50 deletions(-) diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index 1cbb6036..c8f986bf 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -5,7 +5,7 @@ from collections import defaultdict, deque from dataclasses import dataclass from decimal import Decimal -from typing import Dict, Iterable, List, Optional, Self, Sequence +from typing import Callable, Dict, Iterable, List, Optional, Self, Sequence from solders.pubkey import Pubkey @@ -25,43 +25,63 @@ def __init__(self) -> None: def add_details(self, asset: str, details: Iterable[PortfolioPNLDetail]) -> None: self._details_per_asset[asset] = list(details) - def pnl_per_asset(self) -> Dict[str, Decimal]: + def pnl_per_asset(self, *, realized: bool = True, unrealised: bool = True) -> Dict[str, Decimal]: + def include_predicate(item: PortfolioPNLDetail) -> bool: + return (item.is_realized and realized) or (not item.is_realized and unrealised) + result = {} for asset, details in self._details_per_asset.items(): - result[asset] = sum([item.pnl for item in details], Decimal(0)) + result[asset] = sum([item.pnl for item in details if include_predicate(item)], Decimal(0)) return result - def pnl(self) -> Decimal: - return sum([pnl for asset, pnl in self.pnl_per_asset().items()], Decimal(0)) + def pnl(self, *, realized: bool = True, unrealised: bool = True) -> Decimal: + return sum( + [pnl for asset, pnl in self.pnl_per_asset(realized=realized, unrealised=unrealised).items()], Decimal(0) + ) class PortfolioPNLDetail: - def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, asset_sold: Decimal) -> None: - if bought.block_number > sold.block_number: - raise ValueError("bought block number is greater than sold block number") - + def __init__(self, bought: PortfolioSwap, selling_price: Decimal, asset_sold: Decimal, is_realized: bool) -> None: self._bought = bought - self._sold = sold - self._asset_sold = asset_sold - self._pnl = asset_sold * (self.selling_price - self.buying_price) + self._selling_price = selling_price + self._assert_sold = asset_sold + self._is_realized = is_realized + self._pnl = asset_sold * (self._selling_price - self.buying_price) @property - def pnl(self) -> Decimal: - return self._pnl + def buying_price(self) -> Decimal: + """Buying price per assert""" + return self._bought.sold.value / self._bought.bought.value @property def sold_amount(self) -> Decimal: - return self._asset_sold + return self._assert_sold @property - def buying_price(self) -> Decimal: - """Buying price per assert""" - return self._bought.sold.value / self._bought.bought.value + def selling_price(self) -> Decimal: + return self._selling_price @property - def selling_price(self) -> Decimal: - """Selling price per assert""" - return self._sold.bought.value / self._sold.sold.value + def pnl(self) -> Decimal: + return self._pnl + + @property + def is_realized(self) -> bool: + return self._is_realized + + +class PortfolioRealizedPNLDetail(PortfolioPNLDetail): + def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, asset_sold: Decimal) -> None: + if bought.block_number > sold.block_number: + raise ValueError("bought block number is greater than sold block number") + + super().__init__(bought, sold.bought.value / sold.sold.value, asset_sold, is_realized=True) + self._sold = sold + + +class PortfolioUnrealizedPNLDetail(PortfolioPNLDetail): + def __init__(self, bought: PortfolioSwap, selling_price: Decimal, asset_sold: Decimal) -> None: + super().__init__(bought, selling_price, asset_sold, is_realized=False) @dataclass @@ -75,6 +95,10 @@ def to_short_string(self) -> str: return f"{self.sold.value} {self.sold.token_info.symbol} -> {self.bought.value} {self.bought.token_info.symbol} ({self.sold.token_info.chain} {self.block_number} {self.hash})" +# A pricing function that returns the price in second token address for each first token address +PricingFunction = Callable[[str, str], Decimal] + + class PortfolioBase: def __init__(self, wallet: WalletInfo) -> None: self._wallet = wallet @@ -88,7 +112,9 @@ def chain(self) -> str: return self._wallet.chain @classmethod - def compute_pnl_fifo(cls, positions: Sequence[PortfolioSwap], base_token: TokenInfo) -> PortfolioPNL: + def compute_pnl_fifo( + cls, positions: Sequence[PortfolioSwap], base_token: TokenInfo, pricing_function: PricingFunction + ) -> PortfolioPNL: items = sorted(positions, key=lambda x: x.block_number) purchases: Dict[str, deque[PortfolioSwap]] = defaultdict(deque) sells: Dict[str, deque[PortfolioSwap]] = defaultdict(deque) @@ -100,13 +126,16 @@ def compute_pnl_fifo(cls, positions: Sequence[PortfolioSwap], base_token: TokenI result = PortfolioPNL() for asset, swaps in sells.items(): - result.add_details(asset, cls.compute_pnl_fifo_for_pair(purchases[asset], swaps)) + result.add_details( + asset, + cls.compute_pnl_fifo_for_pair(purchases[asset], swaps, pricing_function(asset, base_token.address)), + ) return result @classmethod def compute_pnl_fifo_for_pair( - cls, purchases: deque[PortfolioSwap], sells: deque[PortfolioSwap] + cls, purchases: deque[PortfolioSwap], sells: deque[PortfolioSwap], asset_price: Decimal ) -> List[PortfolioPNLDetail]: purchases_it = iter(purchases) bought_position: Optional[PortfolioSwap] = None @@ -121,10 +150,16 @@ def compute_pnl_fifo_for_pair( raise RuntimeError("Missing bought position to compute PNL") buy_remaining = bought_position.bought.value sold_quantity = min(sell_remaining, buy_remaining) - result.append(PortfolioPNLDetail(bought_position, sell, sold_quantity)) + result.append(PortfolioRealizedPNLDetail(bought_position, sell, sold_quantity)) sell_remaining -= sold_quantity buy_remaining -= sold_quantity + if buy_remaining > 0 and bought_position is not None: + result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, buy_remaining)) + + for bought_position in purchases_it: + result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, bought_position.bought.value)) + return result diff --git a/tests/unit/services/portfolio/test_portfolio.py b/tests/unit/services/portfolio/test_portfolio.py index 11c269e3..bb168094 100644 --- a/tests/unit/services/portfolio/test_portfolio.py +++ b/tests/unit/services/portfolio/test_portfolio.py @@ -4,7 +4,11 @@ import pytest from alphaswarm.core.token import TokenAmount, TokenInfo -from alphaswarm.services.portfolio.portfolio import PortfolioBase, PortfolioPNLDetail, PortfolioSwap +from alphaswarm.services.portfolio.portfolio import ( + PortfolioBase, + PortfolioPNLDetail, + PortfolioSwap, +) def create_swaps( @@ -35,18 +39,20 @@ def weth() -> TokenInfo: return TokenInfo(symbol="WETH", address="0xWETH", decimals=18, chain="chain") -def assert_pnl_detail( +def assert_detail( item: PortfolioPNLDetail, *, sold_amount: Union[int, str], buying_price: Union[int, str], selling_price: Union[int, str], pnl: Union[int, str], + realized: bool, ) -> None: assert item.sold_amount == Decimal(sold_amount) assert item.buying_price == Decimal(buying_price) assert item.selling_price == Decimal(selling_price) assert item.pnl == Decimal(pnl) + assert item.is_realized == realized def test_portfolio_compute_pnl_fifo_one_asset__sell_from_first_swap(weth: TokenInfo, usdc: TokenInfo) -> None: @@ -59,11 +65,17 @@ def test_portfolio_compute_pnl_fifo_one_asset__sell_from_first_swap(weth: TokenI ] ) - pnl = PortfolioBase.compute_pnl_fifo(positions, weth) - usdc_pnl = pnl._details_per_asset[usdc.address] - assert_pnl_detail(usdc_pnl[0], sold_amount=5, buying_price="0.1", selling_price="0.4", pnl="1.5") - assert_pnl_detail(usdc_pnl[1], sold_amount=2, buying_price="0.1", selling_price="1", pnl="1.8") - assert pnl.pnl() == Decimal("3.3") + pnl = PortfolioBase.compute_pnl_fifo(positions, weth, lambda asset, base: Decimal(1)) + + usdc_pnl = iter(pnl._details_per_asset[usdc.address]) + assert_detail(next(usdc_pnl), sold_amount=5, buying_price="0.1", selling_price="0.4", pnl="1.5", realized=True) + assert_detail(next(usdc_pnl), sold_amount=2, buying_price="0.1", selling_price="1", pnl="1.8", realized=True) + assert_detail(next(usdc_pnl), sold_amount=3, buying_price="0.1", selling_price="1", pnl="2.7", realized=False) + assert_detail(next(usdc_pnl), sold_amount=8, buying_price="0.125", selling_price="1", pnl="7", realized=False) + assert next(usdc_pnl, None) is None + assert pnl.pnl(unrealised=False) == Decimal("3.3") + assert pnl.pnl(realized=False) == Decimal("9.7") + assert pnl.pnl() == Decimal("13") def test_portfolio_compute_pnl_fifo_one_asset__sell_from_multiple_swaps(weth: TokenInfo, usdc: TokenInfo) -> None: @@ -77,31 +89,39 @@ def test_portfolio_compute_pnl_fifo_one_asset__sell_from_multiple_swaps(weth: To ] ) - pnl = PortfolioBase.compute_pnl_fifo(positions, weth) - usdc_pnl = pnl._details_per_asset[usdc.address] - assert_pnl_detail(usdc_pnl[0], sold_amount=5, buying_price="0.1", selling_price=".15", pnl=".25") - assert_pnl_detail(usdc_pnl[1], sold_amount=5, buying_price="0.1", selling_price="1", pnl="4.5") - assert_pnl_detail(usdc_pnl[2], sold_amount=2, buying_price="0.2", selling_price="1", pnl="1.6") - assert_pnl_detail(usdc_pnl[3], sold_amount=3, buying_price="0.2", selling_price="0.01", pnl="-0.57") + pnl = PortfolioBase.compute_pnl_fifo(positions, weth, lambda asset, base: Decimal(1)) + usdc_pnl = iter(pnl._details_per_asset[usdc.address]) + assert_detail(next(usdc_pnl), sold_amount=5, buying_price="0.1", selling_price=".15", pnl=".25", realized=True) + assert_detail(next(usdc_pnl), sold_amount=5, buying_price="0.1", selling_price="1", pnl="4.5", realized=True) + assert_detail(next(usdc_pnl), sold_amount=2, buying_price="0.2", selling_price="1", pnl="1.6", realized=True) + assert_detail(next(usdc_pnl), sold_amount=3, buying_price="0.2", selling_price="0.01", pnl="-0.57", realized=True) + assert next(usdc_pnl, None) is None + assert pnl.pnl(unrealised=False) == Decimal("5.78") + assert pnl.pnl(realized=False) == Decimal(0) assert pnl.pnl() == Decimal("5.78") + def test_portfolio_compute_pnl__wrong_ordering_raise_exception(weth: TokenInfo, usdc: TokenInfo) -> None: - positions = create_swaps([ - (10, usdc, 1, weth), - (1, weth, 10, usdc), - (10, usdc, 1, weth), - ] + positions = create_swaps( + [ + (10, usdc, 1, weth), + (1, weth, 10, usdc), + (10, usdc, 1, weth), + ] ) with pytest.raises(ValueError): - PortfolioBase.compute_pnl_fifo(positions, weth) + PortfolioBase.compute_pnl_fifo(positions, weth, lambda asset, base: Decimal(1)) + def test_portoflio_compute_pnl__bought_exhausted_raise_exception(weth: TokenInfo, usdc: TokenInfo) -> None: - positions = create_swaps([ - (1, weth, 10, usdc), - (9, usdc, 1, weth), - (2, usdc, 1, weth), - ]) + positions = create_swaps( + [ + (1, weth, 10, usdc), + (9, usdc, 1, weth), + (2, usdc, 1, weth), + ] + ) with pytest.raises(RuntimeError): - PortfolioBase.compute_pnl_fifo(positions, weth) + PortfolioBase.compute_pnl_fifo(positions, weth, lambda asset, base: Decimal(1)) From 31f5ad57e38d399762871ab0f30b95df0ff3c4a3 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Fri, 21 Feb 2025 13:53:03 -0800 Subject: [PATCH 09/24] clean up --- alphaswarm/services/portfolio/portfolio.py | 27 ++++++++++++------- .../unit/services/portfolio/test_portfolio.py | 9 ++++--- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index c8f986bf..88f4956e 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -5,6 +5,7 @@ from collections import defaultdict, deque from dataclasses import dataclass from decimal import Decimal +from enum import Enum, auto from typing import Callable, Dict, Iterable, List, Optional, Self, Sequence from solders.pubkey import Pubkey @@ -18,6 +19,12 @@ logger = logging.getLogger(__name__) +class PnlMode(Enum): + TOTAL = auto() + REALIZED = auto() + UNREALIZED = auto() + + class PortfolioPNL: def __init__(self) -> None: self._details_per_asset: Dict[str, List[PortfolioPNLDetail]] = {} @@ -25,19 +32,14 @@ def __init__(self) -> None: def add_details(self, asset: str, details: Iterable[PortfolioPNLDetail]) -> None: self._details_per_asset[asset] = list(details) - def pnl_per_asset(self, *, realized: bool = True, unrealised: bool = True) -> Dict[str, Decimal]: - def include_predicate(item: PortfolioPNLDetail) -> bool: - return (item.is_realized and realized) or (not item.is_realized and unrealised) - + def pnl_per_asset(self, mode: PnlMode = PnlMode.TOTAL) -> Dict[str, Decimal]: result = {} for asset, details in self._details_per_asset.items(): - result[asset] = sum([item.pnl for item in details if include_predicate(item)], Decimal(0)) + result[asset] = sum([item.pnl for item in details if item.is_in_scope(mode)], Decimal(0)) return result - def pnl(self, *, realized: bool = True, unrealised: bool = True) -> Decimal: - return sum( - [pnl for asset, pnl in self.pnl_per_asset(realized=realized, unrealised=unrealised).items()], Decimal(0) - ) + def pnl(self, mode: PnlMode = PnlMode.TOTAL) -> Decimal: + return sum([pnl for asset, pnl in self.pnl_per_asset(mode).items()], Decimal(0)) class PortfolioPNLDetail: @@ -69,6 +71,13 @@ def pnl(self) -> Decimal: def is_realized(self) -> bool: return self._is_realized + def is_in_scope(self, mode: PnlMode) -> bool: + return ( + mode == PnlMode.TOTAL + or (mode == PnlMode.REALIZED and self._is_realized) + or (mode == PnlMode.UNREALIZED and not self._is_realized) + ) + class PortfolioRealizedPNLDetail(PortfolioPNLDetail): def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, asset_sold: Decimal) -> None: diff --git a/tests/unit/services/portfolio/test_portfolio.py b/tests/unit/services/portfolio/test_portfolio.py index bb168094..660a8296 100644 --- a/tests/unit/services/portfolio/test_portfolio.py +++ b/tests/unit/services/portfolio/test_portfolio.py @@ -5,6 +5,7 @@ from alphaswarm.core.token import TokenAmount, TokenInfo from alphaswarm.services.portfolio.portfolio import ( + PnlMode, PortfolioBase, PortfolioPNLDetail, PortfolioSwap, @@ -73,8 +74,8 @@ def test_portfolio_compute_pnl_fifo_one_asset__sell_from_first_swap(weth: TokenI assert_detail(next(usdc_pnl), sold_amount=3, buying_price="0.1", selling_price="1", pnl="2.7", realized=False) assert_detail(next(usdc_pnl), sold_amount=8, buying_price="0.125", selling_price="1", pnl="7", realized=False) assert next(usdc_pnl, None) is None - assert pnl.pnl(unrealised=False) == Decimal("3.3") - assert pnl.pnl(realized=False) == Decimal("9.7") + assert pnl.pnl(PnlMode.REALIZED) == Decimal("3.3") + assert pnl.pnl(PnlMode.UNREALIZED) == Decimal("9.7") assert pnl.pnl() == Decimal("13") @@ -96,8 +97,8 @@ def test_portfolio_compute_pnl_fifo_one_asset__sell_from_multiple_swaps(weth: To assert_detail(next(usdc_pnl), sold_amount=2, buying_price="0.2", selling_price="1", pnl="1.6", realized=True) assert_detail(next(usdc_pnl), sold_amount=3, buying_price="0.2", selling_price="0.01", pnl="-0.57", realized=True) assert next(usdc_pnl, None) is None - assert pnl.pnl(unrealised=False) == Decimal("5.78") - assert pnl.pnl(realized=False) == Decimal(0) + assert pnl.pnl(PnlMode.REALIZED) == Decimal("5.78") + assert pnl.pnl(PnlMode.UNREALIZED) == Decimal(0) assert pnl.pnl() == Decimal("5.78") From 4d2b0a4d84841aecd245b026e89dc3a9a3ae4cf9 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Fri, 21 Feb 2025 14:16:46 -0800 Subject: [PATCH 10/24] rework pnl computation --- alphaswarm/services/portfolio/portfolio.py | 37 +++++++++++-------- .../unit/services/portfolio/test_portfolio.py | 19 ++-------- 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index 88f4956e..1e696eaf 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -121,52 +121,57 @@ def chain(self) -> str: return self._wallet.chain @classmethod - def compute_pnl_fifo( + def compute_pnl( cls, positions: Sequence[PortfolioSwap], base_token: TokenInfo, pricing_function: PricingFunction ) -> PortfolioPNL: items = sorted(positions, key=lambda x: x.block_number) - purchases: Dict[str, deque[PortfolioSwap]] = defaultdict(deque) - sells: Dict[str, deque[PortfolioSwap]] = defaultdict(deque) + per_asset = defaultdict(list) for position in items: if position.sold.token_info.address == base_token.address: - purchases[position.bought.token_info.address].append(position) + per_asset[position.bought.token_info.address].append(position) if position.bought.token_info.address == base_token.address: - sells[position.sold.token_info.address].append(position) + per_asset[position.sold.token_info.address].append(position) result = PortfolioPNL() - for asset, swaps in sells.items(): + for asset, swaps in per_asset.items(): result.add_details( asset, - cls.compute_pnl_fifo_for_pair(purchases[asset], swaps, pricing_function(asset, base_token.address)), + cls.compute_pnl_fifo_for_pair(swaps, base_token, pricing_function(asset, base_token.address)), ) return result @classmethod def compute_pnl_fifo_for_pair( - cls, purchases: deque[PortfolioSwap], sells: deque[PortfolioSwap], asset_price: Decimal + cls, swaps: List[PortfolioSwap], base_token: TokenInfo, asset_price: Decimal ) -> List[PortfolioPNLDetail]: - purchases_it = iter(purchases) + purchases: deque[PortfolioSwap] = deque() bought_position: Optional[PortfolioSwap] = None buy_remaining = Decimal(0) result: List[PortfolioPNLDetail] = [] - for sell in sells: - sell_remaining = sell.sold.value + for swap in swaps: + if swap.sold.token_info.address == base_token.address: + purchases.append(swap) + continue + + sell_remaining = swap.sold.value while sell_remaining > 0: - if bought_position is None or buy_remaining <= 0: - bought_position = next(purchases_it, None) - if bought_position is None: + if buy_remaining <= 0 or bought_position is None: + try: + bought_position = purchases.popleft() + except IndexError: raise RuntimeError("Missing bought position to compute PNL") buy_remaining = bought_position.bought.value + sold_quantity = min(sell_remaining, buy_remaining) - result.append(PortfolioRealizedPNLDetail(bought_position, sell, sold_quantity)) + result.append(PortfolioRealizedPNLDetail(bought_position, swap, sold_quantity)) sell_remaining -= sold_quantity buy_remaining -= sold_quantity if buy_remaining > 0 and bought_position is not None: result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, buy_remaining)) - for bought_position in purchases_it: + for bought_position in purchases: result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, bought_position.bought.value)) return result diff --git a/tests/unit/services/portfolio/test_portfolio.py b/tests/unit/services/portfolio/test_portfolio.py index 660a8296..78bc7502 100644 --- a/tests/unit/services/portfolio/test_portfolio.py +++ b/tests/unit/services/portfolio/test_portfolio.py @@ -66,7 +66,7 @@ def test_portfolio_compute_pnl_fifo_one_asset__sell_from_first_swap(weth: TokenI ] ) - pnl = PortfolioBase.compute_pnl_fifo(positions, weth, lambda asset, base: Decimal(1)) + pnl = PortfolioBase.compute_pnl(positions, weth, lambda asset, base: Decimal(1)) usdc_pnl = iter(pnl._details_per_asset[usdc.address]) assert_detail(next(usdc_pnl), sold_amount=5, buying_price="0.1", selling_price="0.4", pnl="1.5", realized=True) @@ -90,7 +90,7 @@ def test_portfolio_compute_pnl_fifo_one_asset__sell_from_multiple_swaps(weth: To ] ) - pnl = PortfolioBase.compute_pnl_fifo(positions, weth, lambda asset, base: Decimal(1)) + pnl = PortfolioBase.compute_pnl(positions, weth, lambda asset, base: Decimal(1)) usdc_pnl = iter(pnl._details_per_asset[usdc.address]) assert_detail(next(usdc_pnl), sold_amount=5, buying_price="0.1", selling_price=".15", pnl=".25", realized=True) assert_detail(next(usdc_pnl), sold_amount=5, buying_price="0.1", selling_price="1", pnl="4.5", realized=True) @@ -102,19 +102,6 @@ def test_portfolio_compute_pnl_fifo_one_asset__sell_from_multiple_swaps(weth: To assert pnl.pnl() == Decimal("5.78") -def test_portfolio_compute_pnl__wrong_ordering_raise_exception(weth: TokenInfo, usdc: TokenInfo) -> None: - positions = create_swaps( - [ - (10, usdc, 1, weth), - (1, weth, 10, usdc), - (10, usdc, 1, weth), - ] - ) - - with pytest.raises(ValueError): - PortfolioBase.compute_pnl_fifo(positions, weth, lambda asset, base: Decimal(1)) - - def test_portoflio_compute_pnl__bought_exhausted_raise_exception(weth: TokenInfo, usdc: TokenInfo) -> None: positions = create_swaps( [ @@ -125,4 +112,4 @@ def test_portoflio_compute_pnl__bought_exhausted_raise_exception(weth: TokenInfo ) with pytest.raises(RuntimeError): - PortfolioBase.compute_pnl_fifo(positions, weth, lambda asset, base: Decimal(1)) + PortfolioBase.compute_pnl(positions, weth, lambda asset, base: Decimal(1)) From d79673beeed3065501d05b271ce75b20248934ef Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Fri, 21 Feb 2025 14:24:45 -0800 Subject: [PATCH 11/24] clean up --- alphaswarm/services/portfolio/portfolio.py | 2 +- tests/integration/services/portfolio/test_portfolio.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index a2a1b863..8593d812 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -279,7 +279,7 @@ def get_token_balances(self) -> List[TokenAmount]: result.append(token_info.to_amount_from_base_units(Wei(balance.value))) return result - def get_positions(self) -> List[PortfolioSwap]: + def get_swaps(self) -> List[PortfolioSwap]: transfer_in = self._alchemy_client.get_transfers( wallet=self._wallet.address, chain=self._wallet.chain, incoming=True ) diff --git a/tests/integration/services/portfolio/test_portfolio.py b/tests/integration/services/portfolio/test_portfolio.py index 726aa1bc..4d14dcf6 100644 --- a/tests/integration/services/portfolio/test_portfolio.py +++ b/tests/integration/services/portfolio/test_portfolio.py @@ -35,7 +35,7 @@ def test_portfolio_get_balances(default_config: Config, alchemy_client: AlchemyC @pytest.mark.parametrize("chain", chains) @pytest.mark.skip("Need wallet") -def test_portfolio_get_positions(chain: str, evm_portfolio: PortfolioEvm) -> None: - result = evm_portfolio.get_positions() +def test_portfolio_get_swaps(chain: str, evm_portfolio: PortfolioEvm) -> None: + result = evm_portfolio.get_swaps() for item in result: print(item.to_short_string()) From a581cdc623dca185387ffbe50862d3a89e9fb263 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:03:22 -0800 Subject: [PATCH 12/24] list transactions for Solana with Helius, PNL for Solana --- .env.example | 3 + .../services/chains/solana/solana_client.py | 31 ++- alphaswarm/services/helius/__init__.py | 1 + alphaswarm/services/helius/helius_client.py | 230 ++++++++++++++++++ alphaswarm/services/portfolio/portfolio.py | 86 ++++++- .../services/chains/sol/test_solana_client.py | 7 + tests/integration/services/helius/__init__.py | 0 .../services/helius/test_helius_client.py | 39 +++ .../services/portfolio/test_portfolio.py | 13 +- 9 files changed, 394 insertions(+), 16 deletions(-) create mode 100644 alphaswarm/services/helius/__init__.py create mode 100644 alphaswarm/services/helius/helius_client.py create mode 100644 tests/integration/services/helius/__init__.py create mode 100644 tests/integration/services/helius/test_helius_client.py diff --git a/.env.example b/.env.example index 9e6d9a65..e548f165 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ SOLANA_RPC_URL=your_solana_rpc_url # Alchemy Configuration ALCHEMY_API_KEY= +# HELIUS API for Solana +HELIUS_API_KEY= + # Cookie.fun Configuration COOKIE_FUN_API_KEY= diff --git a/alphaswarm/services/chains/solana/solana_client.py b/alphaswarm/services/chains/solana/solana_client.py index 7bcee384..ff13f428 100644 --- a/alphaswarm/services/chains/solana/solana_client.py +++ b/alphaswarm/services/chains/solana/solana_client.py @@ -1,19 +1,21 @@ import logging import time from decimal import Decimal -from typing import Annotated, List, Optional, Self +from typing import Annotated, Any, Iterable, List, Optional, Self from alphaswarm.config import ChainConfig, TokenInfo from alphaswarm.core.token import BaseUnit, TokenAmount from alphaswarm.services.chains.solana.jupiter_client import JupiterClient from pydantic import BaseModel, Field +from solana.exceptions import SolanaRpcException from solana.rpc import api +from solana.rpc.commitment import Finalized from solana.rpc.types import TokenAccountOpts from solders.account_decoder import ParsedAccount from solders.keypair import Keypair from solders.message import to_bytes_versioned from solders.pubkey import Pubkey -from solders.rpc.responses import SendTransactionResp +from solders.rpc.responses import RpcConfirmedTransactionStatusWithSignature, SendTransactionResp from solders.signature import Signature from solders.transaction import VersionedTransaction from solders.transaction_status import TransactionConfirmationStatus @@ -161,3 +163,28 @@ def _wait_for_confirmation(self, signature: Signature) -> None: raise RuntimeError( f"Failed to get confirmation for transaction '{str(signature)}' for {initial_timeout} seconds. Last status is {status}" ) + + def get_signatures_for_address( + self, wallet_address: Pubkey, limit: int = 1000, before: Optional[Signature] = None + ) -> List[RpcConfirmedTransactionStatusWithSignature]: + + result = self._client.get_signatures_for_address( + wallet_address, commitment=Finalized, limit=limit, before=before + ) + return result.value + + def get_transactions(self, wallet_address: Pubkey) -> Iterable[RpcConfirmedTransactionStatusWithSignature]: + # TODO handle more than the default 1000 first transactions + signatures = self._client.get_signatures_for_address(wallet_address, commitment=Finalized) + result = [] + for item in signatures.value: + try: + time.sleep(1) + result.append(self.get_transaction(item.signature)) + except SolanaRpcException: + raise + return result + + def get_transaction(self, signature: Signature) -> Any: + tx = self._client.get_transaction(signature, max_supported_transaction_version=0) + return tx diff --git a/alphaswarm/services/helius/__init__.py b/alphaswarm/services/helius/__init__.py new file mode 100644 index 00000000..b20537d2 --- /dev/null +++ b/alphaswarm/services/helius/__init__.py @@ -0,0 +1 @@ +from .helius_client import HeliusClient diff --git a/alphaswarm/services/helius/helius_client.py b/alphaswarm/services/helius/helius_client.py new file mode 100644 index 00000000..650ada7f --- /dev/null +++ b/alphaswarm/services/helius/helius_client.py @@ -0,0 +1,230 @@ +import os +from decimal import Decimal +from enum import StrEnum +from typing import Annotated, Dict, Final, List, Optional, Self, Sequence + +import requests +from alphaswarm.services import ApiException +from pydantic import Field +from pydantic.dataclasses import dataclass + + +class ConfirmationStatus(StrEnum): + CONFIRMED = "confirmed" + FINALIZED = "finalized" + PROCESSED = "processed" + + +@dataclass +class SignatureResult: + signature: str + slot: int + err: Annotated[Optional[Dict], Field(default=None)] + memo: Annotated[Optional[str], Field(default=None)] + block_time: Annotated[Optional[int], Field(default=None, alias="blockTime")] + confirmation_status: Annotated[Optional[ConfirmationStatus], Field(default=None, alias="confirmationStatus")] + + +@dataclass +class NativeTransfer: + from_user_account: Annotated[str, Field(alias="fromUserAccount")] + to_user_account: Annotated[str, Field(alias="toUserAccount")] + amount: Annotated[int, Field(description="The amount fo sol sent (in lamports)")] + + +@dataclass +class TokenTransfer: + from_user_account: Annotated[str, Field(alias="fromUserAccount")] + to_user_account: Annotated[str, Field(alias="toUserAccount")] + from_token_account: Annotated[str, Field(alias="fromTokenAccount")] + to_token_account: Annotated[str, Field(alias="toTokenAccount")] + token_amount: Annotated[Decimal, Field(alias="tokenAmount", description="The number of tokens sent.")] + mint: str + + +@dataclass +class RawTokenAmount: + token_amount: Annotated[Decimal, Field(alias="tokenAmount")] + decimals: int + + +@dataclass +class TokenBalanceChange: + user_account: Annotated[str, Field(alias="userAccount")] + token_account: Annotated[str, Field(alias="tokenAccount")] + mint: str + raw_token_amount: Annotated[RawTokenAmount, Field(alias="rawTokenAmount")] + + +@dataclass +class InnerInstruction: + data: str + program_id: Annotated[str, Field(alias="programId")] + accounts: List[str] + + +@dataclass +class Instruction: + data: str + program_id: Annotated[str, Field(alias="programId")] + accounts: List[str] + inner_instructions: Annotated[List[InnerInstruction], Field(alias="innerInstructions")] + + +@dataclass +class TransactionError: + error: str + + +@dataclass +class Nft: + mint: str + token_standard: Annotated[str, Field(alias="tokenStandard")] + + +@dataclass +class NftEvent: + description: str + type: str + source: str + amount: Annotated[int, Field(description="The amount of the NFT transaction (in lamports)")] + fee: int + fee_payer: Annotated[str, Field(alias="feePayer")] + signature: str + slot: int + timestamp: int + sale_type: Annotated[str, Field(alias="saleType")] + buyer: str + seller: str + staker: str + ntfs: List[Nft] + + +@dataclass +class AccountData: + account: str + native_balance_change: Annotated[ + Decimal, Field(alias="nativeBalanceChange", description="Native (SOL) balance change of the account.") + ] + token_balance_changes: Annotated[List[TokenBalanceChange], Field(alias="tokenBalanceChanges")] + + +@dataclass +class NativeAmount: + account: str + amount: Annotated[str, Field(description="The amount of the balance change")] + + +@dataclass +class ProgramInfo: + source: str + account: str + program_name: Annotated[str, Field(alias="programName")] + instruction_name: Annotated[str, Field(alias="instructionName")] + + +@dataclass +class InnerSwap: + program_info: ProgramInfo + token_inputs: Annotated[List[TokenTransfer], Field(alias="tokenInputs")] + token_outputs: Annotated[List[TokenTransfer], Field(alias="tokenOutputs")] + token_fees: Annotated[List[TokenTransfer], Field(alias="tokenFees")] + native_fees: Annotated[List[NativeTransfer], Field(alias="nativeFees")] + + +@dataclass +class SwapEvent: + native_input: Annotated[NativeAmount, Field(alias="nativeAmount")] + native_output: Annotated[NativeAmount, Field(alias="nativeAmount")] + token_inputs: Annotated[List[TokenBalanceChange], Field(alias="tokenInputs")] + token_outputs: Annotated[List[TokenBalanceChange], Field(alias="tokenOutputs")] + tokens_fees: Annotated[List[TokenBalanceChange], Field(alias="tokensFees")] + native_fees: Annotated[List[NativeAmount], Field(alias="nativeFees")] + inner_swaps: Annotated[List[InnerSwap], Field(alias="innerSwaps")] + + +@dataclass +class Compressed: + type: str + tree_id: Annotated[str, Field(alias="treeId")] + asset_id: Annotated[str, Field(alias="assetId")] + leaf_index: Annotated[int, Field(alias="leafIndex")] + instruction_index: Annotated[int, Field(alias="instructionIndex")] + inner_instruction_index: Annotated[int, Field(alias="innerInstructionIndex")] + new_leaf_owner: Annotated[str, Field(alias="newLeafOwner")] + old_leaf_owner: Annotated[str, Field(alias="oldLeafOwner")] + + +@dataclass +class Authority: + account: str + from_: Annotated[str, Field(alias="from")] + to: str + instruction_index: Annotated[int, Field(alias="instructionIndex")] + inner_instruction_index: Annotated[int, Field(alias="innerInstructionIndex")] + + +@dataclass +class TransactionEvent: + nft: NftEvent + swap: SwapEvent + compressed: Compressed + distributed_compression_rewards: Annotated[int, Field(alias="distributedCompressionRewards")] + + +@dataclass +class EnhancedTransaction: + description: str + type: str + source: str + fee: int + fee_payer: Annotated[str, Field(alias="feePayer")] + signature: str + slot: int + timestamp: int + native_transfers: Annotated[List[NativeTransfer], Field(alias="nativeTransfers")] + token_transfers: Annotated[List[TokenTransfer], Field(alias="tokenTransfers")] + account_data: Annotated[List[AccountData], Field(alias="accountData")] + instructions: List[Instruction] + # transaction_error: Annotated[Optional[TransactionError], Field(alias="transactionError", default=None)] + # events: TransactionEvent + + +class HeliusClient: + BASE_RPC_URL: Final[str] = "https://mainnet.helius-rpc.com" + BASE_TRANSACTION_URL: Final[str] = "https://api.helius.xyz" + + def __init__(self, api_key: str) -> None: + self._api_key = api_key + + def _make_request(self, url: str, data: Dict) -> Dict: + params = {"api-key": self._api_key} + response = requests.post(url=url, json=data, params=params) + if response.status_code >= 400: + raise ApiException(response) + return response.json() + + def get_signatures_for_address(self, wallet_address: str) -> List[SignatureResult]: + body = { + "id": 1, + "jsonrpc": "2.0", + "method": "getSignaturesForAddress", + "params": [ + wallet_address, + ], + } + + response = self._make_request(self.BASE_RPC_URL, body) + return [SignatureResult(**item) for item in response["result"]] + + def get_transactions(self, signatures: Sequence[str]) -> List[EnhancedTransaction]: + if len(signatures) > 100: + raise ValueError("Can only get 100 transactions at a time") + + data = {"transactions": signatures} + response = self._make_request(f"{self.BASE_TRANSACTION_URL}/v0/transactions", data) + return [EnhancedTransaction(**item) for item in response] + + @classmethod + def from_env(cls) -> Self: + return cls(api_key=os.environ["HELIUS_API_KEY"]) diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index 8593d812..969d1ac3 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -10,13 +10,17 @@ from typing import Callable, Dict, Iterable, List, Optional, Self, Sequence from solders.pubkey import Pubkey +from solders.signature import Signature from web3.types import Wei -from ...config import Config, WalletInfo +from ...config import ChainConfig, Config, WalletInfo from ...core.token import TokenAmount, TokenInfo from ..alchemy import AlchemyClient from ..alchemy.alchemy_client import Transfer from ..chains import EVMClient, SolanaClient +from ..chains.solana.jupiter_client import JupiterClient +from ..helius import HeliusClient +from ..helius.helius_client import EnhancedTransaction, TokenTransfer logger = logging.getLogger(__name__) @@ -118,6 +122,10 @@ def __init__(self, wallet: WalletInfo) -> None: def get_token_balances(self) -> List[TokenAmount]: pass + @abstractmethod + def get_swaps(self) -> List[PortfolioSwap]: + pass + @property def chain(self) -> str: return self._wallet.chain @@ -256,14 +264,18 @@ def get_token_balances(self, chain: Optional[str] = None) -> PortfolioBalance: def from_config(cls, config: Config) -> Self: portfolios: List[PortfolioBase] = [] for chain in config.get_supported_networks(): - chain_config = config.get_chain_config(chain) - wallet_info = WalletInfo.from_chain_config(chain_config) - if chain == "solana": - portfolios.append(PortfolioSolana(wallet_info, SolanaClient(chain_config))) - if chain in ["ethereum", "ethereum_sepolia", "base"]: - portfolios.append(PortfolioEvm(wallet_info, EVMClient(chain_config), AlchemyClient.from_env())) + portfolios.append(cls.from_chain(chain)) return cls(portfolios) + @staticmethod + def from_chain(chain_config: ChainConfig) -> PortfolioBase: + wallet_info = WalletInfo.from_chain_config(chain_config) + if chain_config.chain == "solana": + return PortfolioSolana(wallet_info, SolanaClient(chain_config), HeliusClient.from_env(), JupiterClient()) + if chain_config.chain in ["ethereum", "ethereum_sepolia", "base"]: + return PortfolioEvm(wallet_info, EVMClient(chain_config), AlchemyClient.from_env()) + raise ValueError(f"unsupported chain {chain_config.chain}") + class PortfolioEvm(PortfolioBase): def __init__(self, wallet: WalletInfo, evm_client: EVMClient, alchemy_client: AlchemyClient) -> None: @@ -318,9 +330,67 @@ def transfer_to_token_amount(self, transfer: Transfer) -> TokenAmount: class PortfolioSolana(PortfolioBase): - def __init__(self, wallet: WalletInfo, solana_client: SolanaClient) -> None: + def __init__( + self, + wallet: WalletInfo, + solana_client: SolanaClient, + helius_client: HeliusClient, + jupiter_client: JupiterClient, + ) -> None: super().__init__(wallet) self._solana_client = solana_client + self._helius_client = helius_client + self._jupiter_client = jupiter_client def get_token_balances(self) -> List[TokenAmount]: return self._solana_client.get_all_token_balances(Pubkey.from_string(self._wallet.address)) + + def get_swaps(self) -> List[PortfolioSwap]: + result = [] + before: Optional[Signature] = None + page_size = 100 + last_page = page_size + wallet = Pubkey.from_string(self._wallet.address) + + while last_page >= page_size: + signatures = self._solana_client.get_signatures_for_address(wallet, page_size, before) + if len(signatures) == 0: + break + + last_page = len(signatures) + before = signatures[-1].signature + result.extend(self._signatures_to_swaps([str(item.signature) for item in signatures])) + return result + + def _signatures_to_swaps(self, signatures: List[str]) -> List[PortfolioSwap]: + result = [] + chunk_size = 100 + for chunk in [signatures[i : i + chunk_size] for i in range(0, len(signatures), chunk_size)]: + transactions = self._helius_client.get_transactions(chunk) + for item in transactions: + swap = self._transaction_to_swap(item) + if swap is not None: + result.append(swap) + return result + + def _transaction_to_swap(self, transaction: EnhancedTransaction) -> Optional[PortfolioSwap]: + transfer_out: Optional[TokenTransfer] = next( + (item for item in transaction.token_transfers if item.from_user_account == self._wallet.address), None + ) + transfer_in: Optional[TokenTransfer] = next( + (item for item in transaction.token_transfers if item.to_user_account == self._wallet.address), None + ) + + if transfer_out is None or transfer_in is None: + return None + + return PortfolioSwap( + bought=self.transfer_to_token_amount(transfer_in), + sold=self.transfer_to_token_amount(transfer_out), + hash=transaction.signature, + block_number=transaction.slot, + ) + + def transfer_to_token_amount(self, transaction: TokenTransfer) -> TokenAmount: + token_info = self._solana_client.get_token_info(transaction.mint) + return TokenAmount(token_info, transaction.token_amount) diff --git a/tests/integration/services/chains/sol/test_solana_client.py b/tests/integration/services/chains/sol/test_solana_client.py index dd5b11b8..12502f9c 100644 --- a/tests/integration/services/chains/sol/test_solana_client.py +++ b/tests/integration/services/chains/sol/test_solana_client.py @@ -43,3 +43,10 @@ def test_get_all_token_balances(client: SolanaClient, solana_config: ChainConfig assert len(result) > 0 for item in result: assert item.value > 0, f"balance for token {item.token_info.symbol}" + + +@pytest.mark.skip("Requires a valid Solana wallet") +def test_get_signatures_for_address(client: SolanaClient, solana_config: ChainConfig) -> None: + wallet = Pubkey.from_string(solana_config.wallet_address) + result = client.get_signatures_for_address(wallet, limit=4) + assert len(result) == 4 diff --git a/tests/integration/services/helius/__init__.py b/tests/integration/services/helius/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/services/helius/test_helius_client.py b/tests/integration/services/helius/test_helius_client.py new file mode 100644 index 00000000..71a3111e --- /dev/null +++ b/tests/integration/services/helius/test_helius_client.py @@ -0,0 +1,39 @@ +import pytest + +from alphaswarm.config import Config +from alphaswarm.services.chains import SolanaClient +from alphaswarm.services.helius.helius_client import HeliusClient + + +@pytest.fixture +def client(default_config: Config) -> HeliusClient: + return HeliusClient.from_env() + + +@pytest.fixture +def wallet_address(default_config: Config) -> str: + return default_config.get_chain_config("solana").wallet_address + + +@pytest.fixture +def solana_client(default_config: Config) -> SolanaClient: + return SolanaClient(default_config.get_chain_config("solana")) + + +@pytest.mark.skip("Requires an API KEY and a wallet address") +def test_get_signatures_for_wallet(client: HeliusClient, wallet_address: str) -> None: + signature_result = client.get_signatures_for_address(wallet_address) + assert len(signature_result) > 0 + + +@pytest.mark.skip("Requires an API KEY and a wallet address") +def test_get_transactions(client: HeliusClient, wallet_address: str) -> None: + signatures = [ + "2ayqz3XdE9W8uoMoraqqvepZySwA9kaAhNKWF4GRxeLM9N6tQuwjnUnZLnPbng263wSpMp8FyFmbK64PSUbCRpsg", + "2kFxpa3fCtjL3UWBKdhpCcdVWZ2VH9A2YF3mEFxDwNSW3Q2r5AdZL4ZRSeyGM2da3t189rSkcBSFyfHse29A6J3K", + "3C3g5dk2MPDcmJD6fvjkSQn8EYF5U1cyxywkZMRsiSWfifTM77NJXLgtHLEd5iXqFX2LQQEzrTr6gwx1xoNMy8uX", + ] + + result = client.get_transactions(signatures) + assert [item.signature for item in result] == signatures + assert all(len(item.token_transfers) > 0 for item in result) diff --git a/tests/integration/services/portfolio/test_portfolio.py b/tests/integration/services/portfolio/test_portfolio.py index 4d14dcf6..037f75a3 100644 --- a/tests/integration/services/portfolio/test_portfolio.py +++ b/tests/integration/services/portfolio/test_portfolio.py @@ -1,10 +1,11 @@ + import pytest from alphaswarm.config import ChainConfig, Config, WalletInfo from alphaswarm.services.alchemy import AlchemyClient from alphaswarm.services.chains import EVMClient from alphaswarm.services.portfolio import Portfolio -from alphaswarm.services.portfolio.portfolio import PortfolioEvm +from alphaswarm.services.portfolio.portfolio import PortfolioBase, PortfolioEvm @pytest.fixture @@ -18,9 +19,9 @@ def eth_sepolia_portfolio(eth_sepolia_config: ChainConfig, alchemy_client: Alche @pytest.fixture -def evm_portfolio(chain: str, default_config: Config, alchemy_client: AlchemyClient) -> PortfolioEvm: +def portfolio(chain: str, default_config: Config, alchemy_client: AlchemyClient) -> PortfolioBase: chain_config = default_config.get_chain_config(chain) - return PortfolioEvm(WalletInfo.from_chain_config(chain_config), EVMClient(chain_config), alchemy_client) + return Portfolio.from_chain(chain_config) @pytest.mark.skip("Need wallet") @@ -30,12 +31,12 @@ def test_portfolio_get_balances(default_config: Config, alchemy_client: AlchemyC assert len(result.get_non_zero_balances()) > 3 -chains = ["ethereum", "ethereum_sepolia", "base"] +chains = ["ethereum", "ethereum_sepolia", "base", "solana"] @pytest.mark.parametrize("chain", chains) @pytest.mark.skip("Need wallet") -def test_portfolio_get_swaps(chain: str, evm_portfolio: PortfolioEvm) -> None: - result = evm_portfolio.get_swaps() +def test_portfolio_get_swaps(chain: str, portfolio: PortfolioBase, default_config: Config) -> None: + result = portfolio.get_swaps() for item in result: print(item.to_short_string()) From fb71b7d896f7784fd176c3c8e1850cbe97cc6fee Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:34:03 -0800 Subject: [PATCH 13/24] fix and test `Config.get_supported_netowrks` --- alphaswarm/config.py | 11 ++++++++--- tests/unit/test_config.py | 5 +++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/alphaswarm/config.py b/alphaswarm/config.py index 0b416af3..bcc66521 100644 --- a/alphaswarm/config.py +++ b/alphaswarm/config.py @@ -230,9 +230,14 @@ def _filter_networks(self) -> None: def get_supported_networks(self) -> list: """Get list of supported networks for current environment""" - if self._network_env == "all": - return self._config["chain_config"].keys() - return self._config["network_environments"].get(self._network_env, []) + network_env = self._config["network_environments"] + if self._network_env != "all": + return network_env.get(self._network_env, []) + + result = set() + for networks in network_env.values(): + result.update(set(networks)) + return list(result) def get(self, key_path: str, default: Any = None) -> Any: """Get configuration value using dot notation""" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 20d5532f..b42f968f 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -118,3 +118,8 @@ def test_get_trading_venues_for_chain(default_config: Config) -> None: result = default_config.get_trading_venues_for_chain(chain="ethereum") assert set(result) == {"uniswap_v2", "uniswap_v3"} + +def test_get_supported_networks(default_config: Config) -> None: + result = default_config.get_supported_networks() + + assert set(result) == {"ethereum", "ethereum_sepolia", "base", "solana"} From 685f588a672ca4e54ea8a9d9fd8e08f7febac4b4 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:34:08 -0800 Subject: [PATCH 14/24] Update portfolio.py --- alphaswarm/services/portfolio/portfolio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index 969d1ac3..11dab93f 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -264,7 +264,7 @@ def get_token_balances(self, chain: Optional[str] = None) -> PortfolioBalance: def from_config(cls, config: Config) -> Self: portfolios: List[PortfolioBase] = [] for chain in config.get_supported_networks(): - portfolios.append(cls.from_chain(chain)) + portfolios.append(cls.from_chain(config.get_chain_config(chain))) return cls(portfolios) @staticmethod From a6c9d9ad252f8f3b8398ce2cfa40d04abbe5cebc Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:41:00 -0800 Subject: [PATCH 15/24] clean up --- alphaswarm/services/portfolio/portfolio.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index 11dab93f..3bdcce75 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -49,21 +49,21 @@ def pnl(self, mode: PnlMode = PnlMode.TOTAL) -> Decimal: class PortfolioPNLDetail: - def __init__(self, bought: PortfolioSwap, selling_price: Decimal, asset_sold: Decimal, is_realized: bool) -> None: + def __init__(self, bought: PortfolioSwap, selling_price: Decimal, sold_amount: Decimal, is_realized: bool) -> None: self._bought = bought self._selling_price = selling_price - self._assert_sold = asset_sold + self._sold_amount = sold_amount self._is_realized = is_realized - self._pnl = asset_sold * (self._selling_price - self.buying_price) + self._pnl = sold_amount * (self._selling_price - self.buying_price) @property def buying_price(self) -> Decimal: - """Buying price per assert""" + """Buying price per asset""" return self._bought.sold.value / self._bought.bought.value @property def sold_amount(self) -> Decimal: - return self._assert_sold + return self._sold_amount @property def selling_price(self) -> Decimal: @@ -86,17 +86,17 @@ def is_in_scope(self, mode: PnlMode) -> bool: class PortfolioRealizedPNLDetail(PortfolioPNLDetail): - def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, asset_sold: Decimal) -> None: + def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, sold_amount: Decimal) -> None: if bought.block_number > sold.block_number: raise ValueError("bought block number is greater than sold block number") - super().__init__(bought, sold.bought.value / sold.sold.value, asset_sold, is_realized=True) + super().__init__(bought, sold.bought.value / sold.sold.value, sold_amount, is_realized=True) self._sold = sold class PortfolioUnrealizedPNLDetail(PortfolioPNLDetail): - def __init__(self, bought: PortfolioSwap, selling_price: Decimal, asset_sold: Decimal) -> None: - super().__init__(bought, selling_price, asset_sold, is_realized=False) + def __init__(self, bought: PortfolioSwap, selling_price: Decimal, sold_amount: Decimal) -> None: + super().__init__(bought, selling_price, sold_amount, is_realized=False) @dataclass From bca3bcb004f95caca24c79c82a3b79c093b7a393 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:41:05 -0800 Subject: [PATCH 16/24] clean up --- tests/unit/test_config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index b42f968f..cd2f48dc 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -119,6 +119,7 @@ def test_get_trading_venues_for_chain(default_config: Config) -> None: assert set(result) == {"uniswap_v2", "uniswap_v3"} + def test_get_supported_networks(default_config: Config) -> None: result = default_config.get_supported_networks() From 5e9ac3867f652b8b4afdbf56b18c90a0d76a3c0c Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:45:52 -0800 Subject: [PATCH 17/24] extract helius.data --- alphaswarm/services/helius/__init__.py | 1 + alphaswarm/services/helius/data.py | 188 ++++++++++++++++++++ alphaswarm/services/helius/helius_client.py | 187 +------------------ alphaswarm/services/portfolio/portfolio.py | 3 +- 4 files changed, 192 insertions(+), 187 deletions(-) create mode 100644 alphaswarm/services/helius/data.py diff --git a/alphaswarm/services/helius/__init__.py b/alphaswarm/services/helius/__init__.py index b20537d2..a29a3506 100644 --- a/alphaswarm/services/helius/__init__.py +++ b/alphaswarm/services/helius/__init__.py @@ -1 +1,2 @@ from .helius_client import HeliusClient +from .data import EnhancedTransaction, SignatureResult, TokenTransfer diff --git a/alphaswarm/services/helius/data.py b/alphaswarm/services/helius/data.py new file mode 100644 index 00000000..c69edf68 --- /dev/null +++ b/alphaswarm/services/helius/data.py @@ -0,0 +1,188 @@ +from decimal import Decimal +from enum import StrEnum +from typing import Annotated, Dict, List, Optional + +from pydantic import Field +from pydantic.dataclasses import dataclass + + +class ConfirmationStatus(StrEnum): + CONFIRMED = "confirmed" + FINALIZED = "finalized" + PROCESSED = "processed" + + +@dataclass +class SignatureResult: + signature: str + slot: int + err: Annotated[Optional[Dict], Field(default=None)] + memo: Annotated[Optional[str], Field(default=None)] + block_time: Annotated[Optional[int], Field(default=None, alias="blockTime")] + confirmation_status: Annotated[Optional[ConfirmationStatus], Field(default=None, alias="confirmationStatus")] + + +@dataclass +class NativeTransfer: + from_user_account: Annotated[str, Field(alias="fromUserAccount")] + to_user_account: Annotated[str, Field(alias="toUserAccount")] + amount: Annotated[int, Field(description="The amount fo sol sent (in lamports)")] + + +@dataclass +class TokenTransfer: + from_user_account: Annotated[str, Field(alias="fromUserAccount")] + to_user_account: Annotated[str, Field(alias="toUserAccount")] + from_token_account: Annotated[str, Field(alias="fromTokenAccount")] + to_token_account: Annotated[str, Field(alias="toTokenAccount")] + token_amount: Annotated[Decimal, Field(alias="tokenAmount", description="The number of tokens sent.")] + mint: str + + +@dataclass +class RawTokenAmount: + token_amount: Annotated[Decimal, Field(alias="tokenAmount")] + decimals: int + + +@dataclass +class TokenBalanceChange: + user_account: Annotated[str, Field(alias="userAccount")] + token_account: Annotated[str, Field(alias="tokenAccount")] + mint: str + raw_token_amount: Annotated[RawTokenAmount, Field(alias="rawTokenAmount")] + + +@dataclass +class InnerInstruction: + data: str + program_id: Annotated[str, Field(alias="programId")] + accounts: List[str] + + +@dataclass +class Instruction: + data: str + program_id: Annotated[str, Field(alias="programId")] + accounts: List[str] + inner_instructions: Annotated[List[InnerInstruction], Field(alias="innerInstructions")] + + +@dataclass +class TransactionError: + error: str + + +@dataclass +class Nft: + mint: str + token_standard: Annotated[str, Field(alias="tokenStandard")] + + +@dataclass +class NftEvent: + description: str + type: str + source: str + amount: Annotated[int, Field(description="The amount of the NFT transaction (in lamports)")] + fee: int + fee_payer: Annotated[str, Field(alias="feePayer")] + signature: str + slot: int + timestamp: int + sale_type: Annotated[str, Field(alias="saleType")] + buyer: str + seller: str + staker: str + ntfs: List[Nft] + + +@dataclass +class AccountData: + account: str + native_balance_change: Annotated[ + Decimal, Field(alias="nativeBalanceChange", description="Native (SOL) balance change of the account.") + ] + token_balance_changes: Annotated[List[TokenBalanceChange], Field(alias="tokenBalanceChanges")] + + +@dataclass +class NativeAmount: + account: str + amount: Annotated[str, Field(description="The amount of the balance change")] + + +@dataclass +class ProgramInfo: + source: str + account: str + program_name: Annotated[str, Field(alias="programName")] + instruction_name: Annotated[str, Field(alias="instructionName")] + + +@dataclass +class InnerSwap: + program_info: ProgramInfo + token_inputs: Annotated[List[TokenTransfer], Field(alias="tokenInputs")] + token_outputs: Annotated[List[TokenTransfer], Field(alias="tokenOutputs")] + token_fees: Annotated[List[TokenTransfer], Field(alias="tokenFees")] + native_fees: Annotated[List[NativeTransfer], Field(alias="nativeFees")] + + +@dataclass +class SwapEvent: + native_input: Annotated[NativeAmount, Field(alias="nativeAmount")] + native_output: Annotated[NativeAmount, Field(alias="nativeAmount")] + token_inputs: Annotated[List[TokenBalanceChange], Field(alias="tokenInputs")] + token_outputs: Annotated[List[TokenBalanceChange], Field(alias="tokenOutputs")] + tokens_fees: Annotated[List[TokenBalanceChange], Field(alias="tokensFees")] + native_fees: Annotated[List[NativeAmount], Field(alias="nativeFees")] + inner_swaps: Annotated[List[InnerSwap], Field(alias="innerSwaps")] + + +@dataclass +class Compressed: + type: str + tree_id: Annotated[str, Field(alias="treeId")] + asset_id: Annotated[str, Field(alias="assetId")] + leaf_index: Annotated[int, Field(alias="leafIndex")] + instruction_index: Annotated[int, Field(alias="instructionIndex")] + inner_instruction_index: Annotated[int, Field(alias="innerInstructionIndex")] + new_leaf_owner: Annotated[str, Field(alias="newLeafOwner")] + old_leaf_owner: Annotated[str, Field(alias="oldLeafOwner")] + + +@dataclass +class Authority: + account: str + from_: Annotated[str, Field(alias="from")] + to: str + instruction_index: Annotated[int, Field(alias="instructionIndex")] + inner_instruction_index: Annotated[int, Field(alias="innerInstructionIndex")] + + +@dataclass +class TransactionEvent: + nft: NftEvent + swap: SwapEvent + compressed: Compressed + distributed_compression_rewards: Annotated[int, Field(alias="distributedCompressionRewards")] + + +@dataclass +class EnhancedTransaction: + description: str + type: str + source: str + fee: int + fee_payer: Annotated[str, Field(alias="feePayer")] + signature: str + slot: int + timestamp: int + native_transfers: Annotated[List[NativeTransfer], Field(alias="nativeTransfers")] + token_transfers: Annotated[List[TokenTransfer], Field(alias="tokenTransfers")] + account_data: Annotated[List[AccountData], Field(alias="accountData")] + instructions: List[Instruction] + # Disabled for simplicity for now. + # transaction_error: Annotated[Optional[TransactionError], Field(alias="transactionError", default=None)] + # events: TransactionEvent diff --git a/alphaswarm/services/helius/helius_client.py b/alphaswarm/services/helius/helius_client.py index 650ada7f..859ac670 100644 --- a/alphaswarm/services/helius/helius_client.py +++ b/alphaswarm/services/helius/helius_client.py @@ -1,193 +1,10 @@ import os -from decimal import Decimal -from enum import StrEnum -from typing import Annotated, Dict, Final, List, Optional, Self, Sequence +from typing import Dict, Final, List, Self, Sequence import requests from alphaswarm.services import ApiException -from pydantic import Field -from pydantic.dataclasses import dataclass - -class ConfirmationStatus(StrEnum): - CONFIRMED = "confirmed" - FINALIZED = "finalized" - PROCESSED = "processed" - - -@dataclass -class SignatureResult: - signature: str - slot: int - err: Annotated[Optional[Dict], Field(default=None)] - memo: Annotated[Optional[str], Field(default=None)] - block_time: Annotated[Optional[int], Field(default=None, alias="blockTime")] - confirmation_status: Annotated[Optional[ConfirmationStatus], Field(default=None, alias="confirmationStatus")] - - -@dataclass -class NativeTransfer: - from_user_account: Annotated[str, Field(alias="fromUserAccount")] - to_user_account: Annotated[str, Field(alias="toUserAccount")] - amount: Annotated[int, Field(description="The amount fo sol sent (in lamports)")] - - -@dataclass -class TokenTransfer: - from_user_account: Annotated[str, Field(alias="fromUserAccount")] - to_user_account: Annotated[str, Field(alias="toUserAccount")] - from_token_account: Annotated[str, Field(alias="fromTokenAccount")] - to_token_account: Annotated[str, Field(alias="toTokenAccount")] - token_amount: Annotated[Decimal, Field(alias="tokenAmount", description="The number of tokens sent.")] - mint: str - - -@dataclass -class RawTokenAmount: - token_amount: Annotated[Decimal, Field(alias="tokenAmount")] - decimals: int - - -@dataclass -class TokenBalanceChange: - user_account: Annotated[str, Field(alias="userAccount")] - token_account: Annotated[str, Field(alias="tokenAccount")] - mint: str - raw_token_amount: Annotated[RawTokenAmount, Field(alias="rawTokenAmount")] - - -@dataclass -class InnerInstruction: - data: str - program_id: Annotated[str, Field(alias="programId")] - accounts: List[str] - - -@dataclass -class Instruction: - data: str - program_id: Annotated[str, Field(alias="programId")] - accounts: List[str] - inner_instructions: Annotated[List[InnerInstruction], Field(alias="innerInstructions")] - - -@dataclass -class TransactionError: - error: str - - -@dataclass -class Nft: - mint: str - token_standard: Annotated[str, Field(alias="tokenStandard")] - - -@dataclass -class NftEvent: - description: str - type: str - source: str - amount: Annotated[int, Field(description="The amount of the NFT transaction (in lamports)")] - fee: int - fee_payer: Annotated[str, Field(alias="feePayer")] - signature: str - slot: int - timestamp: int - sale_type: Annotated[str, Field(alias="saleType")] - buyer: str - seller: str - staker: str - ntfs: List[Nft] - - -@dataclass -class AccountData: - account: str - native_balance_change: Annotated[ - Decimal, Field(alias="nativeBalanceChange", description="Native (SOL) balance change of the account.") - ] - token_balance_changes: Annotated[List[TokenBalanceChange], Field(alias="tokenBalanceChanges")] - - -@dataclass -class NativeAmount: - account: str - amount: Annotated[str, Field(description="The amount of the balance change")] - - -@dataclass -class ProgramInfo: - source: str - account: str - program_name: Annotated[str, Field(alias="programName")] - instruction_name: Annotated[str, Field(alias="instructionName")] - - -@dataclass -class InnerSwap: - program_info: ProgramInfo - token_inputs: Annotated[List[TokenTransfer], Field(alias="tokenInputs")] - token_outputs: Annotated[List[TokenTransfer], Field(alias="tokenOutputs")] - token_fees: Annotated[List[TokenTransfer], Field(alias="tokenFees")] - native_fees: Annotated[List[NativeTransfer], Field(alias="nativeFees")] - - -@dataclass -class SwapEvent: - native_input: Annotated[NativeAmount, Field(alias="nativeAmount")] - native_output: Annotated[NativeAmount, Field(alias="nativeAmount")] - token_inputs: Annotated[List[TokenBalanceChange], Field(alias="tokenInputs")] - token_outputs: Annotated[List[TokenBalanceChange], Field(alias="tokenOutputs")] - tokens_fees: Annotated[List[TokenBalanceChange], Field(alias="tokensFees")] - native_fees: Annotated[List[NativeAmount], Field(alias="nativeFees")] - inner_swaps: Annotated[List[InnerSwap], Field(alias="innerSwaps")] - - -@dataclass -class Compressed: - type: str - tree_id: Annotated[str, Field(alias="treeId")] - asset_id: Annotated[str, Field(alias="assetId")] - leaf_index: Annotated[int, Field(alias="leafIndex")] - instruction_index: Annotated[int, Field(alias="instructionIndex")] - inner_instruction_index: Annotated[int, Field(alias="innerInstructionIndex")] - new_leaf_owner: Annotated[str, Field(alias="newLeafOwner")] - old_leaf_owner: Annotated[str, Field(alias="oldLeafOwner")] - - -@dataclass -class Authority: - account: str - from_: Annotated[str, Field(alias="from")] - to: str - instruction_index: Annotated[int, Field(alias="instructionIndex")] - inner_instruction_index: Annotated[int, Field(alias="innerInstructionIndex")] - - -@dataclass -class TransactionEvent: - nft: NftEvent - swap: SwapEvent - compressed: Compressed - distributed_compression_rewards: Annotated[int, Field(alias="distributedCompressionRewards")] - - -@dataclass -class EnhancedTransaction: - description: str - type: str - source: str - fee: int - fee_payer: Annotated[str, Field(alias="feePayer")] - signature: str - slot: int - timestamp: int - native_transfers: Annotated[List[NativeTransfer], Field(alias="nativeTransfers")] - token_transfers: Annotated[List[TokenTransfer], Field(alias="tokenTransfers")] - account_data: Annotated[List[AccountData], Field(alias="accountData")] - instructions: List[Instruction] - # transaction_error: Annotated[Optional[TransactionError], Field(alias="transactionError", default=None)] - # events: TransactionEvent +from .data import EnhancedTransaction, SignatureResult class HeliusClient: diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index 3bdcce75..03dae87f 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -19,8 +19,7 @@ from ..alchemy.alchemy_client import Transfer from ..chains import EVMClient, SolanaClient from ..chains.solana.jupiter_client import JupiterClient -from ..helius import HeliusClient -from ..helius.helius_client import EnhancedTransaction, TokenTransfer +from ..helius import EnhancedTransaction, HeliusClient, TokenTransfer logger = logging.getLogger(__name__) From 780ee3d2081bf03dbb865f5ed154ae05f81654a9 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:55:03 -0800 Subject: [PATCH 18/24] clean up --- tests/integration/services/portfolio/test_portfolio.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/services/portfolio/test_portfolio.py b/tests/integration/services/portfolio/test_portfolio.py index 037f75a3..9d722964 100644 --- a/tests/integration/services/portfolio/test_portfolio.py +++ b/tests/integration/services/portfolio/test_portfolio.py @@ -1,4 +1,3 @@ - import pytest from alphaswarm.config import ChainConfig, Config, WalletInfo From 77373e44771a1b393fc2995a45c76906c4aa4f95 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Tue, 25 Feb 2025 12:25:45 -0800 Subject: [PATCH 19/24] clean up --- .../services/chains/solana/solana_client.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/alphaswarm/services/chains/solana/solana_client.py b/alphaswarm/services/chains/solana/solana_client.py index ff13f428..6b42c753 100644 --- a/alphaswarm/services/chains/solana/solana_client.py +++ b/alphaswarm/services/chains/solana/solana_client.py @@ -1,13 +1,12 @@ import logging import time from decimal import Decimal -from typing import Annotated, Any, Iterable, List, Optional, Self +from typing import Annotated, List, Optional, Self from alphaswarm.config import ChainConfig, TokenInfo from alphaswarm.core.token import BaseUnit, TokenAmount from alphaswarm.services.chains.solana.jupiter_client import JupiterClient from pydantic import BaseModel, Field -from solana.exceptions import SolanaRpcException from solana.rpc import api from solana.rpc.commitment import Finalized from solana.rpc.types import TokenAccountOpts @@ -172,19 +171,3 @@ def get_signatures_for_address( wallet_address, commitment=Finalized, limit=limit, before=before ) return result.value - - def get_transactions(self, wallet_address: Pubkey) -> Iterable[RpcConfirmedTransactionStatusWithSignature]: - # TODO handle more than the default 1000 first transactions - signatures = self._client.get_signatures_for_address(wallet_address, commitment=Finalized) - result = [] - for item in signatures.value: - try: - time.sleep(1) - result.append(self.get_transaction(item.signature)) - except SolanaRpcException: - raise - return result - - def get_transaction(self, signature: Signature) -> Any: - tx = self._client.get_transaction(signature, max_supported_transaction_version=0) - return tx From 399bfb062d1142cdcfe6a8f655e767cb3e0b9a97 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Tue, 25 Feb 2025 12:31:43 -0800 Subject: [PATCH 20/24] Update portfolio.py --- alphaswarm/services/portfolio/portfolio.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index 03dae87f..8d876bec 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -109,7 +109,6 @@ def to_short_string(self) -> str: return f"{self.sold.value} {self.sold.token_info.symbol} -> {self.bought.value} {self.bought.token_info.symbol} ({self.sold.token_info.chain} {self.block_number} {self.hash})" -# A pricing function that returns the price in second token address for each first token address PricingFunction = Callable[[str, str], Decimal] @@ -133,6 +132,16 @@ def chain(self) -> str: def compute_pnl( cls, positions: Sequence[PortfolioSwap], base_token: TokenInfo, pricing_function: PricingFunction ) -> PortfolioPNL: + """Compute profit and loss (PNL) for a sequence of portfolio swaps. + + Args: + positions: Sequence of portfolio swaps to analyze + base_token: Token to use as the base currency for PNL calculations + pricing_function: Function that returns current price of an asset in terms of base token (asset_token/base_token) + + Returns: + PortfolioPNL object containing realized and unrealized PNL details + """ items = sorted(positions, key=lambda x: x.block_number) per_asset = defaultdict(list) for position in items: From 8e0db66c2437192d6955299a83123f4a1d216ff2 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Tue, 25 Feb 2025 13:22:43 -0800 Subject: [PATCH 21/24] split portfolio in multiple files --- alphaswarm/services/portfolio/__init__.py | 3 + alphaswarm/services/portfolio/portfolio.py | 311 +----------------- .../services/portfolio/portfolio_base.py | 182 ++++++++++ .../services/portfolio/portfolio_evm.py | 62 ++++ .../services/portfolio/portfolio_solana.py | 79 +++++ .../services/portfolio/test_portfolio.py | 3 +- ...st_portfolio.py => test_portfolio_base.py} | 7 +- 7 files changed, 335 insertions(+), 312 deletions(-) create mode 100644 alphaswarm/services/portfolio/portfolio_base.py create mode 100644 alphaswarm/services/portfolio/portfolio_evm.py create mode 100644 alphaswarm/services/portfolio/portfolio_solana.py rename tests/unit/services/portfolio/{test_portfolio.py => test_portfolio_base.py} (96%) diff --git a/alphaswarm/services/portfolio/__init__.py b/alphaswarm/services/portfolio/__init__.py index 4263bb17..b3ec2b28 100644 --- a/alphaswarm/services/portfolio/__init__.py +++ b/alphaswarm/services/portfolio/__init__.py @@ -1 +1,4 @@ from .portfolio import Portfolio +from .portfolio_base import PortfolioBase, PortfolioSwap, PortfolioPNL, PnlMode +from .portfolio_evm import PortfolioEvm +from .portfolio_solana import PortfolioSolana diff --git a/alphaswarm/services/portfolio/portfolio.py b/alphaswarm/services/portfolio/portfolio.py index 8d876bec..c9c0c464 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -1,198 +1,18 @@ from __future__ import annotations -import logging -from abc import abstractmethod -from collections import defaultdict, deque -from dataclasses import dataclass from datetime import UTC, datetime from decimal import Decimal -from enum import Enum, auto -from typing import Callable, Dict, Iterable, List, Optional, Self, Sequence - -from solders.pubkey import Pubkey -from solders.signature import Signature -from web3.types import Wei +from typing import Dict, Iterable, List, Optional, Self from ...config import ChainConfig, Config, WalletInfo -from ...core.token import TokenAmount, TokenInfo +from ...core.token import TokenAmount from ..alchemy import AlchemyClient -from ..alchemy.alchemy_client import Transfer from ..chains import EVMClient, SolanaClient from ..chains.solana.jupiter_client import JupiterClient -from ..helius import EnhancedTransaction, HeliusClient, TokenTransfer - -logger = logging.getLogger(__name__) - - -class PnlMode(Enum): - TOTAL = auto() - REALIZED = auto() - UNREALIZED = auto() - - -class PortfolioPNL: - def __init__(self) -> None: - self._details_per_asset: Dict[str, List[PortfolioPNLDetail]] = {} - - def add_details(self, asset: str, details: Iterable[PortfolioPNLDetail]) -> None: - self._details_per_asset[asset] = list(details) - - def pnl_per_asset(self, mode: PnlMode = PnlMode.TOTAL) -> Dict[str, Decimal]: - result = {} - for asset, details in self._details_per_asset.items(): - result[asset] = sum([item.pnl for item in details if item.is_in_scope(mode)], Decimal(0)) - return result - - def pnl(self, mode: PnlMode = PnlMode.TOTAL) -> Decimal: - return sum([pnl for asset, pnl in self.pnl_per_asset(mode).items()], Decimal(0)) - - -class PortfolioPNLDetail: - def __init__(self, bought: PortfolioSwap, selling_price: Decimal, sold_amount: Decimal, is_realized: bool) -> None: - self._bought = bought - self._selling_price = selling_price - self._sold_amount = sold_amount - self._is_realized = is_realized - self._pnl = sold_amount * (self._selling_price - self.buying_price) - - @property - def buying_price(self) -> Decimal: - """Buying price per asset""" - return self._bought.sold.value / self._bought.bought.value - - @property - def sold_amount(self) -> Decimal: - return self._sold_amount - - @property - def selling_price(self) -> Decimal: - return self._selling_price - - @property - def pnl(self) -> Decimal: - return self._pnl - - @property - def is_realized(self) -> bool: - return self._is_realized - - def is_in_scope(self, mode: PnlMode) -> bool: - return ( - mode == PnlMode.TOTAL - or (mode == PnlMode.REALIZED and self._is_realized) - or (mode == PnlMode.UNREALIZED and not self._is_realized) - ) - - -class PortfolioRealizedPNLDetail(PortfolioPNLDetail): - def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, sold_amount: Decimal) -> None: - if bought.block_number > sold.block_number: - raise ValueError("bought block number is greater than sold block number") - - super().__init__(bought, sold.bought.value / sold.sold.value, sold_amount, is_realized=True) - self._sold = sold - - -class PortfolioUnrealizedPNLDetail(PortfolioPNLDetail): - def __init__(self, bought: PortfolioSwap, selling_price: Decimal, sold_amount: Decimal) -> None: - super().__init__(bought, selling_price, sold_amount, is_realized=False) - - -@dataclass -class PortfolioSwap: - sold: TokenAmount - bought: TokenAmount - hash: str - block_number: int - - def to_short_string(self) -> str: - return f"{self.sold.value} {self.sold.token_info.symbol} -> {self.bought.value} {self.bought.token_info.symbol} ({self.sold.token_info.chain} {self.block_number} {self.hash})" - - -PricingFunction = Callable[[str, str], Decimal] - - -class PortfolioBase: - def __init__(self, wallet: WalletInfo) -> None: - self._wallet = wallet - - @abstractmethod - def get_token_balances(self) -> List[TokenAmount]: - pass - - @abstractmethod - def get_swaps(self) -> List[PortfolioSwap]: - pass - - @property - def chain(self) -> str: - return self._wallet.chain - - @classmethod - def compute_pnl( - cls, positions: Sequence[PortfolioSwap], base_token: TokenInfo, pricing_function: PricingFunction - ) -> PortfolioPNL: - """Compute profit and loss (PNL) for a sequence of portfolio swaps. - - Args: - positions: Sequence of portfolio swaps to analyze - base_token: Token to use as the base currency for PNL calculations - pricing_function: Function that returns current price of an asset in terms of base token (asset_token/base_token) - - Returns: - PortfolioPNL object containing realized and unrealized PNL details - """ - items = sorted(positions, key=lambda x: x.block_number) - per_asset = defaultdict(list) - for position in items: - if position.sold.token_info.address == base_token.address: - per_asset[position.bought.token_info.address].append(position) - if position.bought.token_info.address == base_token.address: - per_asset[position.sold.token_info.address].append(position) - - result = PortfolioPNL() - for asset, swaps in per_asset.items(): - result.add_details( - asset, - cls.compute_pnl_fifo_for_pair(swaps, base_token, pricing_function(asset, base_token.address)), - ) - - return result - - @classmethod - def compute_pnl_fifo_for_pair( - cls, swaps: List[PortfolioSwap], base_token: TokenInfo, asset_price: Decimal - ) -> List[PortfolioPNLDetail]: - purchases: deque[PortfolioSwap] = deque() - bought_position: Optional[PortfolioSwap] = None - buy_remaining = Decimal(0) - result: List[PortfolioPNLDetail] = [] - for swap in swaps: - if swap.sold.token_info.address == base_token.address: - purchases.append(swap) - continue - - sell_remaining = swap.sold.value - while sell_remaining > 0: - if buy_remaining <= 0 or bought_position is None: - try: - bought_position = purchases.popleft() - except IndexError: - raise RuntimeError("Missing bought position to compute PNL") - buy_remaining = bought_position.bought.value - - sold_quantity = min(sell_remaining, buy_remaining) - result.append(PortfolioRealizedPNLDetail(bought_position, swap, sold_quantity)) - sell_remaining -= sold_quantity - buy_remaining -= sold_quantity - - if buy_remaining > 0 and bought_position is not None: - result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, buy_remaining)) - - for bought_position in purchases: - result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, bought_position.bought.value)) - - return result +from ..helius import HeliusClient +from .portfolio_base import PortfolioBase +from .portfolio_evm import PortfolioEvm +from .portfolio_solana import PortfolioSolana class PortfolioBalance: @@ -283,122 +103,3 @@ def from_chain(chain_config: ChainConfig) -> PortfolioBase: if chain_config.chain in ["ethereum", "ethereum_sepolia", "base"]: return PortfolioEvm(wallet_info, EVMClient(chain_config), AlchemyClient.from_env()) raise ValueError(f"unsupported chain {chain_config.chain}") - - -class PortfolioEvm(PortfolioBase): - def __init__(self, wallet: WalletInfo, evm_client: EVMClient, alchemy_client: AlchemyClient) -> None: - super().__init__(wallet) - self._evm_client = evm_client - self._alchemy_client = alchemy_client - - def get_token_balances(self) -> List[TokenAmount]: - balances = self._alchemy_client.get_token_balances(wallet=self._wallet.address, chain=self._wallet.chain) - result = [] - for balance in balances: - token_info = self._evm_client.get_token_info(EVMClient.to_checksum_address(balance.contract_address)) - result.append(token_info.to_amount_from_base_units(Wei(balance.value))) - return result - - def get_swaps(self) -> List[PortfolioSwap]: - transfer_in = self._alchemy_client.get_transfers( - wallet=self._wallet.address, chain=self._wallet.chain, incoming=True - ) - transfer_out = self._alchemy_client.get_transfers( - wallet=self._wallet.address, chain=self._wallet.chain, incoming=False - ) - map_out = {item.tx_hash: item for item in transfer_out} - - result = [] - for transfer in transfer_in: - matched_out = map_out.get(transfer.tx_hash) - if matched_out is None: - logger.debug(f"Transfer {transfer.tx_hash} has no matching output") - continue - result.append( - PortfolioSwap( - bought=self.transfer_to_token_amount(transfer), - sold=self.transfer_to_token_amount(matched_out), - hash=transfer.tx_hash, - block_number=transfer.block_number, - ) - ) - - return result - - def transfer_to_token_amount(self, transfer: Transfer) -> TokenAmount: - token_info = TokenInfo( - symbol=transfer.asset, - address=EVMClient.to_checksum_address(transfer.raw_contract.address), - decimals=transfer.raw_contract.decimal, - chain=self._wallet.chain, - ) - - value = transfer.value - return TokenAmount(value=value, token_info=token_info) - - -class PortfolioSolana(PortfolioBase): - def __init__( - self, - wallet: WalletInfo, - solana_client: SolanaClient, - helius_client: HeliusClient, - jupiter_client: JupiterClient, - ) -> None: - super().__init__(wallet) - self._solana_client = solana_client - self._helius_client = helius_client - self._jupiter_client = jupiter_client - - def get_token_balances(self) -> List[TokenAmount]: - return self._solana_client.get_all_token_balances(Pubkey.from_string(self._wallet.address)) - - def get_swaps(self) -> List[PortfolioSwap]: - result = [] - before: Optional[Signature] = None - page_size = 100 - last_page = page_size - wallet = Pubkey.from_string(self._wallet.address) - - while last_page >= page_size: - signatures = self._solana_client.get_signatures_for_address(wallet, page_size, before) - if len(signatures) == 0: - break - - last_page = len(signatures) - before = signatures[-1].signature - result.extend(self._signatures_to_swaps([str(item.signature) for item in signatures])) - return result - - def _signatures_to_swaps(self, signatures: List[str]) -> List[PortfolioSwap]: - result = [] - chunk_size = 100 - for chunk in [signatures[i : i + chunk_size] for i in range(0, len(signatures), chunk_size)]: - transactions = self._helius_client.get_transactions(chunk) - for item in transactions: - swap = self._transaction_to_swap(item) - if swap is not None: - result.append(swap) - return result - - def _transaction_to_swap(self, transaction: EnhancedTransaction) -> Optional[PortfolioSwap]: - transfer_out: Optional[TokenTransfer] = next( - (item for item in transaction.token_transfers if item.from_user_account == self._wallet.address), None - ) - transfer_in: Optional[TokenTransfer] = next( - (item for item in transaction.token_transfers if item.to_user_account == self._wallet.address), None - ) - - if transfer_out is None or transfer_in is None: - return None - - return PortfolioSwap( - bought=self.transfer_to_token_amount(transfer_in), - sold=self.transfer_to_token_amount(transfer_out), - hash=transaction.signature, - block_number=transaction.slot, - ) - - def transfer_to_token_amount(self, transaction: TokenTransfer) -> TokenAmount: - token_info = self._solana_client.get_token_info(transaction.mint) - return TokenAmount(token_info, transaction.token_amount) diff --git a/alphaswarm/services/portfolio/portfolio_base.py b/alphaswarm/services/portfolio/portfolio_base.py new file mode 100644 index 00000000..063ff43e --- /dev/null +++ b/alphaswarm/services/portfolio/portfolio_base.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from abc import abstractmethod +from collections import defaultdict, deque +from dataclasses import dataclass +from decimal import Decimal +from enum import Enum, auto +from typing import Callable, Dict, Iterable, List, Optional, Sequence + +from alphaswarm.config import WalletInfo +from alphaswarm.core.token import TokenAmount, TokenInfo + + +class PnlMode(Enum): + TOTAL = auto() + REALIZED = auto() + UNREALIZED = auto() + + +class PortfolioPNL: + def __init__(self) -> None: + self._details_per_asset: Dict[str, List[PortfolioPNLDetail]] = {} + + def add_details(self, asset: str, details: Iterable[PortfolioPNLDetail]) -> None: + self._details_per_asset[asset] = list(details) + + def pnl_per_asset(self, mode: PnlMode = PnlMode.TOTAL) -> Dict[str, Decimal]: + result = {} + for asset, details in self._details_per_asset.items(): + result[asset] = sum([item.pnl for item in details if item.is_in_scope(mode)], Decimal(0)) + return result + + def pnl(self, mode: PnlMode = PnlMode.TOTAL) -> Decimal: + return sum([pnl for asset, pnl in self.pnl_per_asset(mode).items()], Decimal(0)) + + +class PortfolioPNLDetail: + def __init__(self, bought: PortfolioSwap, selling_price: Decimal, sold_amount: Decimal, is_realized: bool) -> None: + self._bought = bought + self._selling_price = selling_price + self._sold_amount = sold_amount + self._is_realized = is_realized + self._pnl = sold_amount * (self._selling_price - self.buying_price) + + @property + def buying_price(self) -> Decimal: + """Buying price per asset""" + return self._bought.sold.value / self._bought.bought.value + + @property + def sold_amount(self) -> Decimal: + return self._sold_amount + + @property + def selling_price(self) -> Decimal: + return self._selling_price + + @property + def pnl(self) -> Decimal: + return self._pnl + + @property + def is_realized(self) -> bool: + return self._is_realized + + def is_in_scope(self, mode: PnlMode) -> bool: + return ( + mode == PnlMode.TOTAL + or (mode == PnlMode.REALIZED and self._is_realized) + or (mode == PnlMode.UNREALIZED and not self._is_realized) + ) + + +class PortfolioRealizedPNLDetail(PortfolioPNLDetail): + def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, sold_amount: Decimal) -> None: + if bought.block_number > sold.block_number: + raise ValueError("bought block number is greater than sold block number") + + super().__init__(bought, sold.bought.value / sold.sold.value, sold_amount, is_realized=True) + self._sold = sold + + +class PortfolioUnrealizedPNLDetail(PortfolioPNLDetail): + def __init__(self, bought: PortfolioSwap, selling_price: Decimal, sold_amount: Decimal) -> None: + super().__init__(bought, selling_price, sold_amount, is_realized=False) + + +@dataclass +class PortfolioSwap: + sold: TokenAmount + bought: TokenAmount + hash: str + block_number: int + + def to_short_string(self) -> str: + return f"{self.sold.value} {self.sold.token_info.symbol} -> {self.bought.value} {self.bought.token_info.symbol} ({self.sold.token_info.chain} {self.block_number} {self.hash})" + + +PricingFunction = Callable[[str, str], Decimal] + + +class PortfolioBase: + def __init__(self, wallet: WalletInfo) -> None: + self._wallet = wallet + + @abstractmethod + def get_token_balances(self) -> List[TokenAmount]: + pass + + @abstractmethod + def get_swaps(self) -> List[PortfolioSwap]: + pass + + @property + def chain(self) -> str: + return self._wallet.chain + + @classmethod + def compute_pnl( + cls, positions: Sequence[PortfolioSwap], base_token: TokenInfo, pricing_function: PricingFunction + ) -> PortfolioPNL: + """Compute profit and loss (PNL) for a sequence of portfolio swaps. + + Args: + positions: Sequence of portfolio swaps to analyze + base_token: Token to use as the base currency for PNL calculations + pricing_function: Function that returns current price of an asset in terms of base token (asset_token/base_token) + + Returns: + PortfolioPNL object containing realized and unrealized PNL details + """ + items = sorted(positions, key=lambda x: x.block_number) + per_asset = defaultdict(list) + for position in items: + if position.sold.token_info.address == base_token.address: + per_asset[position.bought.token_info.address].append(position) + if position.bought.token_info.address == base_token.address: + per_asset[position.sold.token_info.address].append(position) + + result = PortfolioPNL() + for asset, swaps in per_asset.items(): + result.add_details( + asset, + cls.compute_pnl_fifo_for_pair(swaps, base_token, pricing_function(asset, base_token.address)), + ) + + return result + + @classmethod + def compute_pnl_fifo_for_pair( + cls, swaps: List[PortfolioSwap], base_token: TokenInfo, asset_price: Decimal + ) -> List[PortfolioPNLDetail]: + purchases: deque[PortfolioSwap] = deque() + bought_position: Optional[PortfolioSwap] = None + buy_remaining = Decimal(0) + result: List[PortfolioPNLDetail] = [] + for swap in swaps: + if swap.sold.token_info.address == base_token.address: + purchases.append(swap) + continue + + sell_remaining = swap.sold.value + while sell_remaining > 0: + if buy_remaining <= 0 or bought_position is None: + try: + bought_position = purchases.popleft() + except IndexError: + raise RuntimeError("Missing bought position to compute PNL") + buy_remaining = bought_position.bought.value + + sold_quantity = min(sell_remaining, buy_remaining) + result.append(PortfolioRealizedPNLDetail(bought_position, swap, sold_quantity)) + sell_remaining -= sold_quantity + buy_remaining -= sold_quantity + + if buy_remaining > 0 and bought_position is not None: + result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, buy_remaining)) + + for bought_position in purchases: + result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, bought_position.bought.value)) + + return result diff --git a/alphaswarm/services/portfolio/portfolio_evm.py b/alphaswarm/services/portfolio/portfolio_evm.py new file mode 100644 index 00000000..17dabae1 --- /dev/null +++ b/alphaswarm/services/portfolio/portfolio_evm.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import List + +from alphaswarm.config import WalletInfo +from alphaswarm.core.token import TokenAmount, TokenInfo +from alphaswarm.services.alchemy import AlchemyClient +from alphaswarm.services.alchemy.alchemy_client import Transfer +from alphaswarm.services.chains import EVMClient +from alphaswarm.services.portfolio.portfolio_base import PortfolioBase, PortfolioSwap +from web3.types import Wei + + +class PortfolioEvm(PortfolioBase): + def __init__(self, wallet: WalletInfo, evm_client: EVMClient, alchemy_client: AlchemyClient) -> None: + super().__init__(wallet) + self._evm_client = evm_client + self._alchemy_client = alchemy_client + + def get_token_balances(self) -> List[TokenAmount]: + balances = self._alchemy_client.get_token_balances(wallet=self._wallet.address, chain=self._wallet.chain) + result = [] + for balance in balances: + token_info = self._evm_client.get_token_info(EVMClient.to_checksum_address(balance.contract_address)) + result.append(token_info.to_amount_from_base_units(Wei(balance.value))) + return result + + def get_swaps(self) -> List[PortfolioSwap]: + transfer_in = self._alchemy_client.get_transfers( + wallet=self._wallet.address, chain=self._wallet.chain, incoming=True + ) + transfer_out = self._alchemy_client.get_transfers( + wallet=self._wallet.address, chain=self._wallet.chain, incoming=False + ) + map_out = {item.tx_hash: item for item in transfer_out} + + result = [] + for transfer in transfer_in: + matched_out = map_out.get(transfer.tx_hash) + if matched_out is None: + continue + result.append( + PortfolioSwap( + bought=self.transfer_to_token_amount(transfer), + sold=self.transfer_to_token_amount(matched_out), + hash=transfer.tx_hash, + block_number=transfer.block_number, + ) + ) + + return result + + def transfer_to_token_amount(self, transfer: Transfer) -> TokenAmount: + token_info = TokenInfo( + symbol=transfer.asset, + address=EVMClient.to_checksum_address(transfer.raw_contract.address), + decimals=transfer.raw_contract.decimal, + chain=self._wallet.chain, + ) + + value = transfer.value + return TokenAmount(value=value, token_info=token_info) diff --git a/alphaswarm/services/portfolio/portfolio_solana.py b/alphaswarm/services/portfolio/portfolio_solana.py new file mode 100644 index 00000000..ad0438bd --- /dev/null +++ b/alphaswarm/services/portfolio/portfolio_solana.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import List, Optional + +from alphaswarm.config import WalletInfo +from alphaswarm.core.token import TokenAmount +from alphaswarm.services.chains import SolanaClient +from alphaswarm.services.chains.solana.jupiter_client import JupiterClient +from alphaswarm.services.helius import EnhancedTransaction, HeliusClient, TokenTransfer +from alphaswarm.services.portfolio.portfolio_base import PortfolioBase, PortfolioSwap +from solders.pubkey import Pubkey +from solders.signature import Signature + + +class PortfolioSolana(PortfolioBase): + def __init__( + self, + wallet: WalletInfo, + solana_client: SolanaClient, + helius_client: HeliusClient, + jupiter_client: JupiterClient, + ) -> None: + super().__init__(wallet) + self._solana_client = solana_client + self._helius_client = helius_client + self._jupiter_client = jupiter_client + + def get_token_balances(self) -> List[TokenAmount]: + return self._solana_client.get_all_token_balances(Pubkey.from_string(self._wallet.address)) + + def get_swaps(self) -> List[PortfolioSwap]: + result = [] + before: Optional[Signature] = None + page_size = 100 + last_page = page_size + wallet = Pubkey.from_string(self._wallet.address) + + while last_page >= page_size: + signatures = self._solana_client.get_signatures_for_address(wallet, page_size, before) + if len(signatures) == 0: + break + + last_page = len(signatures) + before = signatures[-1].signature + result.extend(self._signatures_to_swaps([str(item.signature) for item in signatures])) + return result + + def _signatures_to_swaps(self, signatures: List[str]) -> List[PortfolioSwap]: + result = [] + chunk_size = 100 + for chunk in [signatures[i : i + chunk_size] for i in range(0, len(signatures), chunk_size)]: + transactions = self._helius_client.get_transactions(chunk) + for item in transactions: + swap = self._transaction_to_swap(item) + if swap is not None: + result.append(swap) + return result + + def _transaction_to_swap(self, transaction: EnhancedTransaction) -> Optional[PortfolioSwap]: + transfer_out: Optional[TokenTransfer] = next( + (item for item in transaction.token_transfers if item.from_user_account == self._wallet.address), None + ) + transfer_in: Optional[TokenTransfer] = next( + (item for item in transaction.token_transfers if item.to_user_account == self._wallet.address), None + ) + + if transfer_out is None or transfer_in is None: + return None + + return PortfolioSwap( + bought=self.transfer_to_token_amount(transfer_in), + sold=self.transfer_to_token_amount(transfer_out), + hash=transaction.signature, + block_number=transaction.slot, + ) + + def transfer_to_token_amount(self, transaction: TokenTransfer) -> TokenAmount: + token_info = self._solana_client.get_token_info(transaction.mint) + return TokenAmount(token_info, transaction.token_amount) diff --git a/tests/integration/services/portfolio/test_portfolio.py b/tests/integration/services/portfolio/test_portfolio.py index 9d722964..d5efc189 100644 --- a/tests/integration/services/portfolio/test_portfolio.py +++ b/tests/integration/services/portfolio/test_portfolio.py @@ -4,7 +4,8 @@ from alphaswarm.services.alchemy import AlchemyClient from alphaswarm.services.chains import EVMClient from alphaswarm.services.portfolio import Portfolio -from alphaswarm.services.portfolio.portfolio import PortfolioBase, PortfolioEvm +from alphaswarm.services.portfolio.portfolio_evm import PortfolioEvm +from alphaswarm.services.portfolio.portfolio_base import PortfolioBase @pytest.fixture diff --git a/tests/unit/services/portfolio/test_portfolio.py b/tests/unit/services/portfolio/test_portfolio_base.py similarity index 96% rename from tests/unit/services/portfolio/test_portfolio.py rename to tests/unit/services/portfolio/test_portfolio_base.py index 78bc7502..35bc0529 100644 --- a/tests/unit/services/portfolio/test_portfolio.py +++ b/tests/unit/services/portfolio/test_portfolio_base.py @@ -4,12 +4,7 @@ import pytest from alphaswarm.core.token import TokenAmount, TokenInfo -from alphaswarm.services.portfolio.portfolio import ( - PnlMode, - PortfolioBase, - PortfolioPNLDetail, - PortfolioSwap, -) +from alphaswarm.services.portfolio.portfolio_base import PnlMode, PortfolioBase, PortfolioPNLDetail, PortfolioSwap def create_swaps( From f039c95fc69fc40c7bbf33c5976b4b46791be6dc Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 26 Feb 2025 09:21:36 -0800 Subject: [PATCH 22/24] Update alphaswarm/services/portfolio/portfolio_base.py Co-authored-by: Arnaud Flament <17051690+aflament@users.noreply.github.com> --- alphaswarm/services/portfolio/portfolio_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alphaswarm/services/portfolio/portfolio_base.py b/alphaswarm/services/portfolio/portfolio_base.py index 063ff43e..033750f2 100644 --- a/alphaswarm/services/portfolio/portfolio_base.py +++ b/alphaswarm/services/portfolio/portfolio_base.py @@ -93,7 +93,7 @@ class PortfolioSwap: block_number: int def to_short_string(self) -> str: - return f"{self.sold.value} {self.sold.token_info.symbol} -> {self.bought.value} {self.bought.token_info.symbol} ({self.sold.token_info.chain} {self.block_number} {self.hash})" + return f"{self.sold} -> {self.bought} ({self.sold.token_info.chain} {self.block_number} {self.hash})" PricingFunction = Callable[[str, str], Decimal] From f41f5264413ef26e06c7ba033f37de265bf50815 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 26 Feb 2025 09:27:33 -0800 Subject: [PATCH 23/24] move computePNL around --- alphaswarm/services/portfolio/__init__.py | 2 +- .../services/portfolio/portfolio_base.py | 132 +++++++++--------- .../services/portfolio/portfolio_pnl.py | 0 .../services/portfolio/test_portfolio_base.py | 8 +- 4 files changed, 71 insertions(+), 71 deletions(-) create mode 100644 alphaswarm/services/portfolio/portfolio_pnl.py diff --git a/alphaswarm/services/portfolio/__init__.py b/alphaswarm/services/portfolio/__init__.py index b3ec2b28..4dca2da2 100644 --- a/alphaswarm/services/portfolio/__init__.py +++ b/alphaswarm/services/portfolio/__init__.py @@ -1,4 +1,4 @@ from .portfolio import Portfolio -from .portfolio_base import PortfolioBase, PortfolioSwap, PortfolioPNL, PnlMode +from .portfolio_base import PortfolioBase, PortfolioSwap, PortfolioPNL, PortfolioPNLDetail, PnlMode from .portfolio_evm import PortfolioEvm from .portfolio_solana import PortfolioSolana diff --git a/alphaswarm/services/portfolio/portfolio_base.py b/alphaswarm/services/portfolio/portfolio_base.py index 033750f2..31445c6e 100644 --- a/alphaswarm/services/portfolio/portfolio_base.py +++ b/alphaswarm/services/portfolio/portfolio_base.py @@ -33,6 +33,72 @@ def pnl_per_asset(self, mode: PnlMode = PnlMode.TOTAL) -> Dict[str, Decimal]: def pnl(self, mode: PnlMode = PnlMode.TOTAL) -> Decimal: return sum([pnl for asset, pnl in self.pnl_per_asset(mode).items()], Decimal(0)) + @classmethod + def compute_pnl( + cls, positions: Sequence[PortfolioSwap], base_token: TokenInfo, pricing_function: PricingFunction + ) -> PortfolioPNL: + """Compute profit and loss (PNL) for a sequence of portfolio swaps. + + Args: + positions: Sequence of portfolio swaps to analyze + base_token: Token to use as the base currency for PNL calculations + pricing_function: Function that returns current price of an asset in terms of base token (asset_token/base_token) + + Returns: + PortfolioPNL object containing realized and unrealized PNL details + """ + items = sorted(positions, key=lambda x: x.block_number) + per_asset = defaultdict(list) + for position in items: + if position.sold.token_info.address == base_token.address: + per_asset[position.bought.token_info.address].append(position) + if position.bought.token_info.address == base_token.address: + per_asset[position.sold.token_info.address].append(position) + + result = PortfolioPNL() + for asset, swaps in per_asset.items(): + result.add_details( + asset, + cls.compute_pnl_fifo_for_pair(swaps, base_token, pricing_function(asset, base_token.address)), + ) + + return result + + @classmethod + def compute_pnl_fifo_for_pair( + cls, swaps: List[PortfolioSwap], base_token: TokenInfo, asset_price: Decimal + ) -> List[PortfolioPNLDetail]: + purchases: deque[PortfolioSwap] = deque() + bought_position: Optional[PortfolioSwap] = None + buy_remaining = Decimal(0) + result: List[PortfolioPNLDetail] = [] + for swap in swaps: + if swap.sold.token_info.address == base_token.address: + purchases.append(swap) + continue + + sell_remaining = swap.sold.value + while sell_remaining > 0: + if buy_remaining <= 0 or bought_position is None: + try: + bought_position = purchases.popleft() + except IndexError: + raise RuntimeError("Missing bought position to compute PNL") + buy_remaining = bought_position.bought.value + + sold_quantity = min(sell_remaining, buy_remaining) + result.append(PortfolioRealizedPNLDetail(bought_position, swap, sold_quantity)) + sell_remaining -= sold_quantity + buy_remaining -= sold_quantity + + if buy_remaining > 0 and bought_position is not None: + result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, buy_remaining)) + + for bought_position in purchases: + result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, bought_position.bought.value)) + + return result + class PortfolioPNLDetail: def __init__(self, bought: PortfolioSwap, selling_price: Decimal, sold_amount: Decimal, is_realized: bool) -> None: @@ -114,69 +180,3 @@ def get_swaps(self) -> List[PortfolioSwap]: @property def chain(self) -> str: return self._wallet.chain - - @classmethod - def compute_pnl( - cls, positions: Sequence[PortfolioSwap], base_token: TokenInfo, pricing_function: PricingFunction - ) -> PortfolioPNL: - """Compute profit and loss (PNL) for a sequence of portfolio swaps. - - Args: - positions: Sequence of portfolio swaps to analyze - base_token: Token to use as the base currency for PNL calculations - pricing_function: Function that returns current price of an asset in terms of base token (asset_token/base_token) - - Returns: - PortfolioPNL object containing realized and unrealized PNL details - """ - items = sorted(positions, key=lambda x: x.block_number) - per_asset = defaultdict(list) - for position in items: - if position.sold.token_info.address == base_token.address: - per_asset[position.bought.token_info.address].append(position) - if position.bought.token_info.address == base_token.address: - per_asset[position.sold.token_info.address].append(position) - - result = PortfolioPNL() - for asset, swaps in per_asset.items(): - result.add_details( - asset, - cls.compute_pnl_fifo_for_pair(swaps, base_token, pricing_function(asset, base_token.address)), - ) - - return result - - @classmethod - def compute_pnl_fifo_for_pair( - cls, swaps: List[PortfolioSwap], base_token: TokenInfo, asset_price: Decimal - ) -> List[PortfolioPNLDetail]: - purchases: deque[PortfolioSwap] = deque() - bought_position: Optional[PortfolioSwap] = None - buy_remaining = Decimal(0) - result: List[PortfolioPNLDetail] = [] - for swap in swaps: - if swap.sold.token_info.address == base_token.address: - purchases.append(swap) - continue - - sell_remaining = swap.sold.value - while sell_remaining > 0: - if buy_remaining <= 0 or bought_position is None: - try: - bought_position = purchases.popleft() - except IndexError: - raise RuntimeError("Missing bought position to compute PNL") - buy_remaining = bought_position.bought.value - - sold_quantity = min(sell_remaining, buy_remaining) - result.append(PortfolioRealizedPNLDetail(bought_position, swap, sold_quantity)) - sell_remaining -= sold_quantity - buy_remaining -= sold_quantity - - if buy_remaining > 0 and bought_position is not None: - result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, buy_remaining)) - - for bought_position in purchases: - result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, bought_position.bought.value)) - - return result diff --git a/alphaswarm/services/portfolio/portfolio_pnl.py b/alphaswarm/services/portfolio/portfolio_pnl.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/services/portfolio/test_portfolio_base.py b/tests/unit/services/portfolio/test_portfolio_base.py index 35bc0529..6388c08a 100644 --- a/tests/unit/services/portfolio/test_portfolio_base.py +++ b/tests/unit/services/portfolio/test_portfolio_base.py @@ -4,7 +4,7 @@ import pytest from alphaswarm.core.token import TokenAmount, TokenInfo -from alphaswarm.services.portfolio.portfolio_base import PnlMode, PortfolioBase, PortfolioPNLDetail, PortfolioSwap +from alphaswarm.services.portfolio import PnlMode, PortfolioPNL, PortfolioPNLDetail, PortfolioSwap def create_swaps( @@ -61,7 +61,7 @@ def test_portfolio_compute_pnl_fifo_one_asset__sell_from_first_swap(weth: TokenI ] ) - pnl = PortfolioBase.compute_pnl(positions, weth, lambda asset, base: Decimal(1)) + pnl = PortfolioPNL.compute_pnl(positions, weth, lambda asset, base: Decimal(1)) usdc_pnl = iter(pnl._details_per_asset[usdc.address]) assert_detail(next(usdc_pnl), sold_amount=5, buying_price="0.1", selling_price="0.4", pnl="1.5", realized=True) @@ -85,7 +85,7 @@ def test_portfolio_compute_pnl_fifo_one_asset__sell_from_multiple_swaps(weth: To ] ) - pnl = PortfolioBase.compute_pnl(positions, weth, lambda asset, base: Decimal(1)) + pnl = PortfolioPNL.compute_pnl(positions, weth, lambda asset, base: Decimal(1)) usdc_pnl = iter(pnl._details_per_asset[usdc.address]) assert_detail(next(usdc_pnl), sold_amount=5, buying_price="0.1", selling_price=".15", pnl=".25", realized=True) assert_detail(next(usdc_pnl), sold_amount=5, buying_price="0.1", selling_price="1", pnl="4.5", realized=True) @@ -107,4 +107,4 @@ def test_portoflio_compute_pnl__bought_exhausted_raise_exception(weth: TokenInfo ) with pytest.raises(RuntimeError): - PortfolioBase.compute_pnl(positions, weth, lambda asset, base: Decimal(1)) + PortfolioPNL.compute_pnl(positions, weth, lambda asset, base: Decimal(1)) From 67b2d5e2d2445c1757964b3474de966439e7de88 Mon Sep 17 00:00:00 2001 From: Guillaume Koch <39165367+gkoch78@users.noreply.github.com> Date: Wed, 26 Feb 2025 09:31:17 -0800 Subject: [PATCH 24/24] extract `portfolio.pnl` --- alphaswarm/services/portfolio/__init__.py | 3 +- .../services/portfolio/portfolio_base.py | 150 +---------------- .../services/portfolio/portfolio_pnl.py | 153 ++++++++++++++++++ ...ortfolio_base.py => test_portfolio_pnl.py} | 0 4 files changed, 157 insertions(+), 149 deletions(-) rename tests/unit/services/portfolio/{test_portfolio_base.py => test_portfolio_pnl.py} (100%) diff --git a/alphaswarm/services/portfolio/__init__.py b/alphaswarm/services/portfolio/__init__.py index 4dca2da2..01fd7f8e 100644 --- a/alphaswarm/services/portfolio/__init__.py +++ b/alphaswarm/services/portfolio/__init__.py @@ -1,4 +1,5 @@ from .portfolio import Portfolio -from .portfolio_base import PortfolioBase, PortfolioSwap, PortfolioPNL, PortfolioPNLDetail, PnlMode +from .portfolio_base import PortfolioBase, PortfolioSwap from .portfolio_evm import PortfolioEvm +from .portfolio_pnl import PortfolioPNL, PortfolioPNLDetail, PnlMode from .portfolio_solana import PortfolioSolana diff --git a/alphaswarm/services/portfolio/portfolio_base.py b/alphaswarm/services/portfolio/portfolio_base.py index 31445c6e..ba7363f5 100644 --- a/alphaswarm/services/portfolio/portfolio_base.py +++ b/alphaswarm/services/portfolio/portfolio_base.py @@ -1,154 +1,11 @@ from __future__ import annotations from abc import abstractmethod -from collections import defaultdict, deque from dataclasses import dataclass -from decimal import Decimal -from enum import Enum, auto -from typing import Callable, Dict, Iterable, List, Optional, Sequence +from typing import List from alphaswarm.config import WalletInfo -from alphaswarm.core.token import TokenAmount, TokenInfo - - -class PnlMode(Enum): - TOTAL = auto() - REALIZED = auto() - UNREALIZED = auto() - - -class PortfolioPNL: - def __init__(self) -> None: - self._details_per_asset: Dict[str, List[PortfolioPNLDetail]] = {} - - def add_details(self, asset: str, details: Iterable[PortfolioPNLDetail]) -> None: - self._details_per_asset[asset] = list(details) - - def pnl_per_asset(self, mode: PnlMode = PnlMode.TOTAL) -> Dict[str, Decimal]: - result = {} - for asset, details in self._details_per_asset.items(): - result[asset] = sum([item.pnl for item in details if item.is_in_scope(mode)], Decimal(0)) - return result - - def pnl(self, mode: PnlMode = PnlMode.TOTAL) -> Decimal: - return sum([pnl for asset, pnl in self.pnl_per_asset(mode).items()], Decimal(0)) - - @classmethod - def compute_pnl( - cls, positions: Sequence[PortfolioSwap], base_token: TokenInfo, pricing_function: PricingFunction - ) -> PortfolioPNL: - """Compute profit and loss (PNL) for a sequence of portfolio swaps. - - Args: - positions: Sequence of portfolio swaps to analyze - base_token: Token to use as the base currency for PNL calculations - pricing_function: Function that returns current price of an asset in terms of base token (asset_token/base_token) - - Returns: - PortfolioPNL object containing realized and unrealized PNL details - """ - items = sorted(positions, key=lambda x: x.block_number) - per_asset = defaultdict(list) - for position in items: - if position.sold.token_info.address == base_token.address: - per_asset[position.bought.token_info.address].append(position) - if position.bought.token_info.address == base_token.address: - per_asset[position.sold.token_info.address].append(position) - - result = PortfolioPNL() - for asset, swaps in per_asset.items(): - result.add_details( - asset, - cls.compute_pnl_fifo_for_pair(swaps, base_token, pricing_function(asset, base_token.address)), - ) - - return result - - @classmethod - def compute_pnl_fifo_for_pair( - cls, swaps: List[PortfolioSwap], base_token: TokenInfo, asset_price: Decimal - ) -> List[PortfolioPNLDetail]: - purchases: deque[PortfolioSwap] = deque() - bought_position: Optional[PortfolioSwap] = None - buy_remaining = Decimal(0) - result: List[PortfolioPNLDetail] = [] - for swap in swaps: - if swap.sold.token_info.address == base_token.address: - purchases.append(swap) - continue - - sell_remaining = swap.sold.value - while sell_remaining > 0: - if buy_remaining <= 0 or bought_position is None: - try: - bought_position = purchases.popleft() - except IndexError: - raise RuntimeError("Missing bought position to compute PNL") - buy_remaining = bought_position.bought.value - - sold_quantity = min(sell_remaining, buy_remaining) - result.append(PortfolioRealizedPNLDetail(bought_position, swap, sold_quantity)) - sell_remaining -= sold_quantity - buy_remaining -= sold_quantity - - if buy_remaining > 0 and bought_position is not None: - result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, buy_remaining)) - - for bought_position in purchases: - result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, bought_position.bought.value)) - - return result - - -class PortfolioPNLDetail: - def __init__(self, bought: PortfolioSwap, selling_price: Decimal, sold_amount: Decimal, is_realized: bool) -> None: - self._bought = bought - self._selling_price = selling_price - self._sold_amount = sold_amount - self._is_realized = is_realized - self._pnl = sold_amount * (self._selling_price - self.buying_price) - - @property - def buying_price(self) -> Decimal: - """Buying price per asset""" - return self._bought.sold.value / self._bought.bought.value - - @property - def sold_amount(self) -> Decimal: - return self._sold_amount - - @property - def selling_price(self) -> Decimal: - return self._selling_price - - @property - def pnl(self) -> Decimal: - return self._pnl - - @property - def is_realized(self) -> bool: - return self._is_realized - - def is_in_scope(self, mode: PnlMode) -> bool: - return ( - mode == PnlMode.TOTAL - or (mode == PnlMode.REALIZED and self._is_realized) - or (mode == PnlMode.UNREALIZED and not self._is_realized) - ) - - -class PortfolioRealizedPNLDetail(PortfolioPNLDetail): - def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, sold_amount: Decimal) -> None: - if bought.block_number > sold.block_number: - raise ValueError("bought block number is greater than sold block number") - - super().__init__(bought, sold.bought.value / sold.sold.value, sold_amount, is_realized=True) - self._sold = sold - - -class PortfolioUnrealizedPNLDetail(PortfolioPNLDetail): - def __init__(self, bought: PortfolioSwap, selling_price: Decimal, sold_amount: Decimal) -> None: - super().__init__(bought, selling_price, sold_amount, is_realized=False) +from alphaswarm.core.token import TokenAmount @dataclass @@ -162,9 +19,6 @@ def to_short_string(self) -> str: return f"{self.sold} -> {self.bought} ({self.sold.token_info.chain} {self.block_number} {self.hash})" -PricingFunction = Callable[[str, str], Decimal] - - class PortfolioBase: def __init__(self, wallet: WalletInfo) -> None: self._wallet = wallet diff --git a/alphaswarm/services/portfolio/portfolio_pnl.py b/alphaswarm/services/portfolio/portfolio_pnl.py index e69de29b..7b76cde1 100644 --- a/alphaswarm/services/portfolio/portfolio_pnl.py +++ b/alphaswarm/services/portfolio/portfolio_pnl.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from collections import defaultdict, deque +from decimal import Decimal +from enum import Enum, auto +from typing import Callable, Dict, Iterable, List, Optional, Sequence + +from alphaswarm.core.token import TokenInfo + +from .portfolio_base import PortfolioSwap + + +class PnlMode(Enum): + TOTAL = auto() + REALIZED = auto() + UNREALIZED = auto() + + +class PortfolioPNL: + def __init__(self) -> None: + self._details_per_asset: Dict[str, List[PortfolioPNLDetail]] = {} + + def add_details(self, asset: str, details: Iterable[PortfolioPNLDetail]) -> None: + self._details_per_asset[asset] = list(details) + + def pnl_per_asset(self, mode: PnlMode = PnlMode.TOTAL) -> Dict[str, Decimal]: + result = {} + for asset, details in self._details_per_asset.items(): + result[asset] = sum([item.pnl for item in details if item.is_in_scope(mode)], Decimal(0)) + return result + + def pnl(self, mode: PnlMode = PnlMode.TOTAL) -> Decimal: + return sum([pnl for asset, pnl in self.pnl_per_asset(mode).items()], Decimal(0)) + + @classmethod + def compute_pnl( + cls, positions: Sequence[PortfolioSwap], base_token: TokenInfo, pricing_function: PricingFunction + ) -> PortfolioPNL: + """Compute profit and loss (PNL) for a sequence of portfolio swaps. + + Args: + positions: Sequence of portfolio swaps to analyze + base_token: Token to use as the base currency for PNL calculations + pricing_function: Function that returns current price of an asset in terms of base token (asset_token/base_token) + + Returns: + PortfolioPNL object containing realized and unrealized PNL details + """ + items = sorted(positions, key=lambda x: x.block_number) + per_asset = defaultdict(list) + for position in items: + if position.sold.token_info.address == base_token.address: + per_asset[position.bought.token_info.address].append(position) + if position.bought.token_info.address == base_token.address: + per_asset[position.sold.token_info.address].append(position) + + result = PortfolioPNL() + for asset, swaps in per_asset.items(): + result.add_details( + asset, + cls.compute_pnl_fifo_for_pair(swaps, base_token, pricing_function(asset, base_token.address)), + ) + + return result + + @classmethod + def compute_pnl_fifo_for_pair( + cls, swaps: List[PortfolioSwap], base_token: TokenInfo, asset_price: Decimal + ) -> List[PortfolioPNLDetail]: + purchases: deque[PortfolioSwap] = deque() + bought_position: Optional[PortfolioSwap] = None + buy_remaining = Decimal(0) + result: List[PortfolioPNLDetail] = [] + for swap in swaps: + if swap.sold.token_info.address == base_token.address: + purchases.append(swap) + continue + + sell_remaining = swap.sold.value + while sell_remaining > 0: + if buy_remaining <= 0 or bought_position is None: + try: + bought_position = purchases.popleft() + except IndexError: + raise RuntimeError("Missing bought position to compute PNL") + buy_remaining = bought_position.bought.value + + sold_quantity = min(sell_remaining, buy_remaining) + result.append(PortfolioRealizedPNLDetail(bought_position, swap, sold_quantity)) + sell_remaining -= sold_quantity + buy_remaining -= sold_quantity + + if buy_remaining > 0 and bought_position is not None: + result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, buy_remaining)) + + for bought_position in purchases: + result.append(PortfolioUnrealizedPNLDetail(bought_position, asset_price, bought_position.bought.value)) + + return result + + +class PortfolioPNLDetail: + def __init__(self, bought: PortfolioSwap, selling_price: Decimal, sold_amount: Decimal, is_realized: bool) -> None: + self._bought = bought + self._selling_price = selling_price + self._sold_amount = sold_amount + self._is_realized = is_realized + self._pnl = sold_amount * (self._selling_price - self.buying_price) + + @property + def buying_price(self) -> Decimal: + """Buying price per asset""" + return self._bought.sold.value / self._bought.bought.value + + @property + def sold_amount(self) -> Decimal: + return self._sold_amount + + @property + def selling_price(self) -> Decimal: + return self._selling_price + + @property + def pnl(self) -> Decimal: + return self._pnl + + @property + def is_realized(self) -> bool: + return self._is_realized + + def is_in_scope(self, mode: PnlMode) -> bool: + return ( + mode == PnlMode.TOTAL + or (mode == PnlMode.REALIZED and self._is_realized) + or (mode == PnlMode.UNREALIZED and not self._is_realized) + ) + + +class PortfolioRealizedPNLDetail(PortfolioPNLDetail): + def __init__(self, bought: PortfolioSwap, sold: PortfolioSwap, sold_amount: Decimal) -> None: + if bought.block_number > sold.block_number: + raise ValueError("bought block number is greater than sold block number") + + super().__init__(bought, sold.bought.value / sold.sold.value, sold_amount, is_realized=True) + self._sold = sold + + +class PortfolioUnrealizedPNLDetail(PortfolioPNLDetail): + def __init__(self, bought: PortfolioSwap, selling_price: Decimal, sold_amount: Decimal) -> None: + super().__init__(bought, selling_price, sold_amount, is_realized=False) + + +PricingFunction = Callable[[str, str], Decimal] diff --git a/tests/unit/services/portfolio/test_portfolio_base.py b/tests/unit/services/portfolio/test_portfolio_pnl.py similarity index 100% rename from tests/unit/services/portfolio/test_portfolio_base.py rename to tests/unit/services/portfolio/test_portfolio_pnl.py