diff --git a/.env.example b/.env.example index acd553e..2166160 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ SOLANA_RPC_URL=https://api.mainnet-beta.solana.com # Alchemy Configuration ALCHEMY_API_KEY= +# HELIUS API for Solana +HELIUS_API_KEY= + # Cookie.fun Configuration COOKIE_FUN_API_KEY= diff --git a/alphaswarm/config.py b/alphaswarm/config.py index 0b416af..bcc6652 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/alphaswarm/services/alchemy/alchemy_client.py b/alphaswarm/services/alchemy/alchemy_client.py index f6a0a5a..b6bae66 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 @@ -34,6 +35,17 @@ class Metadata(BaseModel): 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) + + class Transfer(BaseModel): """Represents a token transfer transaction. @@ -57,6 +69,7 @@ class Transfer(BaseModel): 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/chains/solana/solana_client.py b/alphaswarm/services/chains/solana/solana_client.py index 7bcee38..6b42c75 100644 --- a/alphaswarm/services/chains/solana/solana_client.py +++ b/alphaswarm/services/chains/solana/solana_client.py @@ -8,12 +8,13 @@ from alphaswarm.services.chains.solana.jupiter_client import JupiterClient from pydantic import BaseModel, Field 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 +162,12 @@ 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 diff --git a/alphaswarm/services/helius/__init__.py b/alphaswarm/services/helius/__init__.py new file mode 100644 index 0000000..a29a350 --- /dev/null +++ b/alphaswarm/services/helius/__init__.py @@ -0,0 +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 0000000..c69edf6 --- /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 new file mode 100644 index 0000000..859ac67 --- /dev/null +++ b/alphaswarm/services/helius/helius_client.py @@ -0,0 +1,47 @@ +import os +from typing import Dict, Final, List, Self, Sequence + +import requests +from alphaswarm.services import ApiException + +from .data import EnhancedTransaction, SignatureResult + + +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/__init__.py b/alphaswarm/services/portfolio/__init__.py index 4263bb1..01fd7f8 100644 --- a/alphaswarm/services/portfolio/__init__.py +++ b/alphaswarm/services/portfolio/__init__.py @@ -1 +1,5 @@ from .portfolio import Portfolio +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.py b/alphaswarm/services/portfolio/portfolio.py index ebde6ac..c9c0c46 100644 --- a/alphaswarm/services/portfolio/portfolio.py +++ b/alphaswarm/services/portfolio/portfolio.py @@ -1,30 +1,18 @@ from __future__ import annotations -from abc import abstractmethod from datetime import UTC, datetime from decimal import Decimal from typing import Dict, Iterable, List, Optional, Self -from solders.pubkey import Pubkey -from web3.types import Wei - -from ...config import Config, WalletInfo +from ...config import ChainConfig, Config, WalletInfo from ...core.token import TokenAmount from ..alchemy import AlchemyClient from ..chains import EVMClient, SolanaClient - - -class PortfolioBase: - def __init__(self, wallet: WalletInfo) -> None: - self._wallet = wallet - - @abstractmethod - def get_token_balances(self) -> List[TokenAmount]: - pass - - @property - def chain(self) -> str: - return self._wallet.chain +from ..chains.solana.jupiter_client import JupiterClient +from ..helius import HeliusClient +from .portfolio_base import PortfolioBase +from .portfolio_evm import PortfolioEvm +from .portfolio_solana import PortfolioSolana class PortfolioBalance: @@ -104,34 +92,14 @@ 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(config.get_chain_config(chain))) return cls(portfolios) - -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 - - -class PortfolioSolana(PortfolioBase): - def __init__(self, wallet: WalletInfo, solana_client: SolanaClient) -> None: - super().__init__(wallet) - self._solana_client = solana_client - - def get_token_balances(self) -> List[TokenAmount]: - return self._solana_client.get_all_token_balances(Pubkey.from_string(self._wallet.address)) + @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}") diff --git a/alphaswarm/services/portfolio/portfolio_base.py b/alphaswarm/services/portfolio/portfolio_base.py new file mode 100644 index 0000000..ba7363f --- /dev/null +++ b/alphaswarm/services/portfolio/portfolio_base.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from abc import abstractmethod +from dataclasses import dataclass +from typing import List + +from alphaswarm.config import WalletInfo +from alphaswarm.core.token import TokenAmount + + +@dataclass +class PortfolioSwap: + sold: TokenAmount + bought: TokenAmount + hash: str + block_number: int + + def to_short_string(self) -> str: + return f"{self.sold} -> {self.bought} ({self.sold.token_info.chain} {self.block_number} {self.hash})" + + +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 diff --git a/alphaswarm/services/portfolio/portfolio_evm.py b/alphaswarm/services/portfolio/portfolio_evm.py new file mode 100644 index 0000000..17dabae --- /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_pnl.py b/alphaswarm/services/portfolio/portfolio_pnl.py new file mode 100644 index 0000000..7b76cde --- /dev/null +++ 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/alphaswarm/services/portfolio/portfolio_solana.py b/alphaswarm/services/portfolio/portfolio_solana.py new file mode 100644 index 0000000..ad0438b --- /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/chains/sol/test_solana_client.py b/tests/integration/services/chains/sol/test_solana_client.py index dd5b11b..12502f9 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 0000000..e69de29 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 0000000..71a3111 --- /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 fc496af..d5efc18 100644 --- a/tests/integration/services/portfolio/test_portfolio.py +++ b/tests/integration/services/portfolio/test_portfolio.py @@ -1,8 +1,27 @@ import pytest -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_evm import PortfolioEvm +from alphaswarm.services.portfolio.portfolio_base import PortfolioBase + + +@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 portfolio(chain: str, default_config: Config, alchemy_client: AlchemyClient) -> PortfolioBase: + chain_config = default_config.get_chain_config(chain) + return Portfolio.from_chain(chain_config) @pytest.mark.skip("Need wallet") @@ -10,3 +29,14 @@ def test_portfolio_get_balances(default_config: Config, alchemy_client: AlchemyC portfolio = Portfolio.from_config(default_config) result = portfolio.get_token_balances() assert len(result.get_non_zero_balances()) > 3 + + +chains = ["ethereum", "ethereum_sepolia", "base", "solana"] + + +@pytest.mark.parametrize("chain", chains) +@pytest.mark.skip("Need wallet") +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()) diff --git a/tests/unit/services/portfolio/test_portfolio_pnl.py b/tests/unit/services/portfolio/test_portfolio_pnl.py new file mode 100644 index 0000000..6388c08 --- /dev/null +++ b/tests/unit/services/portfolio/test_portfolio_pnl.py @@ -0,0 +1,110 @@ +from decimal import Decimal +from typing import List, Tuple, Union + +import pytest + +from alphaswarm.core.token import TokenAmount, TokenInfo +from alphaswarm.services.portfolio import PnlMode, PortfolioPNL, PortfolioPNLDetail, PortfolioSwap + + +def create_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 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: + positions = create_swaps( + [ + (1, weth, 10, usdc), + (5, usdc, 2, weth), + (1, weth, 8, usdc), + (2, usdc, 2, weth), + ] + ) + + 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) + 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(PnlMode.REALIZED) == Decimal("3.3") + assert pnl.pnl(PnlMode.UNREALIZED) == 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: + 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 = 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) + 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(PnlMode.REALIZED) == Decimal("5.78") + assert pnl.pnl(PnlMode.UNREALIZED) == Decimal(0) + assert pnl.pnl() == Decimal("5.78") + + +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): + PortfolioPNL.compute_pnl(positions, weth, lambda asset, base: Decimal(1)) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 20d5532..cd2f48d 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -118,3 +118,9 @@ 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"}