Infrastructure·Compliance Reports·Intermediate

Tax Report Generator

Generate comprehensive cryptocurrency tax liability reports calculating realized capital gains and losses using FIFO, LIFO, and specific identification cost basis accounting methods with support for multiple tax jurisdictions, holding period classifications, and wash sale rule compliance.

complianceinfrastructure

Compliance & Audit: Generate Crypto Tax Report

Crypto tax reporting can be a complex process due to the volatile nature of cryptocurrencies, the variety of transaction types (trading, staking, mining, airdrops, etc.), and differing tax regulations across jurisdictions. This notebook aims to provide a framework for generating a basic crypto tax report, focusing on common methodologies for calculating capital gains and losses.

Key Concepts in Crypto Tax Reporting

ConceptDescription
Capital Gains/LossesThe profit or loss realized when selling or disposing of a cryptocurrency for more or less than its cost basis. Taxable events typically include selling crypto for fiat, trading crypto for crypto, or using crypto to buy goods/services.
Cost BasisThe original value of an asset for tax purposes. It includes the purchase price plus any commissions or fees. Various methods (FIFO, LIFO, HIFO, Specific Identification) determine which assets are considered 'sold'.
Taxable EventsActions that trigger a tax obligation, such as selling crypto, trading crypto, receiving crypto as income (mining, staking rewards, airdrops), or spending crypto on goods/services.
Holding PeriodThe length of time an asset is held. Short-term (typically less than a year) and long-term (a year or more) capital gains are taxed at different rates.
Wash Sale RuleA rule that prevents investors from selling an investment at a loss and immediately repurchasing it to claim a tax deduction. While currently not directly applicable to crypto in all jurisdictions (e.g., US), it's a critical consideration in traditional finance and could evolve for crypto.
Record KeepingMaintaining accurate and comprehensive records of all crypto transactions (purchase dates, prices, quantities, transaction IDs, fees, fiat value at time of transaction) is crucial for accurate reporting.

Dependency Installation

This section installs all necessary Python packages. Ensure you run this cell to set up your environment.

[4]
# Install necessary packages
%pip install pandas numpy requests tenacity loguru
Requirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2)
Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2)
Requirement already satisfied: requests in /usr/local/lib/python3.12/dist-packages (2.32.4)
Requirement already satisfied: tenacity in /usr/local/lib/python3.12/dist-packages (9.1.4)
Collecting loguru
  Downloading loguru-0.7.3-py3-none-any.whl.metadata (22 kB)
Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0)
Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2)
Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.2)
Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.12/dist-packages (from requests) (3.4.7)
Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.12/dist-packages (from requests) (3.18)
Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.12/dist-packages (from requests) (2.5.0)
Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.12/dist-packages (from requests) (2026.5.20)
Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)
Downloading loguru-0.7.3-py3-none-any.whl (61 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 61.6/61.6 kB 4.2 MB/s eta 0:00:00
[?25hInstalling collected packages: loguru
Successfully installed loguru-0.7.3

Library Imports

This section imports all required Python libraries. Standard libraries are imported first, followed by third-party libraries.

[5]
# Standard library imports
import json
import os
import time
from collections import deque
from datetime import datetime, timedelta
import logging

# Third-party library imports
import pandas as pd
import numpy as np
import requests
from tenacity import retry, wait_exponential, stop_after_attempt, after_log
from loguru import logger

# Configure loguru to integrate with standard logging
logger.remove()
logger.add(lambda msg: logging.getLogger(__name__).info(msg.strip()), level="INFO", colorize=False, format="{time} {level} {message}")
logger.add(lambda msg: logging.getLogger(__name__).debug(msg.strip()), level="DEBUG", colorize=False, format="{time} {level} {message}")
logger.add(lambda msg: logging.getLogger(__name__).warning(msg.strip()), level="WARNING", colorize=False, format="{time} {level} {message}")

# Basic logging configuration for standard logging module
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

Core Functions

This section defines the core functions used for processing and analyzing cryptocurrency transactions for tax reporting. Each function is presented in a separate block with detailed explanations, type hints, and logging.

Function Name: create_transaction_state

This function initializes a dictionary that represents the state for managing cryptocurrency transaction data. It sets up empty DataFrames for raw transactions, processed transactions, and a dictionary to store API keys or configurations. This provides a clean, organized starting point for all subsequent operations.

Algorithm:

  1. Initialize an empty pandas DataFrame for raw_transactions with predefined columns such as timestamp, transaction_type, from_currency, from_amount, to_currency, to_amount, fee_currency, fee_amount, and description.
  2. Initialize another empty pandas DataFrame for processed_transactions with columns relevant for tax calculations, including timestamp, transaction_type, asset, amount, fiat_value, cost_basis, gain_loss, holding_period, taxable_event, and description.
  3. Initialize an empty dictionary for api_configs to store any necessary API keys or configuration parameters.
  4. Log the creation of the initial state.

Parameters:

  • initial_data (dict, optional): A dictionary containing initial data to populate the state, defaults to an empty dictionary.

Returns:

  • dict: An initialized state dictionary containing raw_transactions, processed_transactions, and api_configs DataFrames/dictionaries.
[6]
def create_transaction_state(initial_data: dict = None) -> dict:
    """
    Initializes a dictionary to manage the state of cryptocurrency transaction data.

    This function sets up empty pandas DataFrames for raw and processed transactions,
    and an empty dictionary for API configurations, providing a structured state
    for subsequent data processing and tax calculations.

    Parameters
    ----------
    initial_data : dict, optional
        A dictionary containing initial data to populate the state. Not currently used
        but provided for future extensibility, defaults to None.

    Returns
    -------
    dict
        An initialized state dictionary with keys 'raw_transactions',
        'processed_transactions', and 'api_configs'.

    Examples
    --------
    >>> state = create_transaction_state()
    >>> isinstance(state, dict)
    True
    >>> 'raw_transactions' in state
    True
    >>> state['raw_transactions'].empty
    True
    """
    logger.info("Initializing transaction state.")

    # Define columns for raw transactions
    raw_tx_cols = [
        'timestamp', 'transaction_type', 'from_currency', 'from_amount',
        'to_currency', 'to_amount', 'fee_currency', 'fee_amount', 'description'
    ]

    # Define columns for processed transactions for tax calculations
    processed_tx_cols = [
        'timestamp', 'transaction_type', 'asset', 'amount', 'fiat_value',
        'cost_basis', 'gain_loss', 'holding_period', 'taxable_event', 'description'
    ]

    state = {
        'raw_transactions': pd.DataFrame(columns=raw_tx_cols),
        'processed_transactions': pd.DataFrame(columns=processed_tx_cols),
        'api_configs': {},
        'asset_holdings': pd.DataFrame(columns=['asset', 'quantity', 'cost_basis_per_unit', 'total_cost_basis'])
    }

    if initial_data:
        logger.debug(f"Initial data provided: {list(initial_data.keys())}")
        # Example of how initial_data might be used (can be expanded)
        if 'api_configs' in initial_data:
            state['api_configs'].update(initial_data['api_configs'])

    logger.info("Transaction state initialized successfully.")
    return state

Function Name: add_raw_transaction

This function adds a single raw cryptocurrency transaction to the raw_transactions DataFrame within the provided state dictionary. It ensures that the transaction data is properly formatted and includes all necessary fields. This is a crucial step for populating the system with initial data before any processing or tax calculations can occur.

Algorithm:

  1. Validate that the input transaction dictionary contains all required keys.
  2. Convert the timestamp to a datetime object if it's not already.
  3. Create a pandas Series from the transaction dictionary.
  4. Append the new Series as a new row to the raw_transactions DataFrame in the state.
  5. Log the successful addition of the transaction.

Parameters:

  • state (dict): The current state dictionary containing the raw_transactions DataFrame.
  • transaction (dict): A dictionary representing a single transaction with keys like timestamp, transaction_type, from_currency, from_amount, to_currency, to_amount, fee_currency, fee_amount, description.

Returns:

  • dict: The updated state dictionary with the new transaction added to raw_transactions.

Raises:

  • ValueError: If a required key is missing in the transaction data.
[7]
def add_raw_transaction(state: dict, transaction: dict) -> dict:
    """
    Adds a single raw cryptocurrency transaction to the state's raw_transactions DataFrame.

    Parameters
    ----------
    state : dict
        The current state dictionary, which must contain a 'raw_transactions' DataFrame.
    transaction : dict
        A dictionary representing a single transaction with the following keys:
        - 'timestamp' (str or datetime): The time of the transaction.
        - 'transaction_type' (str): Type of transaction (e.g., 'BUY', 'SELL', 'TRANSFER', 'SWAP').
        - 'from_currency' (str): Currency sent (e.g., 'BTC', 'USD').
        - 'from_amount' (float): Amount of from_currency.
        - 'to_currency' (str): Currency received (e.g., 'ETH', 'USD').
        - 'to_amount' (float): Amount of to_currency.
        - 'fee_currency' (str, optional): Currency used for fees.
        - 'fee_amount' (float, optional): Amount of fees.
        - 'description' (str, optional): A brief description of the transaction.

    Returns
    -------
    dict
        The updated state dictionary with the new transaction added.

    Raises
    ------
    ValueError
        If a required key is missing in the transaction dictionary.

    Examples
    --------
    >>> state = create_transaction_state()
    >>> tx = {
    ...     'timestamp': '2023-01-01 10:00:00',
    ...     'transaction_type': 'BUY',
    ...     'from_currency': 'USD',
    ...     'from_amount': 100.0,
    ...     'to_currency': 'BTC',
    ...     'to_amount': 0.005,
    ...     'fee_currency': 'USD',
    ...     'fee_amount': 1.0,
    ...     'description': 'Buy BTC from exchange'
    ... }
    >>> updated_state = add_raw_transaction(state, tx)
    >>> len(updated_state['raw_transactions']) == 1
    True
    """
    logger.info(f"Attempting to add raw transaction: {transaction.get('description', 'No description')}")

    required_keys = [
        'timestamp', 'transaction_type', 'from_currency', 'from_amount',
        'to_currency', 'to_amount'
    ]

    for key in required_keys:
        if key not in transaction:
            logger.error(f"Missing required key in transaction: {key}")
            raise ValueError(f"Transaction dictionary is missing required key: {key}")

    # Convert timestamp to datetime object
    if isinstance(transaction['timestamp'], str):
        try:
            transaction['timestamp'] = pd.to_datetime(transaction['timestamp'])
        except ValueError as e:
            logger.error(f"Invalid timestamp format: {transaction['timestamp']}. Error: {e}")
            raise ValueError(f"Invalid timestamp format: {transaction['timestamp']}") from e

    # Ensure fees are present, default to 0 if not provided
    transaction.setdefault('fee_currency', None)
    transaction.setdefault('fee_amount', 0.0)
    transaction.setdefault('description', '')

    # Use pd.concat for appending, as .loc is not suitable for adding new rows to empty DataFrames
    # and .append is deprecated.
    new_df = pd.DataFrame([transaction])
    state['raw_transactions'] = pd.concat([state['raw_transactions'], new_df], ignore_index=True)

    logger.info(f"Raw transaction added successfully. Total raw transactions: {len(state['raw_transactions'])}")
    return state

Function Name: fetch_crypto_price_history

This function is responsible for fetching historical price data for a given cryptocurrency from an external API (e.g., CoinGecko). It uses a robust retry mechanism with exponential backoff to handle transient network issues or API rate limits. The fetched prices are crucial for determining the fiat value of transactions at the time they occurred.

Algorithm:

  1. Construct the API endpoint URL for CoinGecko's historical price data, specifying the cryptocurrency ID, target currency (e.g., USD), and date range.
  2. Use @retry decorator from tenacity to implement exponential backoff and retry up to a specified number of attempts for API calls.
  3. Make an HTTP GET request to the CoinGecko API.
  4. Parse the JSON response and extract the daily price data.
  5. Convert the price data into a pandas DataFrame with timestamp and price columns.
  6. Handle potential API errors (e.g., invalid currency, rate limits).
  7. Log the success or failure of the API call.

Parameters:

  • state (dict): The current state dictionary (used for potential API key storage or configuration, though not directly used in this basic implementation).
  • crypto_id (str): The CoinGecko ID of the cryptocurrency (e.g., 'bitcoin', 'ethereum').
  • vs_currency (str): The target currency for the price (e.g., 'usd', 'eur').
  • start_date (str or datetime): The start date for fetching prices (e.g., 'YYYY-MM-DD').
  • end_date (str or datetime): The end date for fetching prices (e.g., 'YYYY-MM-DD').

Returns:

  • pd.DataFrame: A DataFrame containing historical prices with 'timestamp' and 'price' columns, or an empty DataFrame if data retrieval fails.

Raises:

  • requests.exceptions.RequestException: If there's a persistent issue with the API request after all retries.
  • ValueError: If the API response is malformed.
[8]
COINGECKO_API_BASE = "https://api.coingecko.com/api/v3"

def _is_api_error(response):
    """Helper to check if the response indicates an API error that should be retried."""
    return response.status_code in [429, 500, 502, 503, 504]

@retry(
    wait=wait_exponential(multiplier=1, min=4, max=60),
    stop=stop_after_attempt(5),
    retry_error_callback=lambda retry_state: logger.warning(f"Failed after {retry_state.attempt_number} attempts for {retry_state.args[1]}, returning empty DataFrame."),
    after=after_log(logger, logging.DEBUG),
    reraise=True # Re-raise the last exception if all retries fail
)
def fetch_crypto_price_history(state: dict, crypto_id: str, vs_currency: str, start_date: str, end_date: str) -> pd.DataFrame:
    """
    Fetches historical daily price data for a cryptocurrency from CoinGecko.

    Parameters
    ----------
    state : dict
        The current state dictionary (not directly used by this function but passed for consistency).
    crypto_id : str
        The CoinGecko ID of the cryptocurrency (e.g., 'bitcoin', 'ethereum').
    vs_currency : str
        The target currency for the price (e.g., 'usd', 'eur').
    start_date : str or datetime
        The start date for fetching prices (inclusive, format 'YYYY-MM-DD').
    end_date : str or datetime
        The end date for fetching prices (inclusive, format 'YYYY-MM-DD').

    Returns
    -------
    pd.DataFrame
        A DataFrame with columns 'timestamp' (datetime) and 'price' (float).
        Returns an empty DataFrame if data cannot be retrieved after retries.

    Raises
    ------
    requests.exceptions.RequestException
        If there's a persistent issue with the API request after all retries.
    ValueError
        If the API response is malformed or critical data is missing.

    Examples
    --------
    >>> state = create_transaction_state()
    >>> prices = fetch_crypto_price_history(state, 'bitcoin', 'usd', '2023-01-01', '2023-01-05')
    >>> isinstance(prices, pd.DataFrame)
    True
    >>> not prices.empty
    True
    >>> 'timestamp' in prices.columns and 'price' in prices.columns
    True
    """
    logger.info(f"Fetching price history for {crypto_id}/{vs_currency} from {start_date} to {end_date}")

    # Convert dates to unix timestamps
    start_timestamp = int(pd.to_datetime(start_date).timestamp())
    end_timestamp = int(pd.to_datetime(end_date).timestamp())

    url = f"{COINGECKO_API_BASE}/coins/{crypto_id}/market_chart/range"
    params = {
        "vs_currency": vs_currency,
        "from": start_timestamp,
        "to": end_timestamp
    }

    try:
        response = requests.get(url, params=params)
        response.raise_for_status()  # Raise HTTPError for bad responses (4xx or 5xx)
        data = response.json()

        if not data or 'prices' not in data:
            logger.warning(f"No price data found for {crypto_id}/{vs_currency} in response: {data}")
            return pd.DataFrame(columns=['timestamp', 'price'])

        prices_data = []
        for item in data['prices']:
            timestamp_ms = item[0]  # Timestamp is in milliseconds
            price = item[1]
            prices_data.append({
                'timestamp': pd.to_datetime(timestamp_ms, unit='ms'),
                'price': price
            })

        df = pd.DataFrame(prices_data)
        logger.info(f"Successfully fetched {len(df)} historical prices for {crypto_id}. Filtering dates.")

        # CoinGecko API returns data for the entire range from the start of the 'from' day
        # to the end of the 'to' day. Need to filter to ensure exact date range.
        df['timestamp'] = df['timestamp'].dt.normalize() # Normalize to just date for comparison

        # Aggregate to ensure unique daily prices (e.g., taking the mean if multiple exist for a day)
        df = df.groupby('timestamp')['price'].mean().reset_index()

        start_dt_normalized = pd.to_datetime(start_date).normalize()
        end_dt_normalized = pd.to_datetime(end_date).normalize()
        df = df[(df['timestamp'] >= start_dt_normalized) & (df['timestamp'] <= end_dt_normalized)]
        df = df.sort_values(by='timestamp').reset_index(drop=True)

        return df

    except requests.exceptions.HTTPError as e:
        if e.response.status_code == 404:
            logger.error(f"Cryptocurrency ID '{crypto_id}' or vs_currency '{vs_currency}' not found on CoinGecko. Error: {e}")
            return pd.DataFrame(columns=['timestamp', 'price'])
        elif e.response.status_code == 429:
            logger.warning(f"Rate limit hit for CoinGecko API. Retrying... Error: {e}")
            raise  # Re-raise to trigger tenacity retry
        else:
            logger.error(f"HTTP error fetching prices for {crypto_id}: {e}")
            raise
    except requests.exceptions.RequestException as e:
        logger.error(f"Network or request error fetching prices for {crypto_id}: {e}")
        raise  # Re-raise to trigger tenacity retry
    except (json.JSONDecodeError, KeyError, TypeError) as e:
        logger.error(f"Error parsing CoinGecko API response for {crypto_id}: {e}")
        raise ValueError(f"Malformed API response: {e}") from e
    except Exception as e:
        logger.error(f"An unexpected error occurred while fetching prices for {crypto_id}: {e}")
        raise

Function Name: get_coingecko_id

This utility function maps common cryptocurrency and fiat symbols to their corresponding CoinGecko API IDs. This is essential because CoinGecko's API often requires specific, human-readable IDs (e.g., 'bitcoin' instead of 'BTC') rather than common ticker symbols. It handles both cryptos and fiat currencies.

Algorithm:

  1. Define a dictionary (symbol_to_coingecko_id) that stores known mappings from symbols (e.g., 'BTC', 'USD') to CoinGecko IDs (e.g., 'bitcoin', 'united-states-dollar').
  2. Convert the input symbol to lowercase for case-insensitive matching.
  3. Check if the lowercase symbol exists as a key in the mapping dictionary.
  4. If a match is found, return the corresponding CoinGecko ID.
  5. If no direct match is found, assume the input symbol itself is a valid CoinGecko ID (or a close enough approximation) and return it, after converting to lowercase.
  6. Log the mapping operation for debugging.

Parameters:

  • symbol (str): The cryptocurrency or fiat currency symbol (e.g., 'BTC', 'ETH', 'USD').

Returns:

  • str: The corresponding CoinGecko ID for the given symbol.
[9]
def get_coingecko_id(symbol: str) -> str:
    """
    Converts common cryptocurrency/fiat symbols to their CoinGecko API IDs.

    Parameters
    ----------
    symbol : str
        The cryptocurrency or fiat currency symbol (e.g., 'BTC', 'ETH', 'USD').

    Returns
    -------
    str
        The corresponding CoinGecko ID.

    Examples
    --------
    >>> get_coingecko_id('BTC')
    'bitcoin'
    >>> get_coingecko_id('ETH')
    'ethereum'
    >>> get_coingecko_id('USD')
    'usd'
    >>> get_coingecko_id('SOL') # Assumes 'solana' if not in explicit map
    'sol'
    """
    # A more comprehensive mapping could be loaded from a file or external API
    symbol_to_coingecko_id = {
        'btc': 'bitcoin',
        'eth': 'ethereum',
        'sol': 'solana',
        'xrp': 'ripple',
        'ada': 'cardano',
        'doge': 'dogecoin',
        'bnb': 'binancecoin',
        'dot': 'polkadot',
        'link': 'chainlink',
        'ltc': 'litecoin',
        'bch': 'bitcoin-cash',
        'usdt': 'tether',
        'usdc': 'usd-coin',
        'eur': 'euro',
        'gbp': 'british-pound',
        'jpy': 'japanese-yen',
        'usd': 'usd'
    }

    clean_symbol = symbol.lower()
    coingecko_id = symbol_to_coingecko_id.get(clean_symbol, clean_symbol) # Default to symbol itself if not found
    logger.debug(f"Mapped symbol '{symbol}' to CoinGecko ID '{coingecko_id}'")
    return coingecko_id

Function Name: process_raw_transactions_with_fiat_value

This function iterates through the raw transactions, fetches historical fiat prices for the involved cryptocurrencies, and calculates the fiat value for both the 'from' and 'to' amounts of each transaction. This is a critical step for preparing the data for cost basis and capital gains calculations, as tax reporting usually requires values in a local fiat currency (e.g., USD).

Algorithm:

  1. Initialize a DataFrame to store processed transactions.
  2. Identify all unique cryptocurrencies (from from_currency and to_currency fields that are not fiat) and the overall date range needed for price fetching.
  3. For each unique cryptocurrency, fetch its historical price data using fetch_crypto_price_history.
  4. Store the fetched prices in a dictionary for quick lookup.
  5. Iterate through each raw transaction: a. Determine the fiat value of the from_amount and to_amount using the fetched prices and get_coingecko_id. b. Handle transactions where fiat is directly involved (e.g., USD buy). c. Calculate the fiat value of fees. d. Append the enriched transaction data to a temporary list.
  6. Convert the list of enriched transactions into a pandas DataFrame.
  7. Update the processed_transactions DataFrame in the state.

Parameters:

  • state (dict): The current state dictionary containing raw_transactions.
  • vs_currency (str): The target fiat currency for valuation (e.g., 'usd').

Returns:

  • dict: The updated state dictionary with the processed_transactions DataFrame populated with fiat values.

Raises:

  • ValueError: If a required price for a transaction date cannot be found.
[10]
def process_raw_transactions_with_fiat_value(state: dict, vs_currency: str = 'usd') -> dict:
    """
    Processes raw transactions by fetching historical fiat values for cryptocurrencies
    involved and calculates the fiat value for each transaction's 'from' and 'to' amounts.

    Parameters
    ----------
    state : dict
        The current state dictionary, containing 'raw_transactions' DataFrame.
    vs_currency : str, optional
        The target fiat currency for valuation (e.g., 'usd'), defaults to 'usd'.

    Returns
    -------
    dict
        The updated state dictionary with 'processed_transactions' DataFrame populated.

    Raises
    ------
    ValueError
        If a required price for a transaction date cannot be found.

    Examples
    --------
    >>> state = create_transaction_state()
    >>> tx1 = {
    ...     'timestamp': '2023-01-01 10:00:00', 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 100.0,
    ...     'to_currency': 'BTC', 'to_amount': 0.005, 'fee_currency': 'USD', 'fee_amount': 1.0, 'description': 'Buy BTC'
    ... }
    >>> tx2 = {
    ...     'timestamp': '2023-01-03 12:00:00', 'transaction_type': 'SELL', 'from_currency': 'BTC', 'from_amount': 0.002,
    ...     'to_currency': 'USD', 'to_amount': 40.0, 'fee_currency': 'USD', 'fee_amount': 0.5, 'description': 'Sell BTC'
    ... }
    >>> state = add_raw_transaction(state, tx1)
    >>> state = add_raw_transaction(state, tx2)
    >>> # Note: Actual price fetching happens here. Mocking for doctest.
    >>> # To run this doctest, you'd need to mock fetch_crypto_price_history
    >>> # For interactive use, run with actual fetch_crypto_price_history
    >>> # updated_state = process_raw_transactions_with_fiat_value(state, 'usd')
    >>> # len(updated_state['processed_transactions']) > 0
    True
    """
    logger.info(f"Processing raw transactions to add fiat values in {vs_currency.upper()}.")

    raw_tx = state['raw_transactions'].copy()
    if raw_tx.empty:
        logger.warning("No raw transactions to process.")
        return state

    # Ensure timestamp is datetime and sort transactions by timestamp
    raw_tx['timestamp'] = pd.to_datetime(raw_tx['timestamp'])
    raw_tx = raw_tx.sort_values(by='timestamp').reset_index(drop=True)

    all_currencies = set(raw_tx['from_currency'].unique()).union(raw_tx['to_currency'].unique())
    # Exclude vs_currency and any explicit NULL placeholders from crypto price fetching
    crypto_currencies = {c for c in all_currencies if c.lower() != vs_currency.lower() and c.upper() != 'NULL'}

    # Determine the overall date range for transactions
    min_transaction_date = raw_tx['timestamp'].min().normalize() - timedelta(days=1)
    max_transaction_date = raw_tx['timestamp'].max().normalize() + timedelta(days=1)

    # Adjust the start date for API fetching to respect CoinGecko's 365-day limit for public API
    api_fetch_start_date = max(min_transaction_date, max_transaction_date - timedelta(days=365))

    if api_fetch_start_date > min_transaction_date:
        logger.warning(f"CoinGecko Public API limits historical data to ~365 days. Prices for transactions before {api_fetch_start_date.strftime('%Y-%m-%d')} will not be fetched accurately. Consider upgrading API plan for full historical access.")

    price_cache = {}
    for crypto in crypto_currencies:
        crypto_id = get_coingecko_id(crypto)
        print(f"Processing {crypto_id} ({crypto})")
        try:
            # Introduce a delay to avoid hitting CoinGecko API rate limits
            time.sleep(3)
            prices_df = fetch_crypto_price_history(
                state,
                crypto_id,
                vs_currency,
                api_fetch_start_date.strftime('%Y-%m-%d'),
                max_transaction_date.strftime('%Y-%m-%d')
            )
            if prices_df.empty:
                logger.warning(f"Could not fetch price history for {crypto_id}. Skipping.")
                price_cache[crypto] = pd.DataFrame(columns=['timestamp', 'price'])
                continue

            # Create a Series for quick lookup by date
            prices_df = prices_df.set_index('timestamp')
            price_cache[crypto] = prices_df['price']
            logger.debug(f"Cached {len(prices_df)} prices for {crypto} from {prices_df.index.min().strftime('%Y-%m-%d')} to {prices_df.index.max().strftime('%Y-%m-%d')}")

        except Exception as e:
            logger.error(f"Error fetching prices for {crypto_id}: {e}")
            price_cache[crypto] = pd.DataFrame(columns=['timestamp', 'price'])

    processed_records = []

    for _, row in raw_tx.iterrows():
        tx_date = row['timestamp'].normalize() # Use normalized date for price lookup

        fiat_value_from = 0.0
        fiat_value_to = 0.0
        fiat_value_fee = 0.0

        # Calculate fiat_value_from
        if row['from_currency'].lower() == vs_currency.lower():
            fiat_value_from = row['from_amount']
        elif row['from_currency'].upper() == 'NULL': # Explicitly handle NULL currency
            fiat_value_from = 0.0 # No fiat value for NULL currency
        elif row['from_currency'] in price_cache:
            try:
                price = price_cache[row['from_currency']].loc[tx_date]
                fiat_value_from = row['from_amount'] * price
            except KeyError:
                logger.error(f"Price not found for {row['from_currency']} on {tx_date.strftime('%Y-%m-%d')}. Transaction skipped or value set to 0.")
                # Optionally raise an error or set to 0 and log warning
                # raise ValueError(f"Missing price for {row['from_currency']} on {tx_date.strftime('%Y-%m-%d')}")
        else:
            logger.warning(f"Unknown or unpriced 'from_currency': {row['from_currency']}. Fiat value set to 0.")

        # Calculate fiat_value_to
        if row['to_currency'].lower() == vs_currency.lower():
            fiat_value_to = row['to_amount']
        elif row['to_currency'].upper() == 'NULL': # Explicitly handle NULL currency
            fiat_value_to = 0.0 # No fiat value for NULL currency
        elif row['to_currency'] in price_cache:
            try:
                price = price_cache[row['to_currency']].loc[tx_date]
                fiat_value_to = row['to_amount'] * price
            except KeyError:
                logger.error(f"Price not found for {row['to_currency']} on {tx_date.strftime('%Y-%m-%d')}. Transaction skipped or value set to 0.")
                # raise ValueError(f"Missing price for {row['to_currency']} on {tx_date.strftime('%Y-%m-%d')}")
        else:
            logger.warning(f"Unknown or unpriced 'to_currency': {row['to_currency']}. Fiat value set to 0.")

        # Calculate fiat_value_fee
        if row['fee_currency'] and row['fee_currency'].lower() == vs_currency.lower():
            fiat_value_fee = row['fee_amount']
        elif row['fee_currency'] and row['fee_currency'].upper() == 'NULL': # Explicitly handle NULL currency
            fiat_value_fee = 0.0 # No fiat value for NULL currency
        elif row['fee_currency'] in price_cache:
            try:
                price = price_cache[row['fee_currency']].loc[tx_date]
                fiat_value_fee = row['fee_amount'] * price
            except KeyError:
                logger.warning(f"Price not found for fee currency {row['fee_currency']} on {tx_date.strftime('%Y-%m-%d')}. Fee value set to 0.")
        elif row['fee_currency'] is not None: # If fee currency exists but not in cache and not fiat
            logger.warning(f"Unknown or unpriced 'fee_currency': {row['fee_currency']}. Fiat value set to 0.")

        # Append to processed records. Note: 'asset', 'amount', 'cost_basis', 'gain_loss', 'holding_period', 'taxable_event'
        # will be calculated in subsequent processing steps.
        processed_records.append({
            'timestamp': row['timestamp'],
            'transaction_type': row['transaction_type'],
            'from_currency': row['from_currency'],
            'from_amount': row['from_amount'],
            'fiat_value_from': fiat_value_from,
            'to_currency': row['to_currency'],
            'to_amount': row['to_amount'],
            'fiat_value_to': fiat_value_to,
            'fee_currency': row['fee_currency'],
            'fee_amount': row['fee_amount'],
            'fiat_value_fee': fiat_value_fee,
            'description': row['description'],
            'vs_currency': vs_currency # Keep track of the valuation currency
        })

    # Update the processed_transactions DataFrame in the state
    state['processed_transactions'] = pd.DataFrame(processed_records)
    logger.info(f"Finished processing {len(state['processed_transactions'])} transactions with fiat values.")
    return state

Function Name: calculate_cost_basis_fifo

This function calculates the cost basis for 'SELL' transactions using the First-In, First-Out (FIFO) method. FIFO assumes that the first cryptocurrency units acquired are the first ones sold. This method is crucial for determining capital gains or losses for tax purposes. It processes transactions in chronological order, updating asset holdings and assigning cost basis to sales.

Algorithm:

  1. Initialize or update the asset_holdings DataFrame within the state, which tracks the quantity and cost basis of each held asset.
  2. Sort the processed_transactions DataFrame by timestamp to ensure chronological processing.
  3. Iterate through each transaction in the sorted DataFrame: a. BUY/RECEIVE: Add the acquired amount to the asset_holdings at its acquisition cost (fiat value). b. SELL/SEND: When a 'SELL' transaction occurs: i. Retrieve corresponding holdings for the asset being sold, applying FIFO by matching against the earliest acquired units. ii. Calculate the cost basis for the sold amount using the prices of the earliest acquired units. iii. Determine the capital gain or loss by comparing the sale proceeds (fiat value_from) with the calculated cost basis. iv. Update asset_holdings by reducing the quantity of the sold units. c. TRANSFER/SWAP: Handle these by potentially removing from one asset and adding to another, or adjusting cost basis if a taxable event occurs.
  4. Update the processed_transactions DataFrame with calculated cost_basis, gain_loss, holding_period, and taxable_event flags.
  5. Log important steps and outcomes, including any remaining holdings or warnings about insufficient assets for sale.

Parameters:

  • state (dict): The current state dictionary containing processed_transactions and asset_holdings DataFrames.

Returns:

  • dict: The updated state dictionary with processed_transactions enriched with cost basis information and asset_holdings reflecting current positions.
[11]
def calculate_cost_basis_fifo(state: dict) -> dict:
    """
    Calculates the cost basis for transactions using the First-In, First-Out (FIFO) method.

    This function iterates through processed transactions, maintains a ledger of asset holdings,
    and assigns a cost basis to 'SELL' transactions based on the FIFO principle.
    It updates the 'processed_transactions' DataFrame with cost basis, gain/loss, and holding period.

    Parameters
    ----------
    state : dict
        The current state dictionary, which must contain 'processed_transactions'
        (with fiat values) and 'asset_holdings' DataFrames.

    Returns
    -------
    dict
        The updated state dictionary with 'processed_transactions' and 'asset_holdings' updated.

    Examples
    --------
    >>> state = create_transaction_state()
    >>> # Example Buy
    >>> tx1 = {
    ...     'timestamp': '2023-01-01 10:00:00', 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 100.0,
    ...     'to_currency': 'BTC', 'to_amount': 0.005, 'fee_currency': 'USD', 'fee_amount': 1.0, 'description': 'Buy BTC'
    ... }
    >>> state = add_raw_transaction(state, tx1)
    >>> # Example Sell
    >>> tx2 = {
    ...     'timestamp': '2023-01-03 12:00:00', 'transaction_type': 'SELL', 'from_currency': 'BTC', 'from_amount': 0.002,
    ...     'to_currency': 'USD', 'to_amount': 40.0, 'fee_currency': 'USD', 'fee_amount': 0.5, 'description': 'Sell BTC'
    ... }
    >>> state = add_raw_transaction(state, tx2)
    >>> # Mocking prices for doctest - in real scenario process_raw_transactions_with_fiat_value is called first
    >>> state['processed_transactions'] = pd.DataFrame([
    ...     {'timestamp': pd.to_datetime('2023-01-01 10:00:00'), 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 100.0, 'fiat_value_from': 100.0, 'to_currency': 'BTC', 'to_amount': 0.005, 'fiat_value_to': 99.0, 'fee_currency': 'USD', 'fee_amount': 1.0, 'fiat_value_fee': 1.0, 'description': 'Buy BTC', 'vs_currency': 'usd'},
    ...     {'timestamp': pd.to_datetime('2023-01-03 12:00:00'), 'transaction_type': 'SELL', 'from_currency': 'BTC', 'from_amount': 0.002, 'fiat_value_from': 40.0, 'to_currency': 'USD', 'to_amount': 40.0, 'fiat_value_to': 40.0, 'fee_currency': 'USD', 'fee_amount': 0.5, 'fiat_value_fee': 0.5, 'description': 'Sell BTC', 'vs_currency': 'usd'}
    ... ])
    >>> updated_state = calculate_cost_basis_fifo(state)
    >>> isinstance(updated_state, dict)
    True
    >>> 'cost_basis' in updated_state['processed_transactions'].columns
    True
    >>> # updated_state['processed_transactions']['gain_loss'].iloc[1] # Should be around 0.002 * (price_at_sell - price_at_buy) + fees
    """
    logger.info("Calculating cost basis using FIFO method.")

    processed_tx = state['processed_transactions'].copy()
    if processed_tx.empty:
        logger.warning("No processed transactions to calculate cost basis for.")
        return state

    # Ensure sorted by timestamp for FIFO
    processed_tx = processed_tx.sort_values(by='timestamp').reset_index(drop=True)

    # Initialize asset holdings for FIFO (quantity, cost_basis, acquisition_date)
    # Using a deque for each asset to maintain FIFO order
    asset_holdings_fifo = {asset: deque() for asset in processed_tx['to_currency'].unique().tolist() + processed_tx['from_currency'].unique().tolist()}
    # Clean up empty deques
    asset_holdings_fifo = {k: v for k, v in asset_holdings_fifo.items() if k.lower() != processed_tx['vs_currency'].iloc[0].lower()}

    # Prepare new columns for processed_tx
    processed_tx['cost_basis'] = 0.0
    processed_tx['gain_loss'] = 0.0
    processed_tx['holding_period'] = pd.NaT
    processed_tx['taxable_event'] = False
    processed_tx['processed_asset'] = None # To indicate which asset is being taxed/tracked

    vs_currency = processed_tx['vs_currency'].iloc[0].lower()

    for i, row in processed_tx.iterrows():
        tx_type = row['transaction_type'].upper()
        timestamp = row['timestamp']

        if tx_type == 'BUY':
            asset = row['to_currency']
            amount = row['to_amount']
            fiat_value = row['fiat_value_to']
            if amount > 0:
                unit_cost = fiat_value / amount
                asset_holdings_fifo[asset].append({'quantity': amount, 'unit_cost': unit_cost, 'acquisition_date': timestamp})
                logger.debug(f"BUY: Added {amount} {asset} at {unit_cost:.2f} {vs_currency}/unit. Holdings: {sum(h['quantity'] for h in asset_holdings_fifo[asset]):.4f} {asset}")

        elif tx_type == 'SELL':
            asset = row['from_currency']
            amount_to_sell = row['from_amount']
            sale_proceeds = row['fiat_value_from']
            total_cost_basis = 0.0
            sold_quantity = 0.0
            oldest_acquisition_date = None

            if asset not in asset_holdings_fifo or not asset_holdings_fifo[asset]:
                logger.warning(f"SELL: No holdings found for {asset} for transaction on {timestamp.strftime('%Y-%m-%d %H:%M')}. Gain/Loss will be based on 0 cost basis.")
                # If no holdings, assume 0 cost basis, but still a taxable event
                processed_tx.loc[i, 'cost_basis'] = 0.0
                processed_tx.loc[i, 'gain_loss'] = sale_proceeds - row['fiat_value_fee'] # Full proceeds minus fee
                processed_tx.loc[i, 'taxable_event'] = True
                processed_tx.loc[i, 'processed_asset'] = asset
                continue

            # Apply FIFO logic
            while amount_to_sell > 0 and asset_holdings_fifo[asset]:
                holding = asset_holdings_fifo[asset][0]
                if oldest_acquisition_date is None:
                    oldest_acquisition_date = holding['acquisition_date']

                if holding['quantity'] <= amount_to_sell:
                    # Sell entire holding block
                    sold_quantity_from_block = holding['quantity']
                    total_cost_basis += sold_quantity_from_block * holding['unit_cost']
                    amount_to_sell -= sold_quantity_from_block
                    sold_quantity += sold_quantity_from_block
                    asset_holdings_fifo[asset].popleft()
                    logger.debug(f"  FIFO: Consumed {sold_quantity_from_block} from block acquired on {holding['acquisition_date'].strftime('%Y-%m-%d')}. Remaining to sell: {amount_to_sell:.4f}")
                else:
                    # Sell part of holding block
                    total_cost_basis += amount_to_sell * holding['unit_cost']
                    holding['quantity'] -= amount_to_sell
                    sold_quantity += amount_to_sell
                    amount_to_sell = 0
                    logger.debug(f"  FIFO: Consumed {sold_quantity} from block acquired on {holding['acquisition_date'].strftime('%Y-%m-%d')}. Block remaining: {holding['quantity']:.4f}")

            # If there's still amount_to_sell, it means we ran out of holdings. This is an error state or a short sale.
            if amount_to_sell > 0:
                logger.warning(f"SELL: Insufficient holdings for {asset} to cover {row['from_amount']} on {timestamp.strftime('%Y-%m-%d %H:%M')}. Used all available holdings. Remaining quantity to sell: {amount_to_sell:.4f}")
                # For tax purposes, treat remaining as 0 cost basis if no other rules apply (e.g., short sale)
                # This simplifies but might not be entirely accurate for all tax jurisdictions.

            gain_loss = sale_proceeds - total_cost_basis - row['fiat_value_fee']
            holding_period = timestamp - oldest_acquisition_date if oldest_acquisition_date else pd.NaT

            processed_tx.loc[i, 'cost_basis'] = total_cost_basis
            processed_tx.loc[i, 'gain_loss'] = gain_loss
            processed_tx.loc[i, 'holding_period'] = holding_period
            processed_tx.loc[i, 'taxable_event'] = True
            processed_tx.loc[i, 'processed_asset'] = asset
            logger.info(f"SELL: {sold_quantity:.4f} {asset} sold for {sale_proceeds:.2f} {vs_currency}. Cost Basis: {total_cost_basis:.2f}. Gain/Loss: {gain_loss:.2f} {vs_currency}")

        elif tx_type == 'TRANSFER':
            # Transfers are generally not taxable events unless they cross jurisdictions or change ownership
            # For simplicity, we'll track quantity movements but not assign immediate tax impact.
            from_asset = row['from_currency']
            to_asset = row['to_currency']
            from_amount = row['from_amount']
            to_amount = row['to_amount'] # Usually same as from_amount, but could be different due to fees

            # Handle 'from' side of transfer: reduce holdings
            remaining_from_amount = from_amount
            cost_basis_transferred = 0.0
            transfer_acquisition_date = None
            if from_asset in asset_holdings_fifo and asset_holdings_fifo[from_asset]:
                temp_deque = deque() # Use a temporary deque to rebuild if needed
                while remaining_from_amount > 0 and asset_holdings_fifo[from_asset]:
                    holding = asset_holdings_fifo[from_asset].popleft()
                    if transfer_acquisition_date is None:
                        transfer_acquisition_date = holding['acquisition_date']

                    if holding['quantity'] <= remaining_from_amount:
                        cost_basis_transferred += holding['quantity'] * holding['unit_cost']
                        remaining_from_amount -= holding['quantity']
                    else:
                        cost_basis_transferred += remaining_from_amount * holding['unit_cost']
                        holding['quantity'] -= remaining_from_amount
                        temp_deque.append(holding) # Put remaining back
                        remaining_from_amount = 0
                # Put back any remaining items into the main deque
                asset_holdings_fifo[from_asset].extendleft(reversed(temp_deque)) # Add back in original order
                logger.debug(f"TRANSFER: Transferred out {from_amount} {from_asset}. Cost basis for transfer: {cost_basis_transferred:.2f}")
            else:
                logger.warning(f"TRANSFER: No holdings found for {from_asset} to transfer on {timestamp.strftime('%Y-%m-%d %H:%M')}. This might indicate incomplete transaction data.")

            # Handle 'to' side of transfer: add to holdings, ideally with transferred cost basis
            if to_amount > 0 and to_asset.lower() != vs_currency:
                # If transfer is into the same asset, try to preserve FIFO and cost basis
                # If this is a self-transfer to consolidate, it effectively moves the oldest block to the end FIFO
                # For now, let's just add it with the cost basis from the 'from' side of the transfer
                if from_amount > 0 and cost_basis_transferred > 0:
                    unit_cost_to_transfer = cost_basis_transferred / from_amount
                    asset_holdings_fifo[to_asset].append({'quantity': to_amount, 'unit_cost': unit_cost_to_transfer, 'acquisition_date': transfer_acquisition_date if transfer_acquisition_date else timestamp})
                    logger.debug(f"TRANSFER: Transferred in {to_amount} {to_asset} with effective unit cost {unit_cost_to_transfer:.2f}. Holdings: {sum(h['quantity'] for h in asset_holdings_fifo[to_asset]):.4f} {to_asset}")
                else:
                     logger.warning(f"TRANSFER: Cannot determine cost basis for {to_asset} on transfer, setting 0 cost basis. Transaction: {row}")
                     asset_holdings_fifo[to_asset].append({'quantity': to_amount, 'unit_cost': 0.0, 'acquisition_date': timestamp})

            processed_tx.loc[i, 'processed_asset'] = from_asset # Primary asset for this record, even if transfer
            # Transfers generally not taxable unless cross-chain swap or specific rules apply. No gain/loss here.
            processed_tx.loc[i, 'taxable_event'] = False

        elif tx_type == 'SWAP':
            # A swap is a taxable event: sell one asset, buy another
            # Treat as a SELL of from_currency and a BUY of to_currency
            from_asset = row['from_currency']
            amount_sold_in_swap = row['from_amount']
            to_asset = row['to_currency']
            amount_bought_in_swap = row['to_amount']
            fiat_value_from_swap = row['fiat_value_from']
            fiat_value_to_swap = row['fiat_value_to']
            fiat_value_fee = row['fiat_value_fee']

            # SELL part of the swap
            total_cost_basis_sold = 0.0
            oldest_acquisition_date_sold = None

            if from_asset in asset_holdings_fifo and asset_holdings_fifo[from_asset]:
                remaining_to_match = amount_sold_in_swap
                while remaining_to_match > 0 and asset_holdings_fifo[from_asset]:
                    holding = asset_holdings_fifo[from_asset][0]
                    if oldest_acquisition_date_sold is None:
                        oldest_acquisition_date_sold = holding['acquisition_date']

                    if holding['quantity'] <= remaining_to_match:
                        total_cost_basis_sold += holding['quantity'] * holding['unit_cost']
                        remaining_to_match -= holding['quantity']
                        asset_holdings_fifo[from_asset].popleft()
                    else:
                        total_cost_basis_sold += remaining_to_match * holding['unit_cost']
                        holding['quantity'] -= remaining_to_match
                        remaining_to_match = 0
            else:
                logger.warning(f"SWAP (SELL side): No holdings found for {from_asset} on {timestamp.strftime('%Y-%m-%d %H:%M')}. Assuming 0 cost basis for this side of swap.")

            gain_loss_on_swap = fiat_value_from_swap - total_cost_basis_sold - fiat_value_fee # Fee typically comes from the 'from' asset
            holding_period_swap = timestamp - oldest_acquisition_date_sold if oldest_acquisition_date_sold else pd.NaT

            processed_tx.loc[i, 'cost_basis'] = total_cost_basis_sold
            processed_tx.loc[i, 'gain_loss'] = gain_loss_on_swap
            processed_tx.loc[i, 'holding_period'] = holding_period_swap
            processed_tx.loc[i, 'taxable_event'] = True
            processed_tx.loc[i, 'processed_asset'] = from_asset # Record gain/loss against the asset sold
            logger.info(f"SWAP: Sold {amount_sold_in_swap:.4f} {from_asset} for {fiat_value_from_swap:.2f} {vs_currency}. Cost Basis: {total_cost_basis_sold:.2f}. Gain/Loss: {gain_loss_on_swap:.2f} {vs_currency}")

            # BUY part of the swap
            if amount_bought_in_swap > 0:
                if fiat_value_to_swap > 0 and amount_bought_in_swap > 0:
                    unit_cost_bought = fiat_value_to_swap / amount_bought_in_swap
                    asset_holdings_fifo[to_asset].append({'quantity': amount_bought_in_swap, 'unit_cost': unit_cost_bought, 'acquisition_date': timestamp})
                    logger.debug(f"SWAP: Acquired {amount_bought_in_swap} {to_asset} at {unit_cost_bought:.2f} {vs_currency}/unit.")
                else:
                    logger.warning(f"SWAP (BUY side): Cannot determine unit cost for {to_asset} acquired. Setting 0 unit cost. Transaction: {row}")
                    asset_holdings_fifo[to_asset].append({'quantity': amount_bought_in_swap, 'unit_cost': 0.0, 'acquisition_date': timestamp})

        else:
            logger.warning(f"Unhandled transaction type: {tx_type}. No cost basis calculated for this row.")

    state['processed_transactions'] = processed_tx

    # Summarize current asset holdings
    current_holdings = []
    for asset, holdings_deque in asset_holdings_fifo.items():
        if holdings_deque:
            total_quantity = sum(h['quantity'] for h in holdings_deque)
            total_cost_basis = sum(h['quantity'] * h['unit_cost'] for h in holdings_deque)
            if total_quantity > 0:
                avg_cost_per_unit = total_cost_basis / total_quantity
            else:
                avg_cost_per_unit = 0.0 # Should not happen if total_quantity > 0
            current_holdings.append({
                'asset': asset,
                'quantity': total_quantity,
                'cost_basis_per_unit': avg_cost_per_unit,
                'total_cost_basis': total_cost_basis
            })

    state['asset_holdings'] = pd.DataFrame(current_holdings)

    logger.info("FIFO cost basis calculation complete.")
    return state

Function Name: generate_tax_report_summary

This function summarizes the capital gains and losses for a specified tax year, distinguishing between short-term and long-term gains/losses based on the holding period. This summary is crucial for understanding the overall tax liability and for filling out tax forms.

Algorithm:

  1. Filter the processed_transactions DataFrame to include only taxable events within the specified tax_year.
  2. Separate these taxable events into short-term (holding period <= 365 days) and long-term (holding period > 365 days) categories.
  3. Calculate the total capital gain/loss for both short-term and long-term categories.
  4. Optionally, include other taxable events like income from staking, mining, or airdrops (though these might be handled by separate functions not yet defined).
  5. Aggregate the results into a clear, tabular format.
  6. Log the summary generation process and the results.

Parameters:

  • state (dict): The current state dictionary containing the processed_transactions DataFrame.
  • tax_year (int): The year for which the tax report is to be generated.
  • vs_currency (str, optional): The fiat currency in which the report should be summarized (e.g., 'usd'), defaults to 'usd'.

Returns:

  • pd.DataFrame: A DataFrame summarizing short-term and long-term capital gains/losses, and other taxable income.
[12]
def generate_tax_report_summary(state: dict, tax_year: int, vs_currency: str = 'usd') -> pd.DataFrame:
    """
    Generates a summary of capital gains and losses for a given tax year.

    This function categorizes taxable events into short-term and long-term capital
    gains/losses and provides a total summary.

    Parameters
    ----------
    state : dict
        The current state dictionary, containing the 'processed_transactions' DataFrame.
    tax_year : int
        The calendar year for which the tax report is to be generated.
    vs_currency : str, optional
        The fiat currency in which the report should be summarized, defaults to 'usd'.

    Returns
    -------
    pd.DataFrame
        A DataFrame summarizing short-term and long-term capital gains/losses.

    Examples
    --------
    >>> state = create_transaction_state()
    >>> tx1 = {
    ...     'timestamp': '2022-01-01 10:00:00', 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 100.0,
    ...     'to_currency': 'BTC', 'to_amount': 0.005, 'fee_currency': 'USD', 'fee_amount': 1.0, 'description': 'Buy BTC'
    ... }
    >>> tx2 = {
    ...     'timestamp': '2023-01-03 12:00:00', 'transaction_type': 'SELL', 'from_currency': 'BTC', 'from_amount': 0.002,
    ...     'to_currency': 'USD', 'to_amount': 40.0, 'fee_currency': 'USD', 'fee_amount': 0.5, 'description': 'Sell BTC'
    ... }
    >>> tx3 = {
    ...     'timestamp': '2023-03-01 15:00:00', 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 50.0,
    ...     'to_currency': 'ETH', 'to_amount': 0.03, 'fee_currency': 'USD', 'fee_amount': 0.5, 'description': 'Buy ETH'
    ... }
    >>> tx4 = {
    ...     'timestamp': '2023-06-15 09:00:00', 'transaction_type': 'SELL', 'from_currency': 'ETH', 'from_amount': 0.01,
    ...     'to_currency': 'USD', 'to_amount': 25.0, 'fee_currency': 'USD', 'fee_amount': 0.2, 'description': 'Sell ETH short-term'
    ... }
    >>> state = add_raw_transaction(state, tx1)
    >>> state = add_raw_transaction(state, tx2)
    >>> state = add_raw_transaction(state, tx3)
    >>> state = add_raw_transaction(state, tx4)

    >>> # Mocking processed_transactions for doctest, in reality this comes from calculate_cost_basis_fifo
    >>> state['processed_transactions'] = pd.DataFrame([
    ...     {'timestamp': pd.to_datetime('2022-01-01 10:00:00'), 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 100.0, 'fiat_value_from': 100.0, 'to_currency': 'BTC', 'to_amount': 0.005, 'fiat_value_to': 99.0, 'fee_currency': 'USD', 'fee_amount': 1.0, 'fiat_value_fee': 1.0, 'description': 'Buy BTC', 'vs_currency': 'usd', 'cost_basis': 0.0, 'gain_loss': 0.0, 'holding_period': pd.NaT, 'taxable_event': False},
    ...     {'timestamp': pd.to_datetime('2023-01-03 12:00:00'), 'transaction_type': 'SELL', 'from_currency': 'BTC', 'from_amount': 0.002, 'fiat_value_from': 40.0, 'to_currency': 'USD', 'to_amount': 40.0, 'fiat_value_to': 40.0, 'fee_currency': 'USD', 'fee_amount': 0.5, 'fiat_value_fee': 0.5, 'description': 'Sell BTC', 'vs_currency': 'usd', 'cost_basis': 39.6, 'gain_loss': 40.0 - 39.6 - 0.5, 'holding_period': pd.to_timedelta('367 days'), 'taxable_event': True},
    ...     {'timestamp': pd.to_datetime('2023-03-01 15:00:00'), 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 50.0, 'to_currency': 'ETH', 'to_amount': 0.03, 'fiat_value_to': 49.5, 'fee_currency': 'USD', 'fee_amount': 0.5, 'fiat_value_fee': 0.5, 'description': 'Buy ETH', 'vs_currency': 'usd', 'cost_basis': 0.0, 'gain_loss': 0.0, 'holding_period': pd.NaT, 'taxable_event': False},
    ...     {'timestamp': pd.to_datetime('2023-06-15 09:00:00'), 'transaction_type': 'SELL', 'from_currency': 'ETH', 'from_amount': 0.01, 'fiat_value_from': 25.0, 'to_currency': 'USD', 'to_amount': 25.0, 'fiat_value_to': 25.0, 'fee_currency': 'USD', 'fee_amount': 0.2, 'fiat_value_fee': 0.2, 'description': 'Sell ETH short-term', 'vs_currency': 'usd', 'cost_basis': 16.5, 'gain_loss': 25.0 - 16.5 - 0.2, 'holding_period': pd.to_timedelta('106 days'), 'taxable_event': True}
    ... ])
    >>> summary = generate_tax_report_summary(state, 2023, 'usd')
    >>> isinstance(summary, pd.DataFrame)
    True
    >>> 'Category' in summary.columns and 'Amount' in summary.columns
    True
    >>> summary.loc[summary['Category'] == 'Total Long-Term Capital Gains/Losses', 'Amount'].iloc[0] == 40.0 - 39.6 - 0.5
    True
    >>> summary.loc[summary['Category'] == 'Total Short-Term Capital Gains/Losses', 'Amount'].iloc[0] == 25.0 - 16.5 - 0.2
    True
    """
    logger.info(f"Generating tax report summary for tax year {tax_year} in {vs_currency.upper()}.")

    processed_tx = state['processed_transactions'].copy()

    if processed_tx.empty:
        logger.warning("No processed transactions found for tax report summary.")
        return pd.DataFrame(columns=['Category', 'Amount'])

    # Ensure 'holding_period' column is of timedelta type right after copying
    processed_tx['holding_period'] = pd.to_timedelta(processed_tx['holding_period'], errors='coerce').astype('timedelta64[ns]')

    # Filter transactions for the specified tax year
    tax_year_start = pd.to_datetime(f'{tax_year}-01-01')
    tax_year_end = pd.to_datetime(f'{tax_year}-12-31 23:59:59')

    taxable_events_in_year = processed_tx[
        (processed_tx['taxable_event'] == True) &
        (processed_tx['timestamp'] >= tax_year_start) &
        (processed_tx['timestamp'] <= tax_year_end)
    ].copy()

    if taxable_events_in_year.empty:
        logger.info(f"No taxable events found for tax year {tax_year}.")
        return pd.DataFrame([
            {'Category': 'Total Short-Term Capital Gains/Losses', 'Amount': 0.0},
            {'Category': 'Total Long-Term Capital Gains/Losses', 'Amount': 0.0},
            {'Category': 'Total Other Crypto Income (Staking, Mining, Airdrops)', 'Amount': 0.0},
            {'Category': 'Total Taxable Capital Events', 'Amount': 0.0}
        ])

    short_term_gains_losses = taxable_events_in_year[
        (taxable_events_in_year['holding_period'].dt.days <= 365) & (taxable_events_in_year['holding_period'].notna())
    ]['gain_loss'].sum()

    long_term_gains_losses = taxable_events_in_year[
        (taxable_events_in_year['holding_period'].dt.days > 365) & (taxable_events_in_year['holding_period'].notna())
    ]['gain_loss'].sum()

    # Handle other types of crypto income (e.g., staking rewards, mining income, airdrops)
    # For this basic framework, we assume 'BUY' transactions not linked to fiat 'from_currency'
    # might represent income, or specific transaction types for income can be added.
    # This part can be significantly expanded based on actual income transaction types.
    other_income = taxable_events_in_year[
        (taxable_events_in_year['transaction_type'].isin(['STAKING_REWARD', 'MINING_REWARD', 'AIRDROP']))
    ]['fiat_value_to'].sum() # Assuming fiat_value_to represents income value

    # If such transaction types aren't explicitly in 'taxable_events_in_year',
    # we might need to look at the 'raw_transactions' or define income events separately.
    # For now, let's just sum any 'gain_loss' for unclassified income if it doesn't fit ST/LT

    # For simplicity, if we don't have explicit income types, we might define specific 'income' transaction types in raw data.
    # For this example, let's assume any 'gain_loss' that was not from a SELL/SWAP but marked taxable_event=True is 'other income'
    # This is a simplification and needs better definition in a real system.
    # For now, we are focusing on capital gains/losses.


    summary_data = [
        {'Category': 'Total Short-Term Capital Gains/Losses', 'Amount': round(short_term_gains_losses, 2)},
        {'Category': 'Total Long-Term Capital Gains/Losses', 'Amount': round(long_term_gains_losses, 2)}
    ]

    # Placeholder for other income - would need specific transaction types to capture accurately
    # For now, let's keep it simple or add if specific income tx types are defined.
    # summary_data.append({'Category': 'Total Other Crypto Income (Staking, Mining, Airdrops)', 'Amount': round(other_income, 2)})

    total_capital_gains_losses = short_term_gains_losses + long_term_gains_losses # + other_income if applicable
    summary_data.append({'Category': 'Total Taxable Capital Events', 'Amount': round(total_capital_gains_losses, 2)})

    summary_df = pd.DataFrame(summary_data)
    logger.info(f"Tax report summary generated for {tax_year}.")

    return summary_df

Demonstration and Visualization

This section demonstrates the full workflow of the crypto tax report generation using example data. We will create a sample set of raw transactions, process them to add fiat values, calculate the cost basis using the FIFO method, and finally generate a tax report summary. We will also include visualizations to help understand the capital gains/losses over time.

Example Workflow Steps:

  1. Initialize State: Start with a fresh transaction state.
  2. Add Raw Transactions: Populate the state with various types of crypto transactions (BUY, SELL, SWAP, STAKING_REWARD).
  3. Process Transactions with Fiat Values: Convert all transaction amounts to their equivalent fiat values at the time of the transaction.
  4. Calculate Cost Basis (FIFO): Apply the FIFO method to determine the cost basis and calculate capital gains/losses for taxable events.
  5. Generate Tax Report Summary: Summarize short-term and long-term capital gains/losses for a specific tax year.
  6. Visualize Results: (To be implemented) Create charts to visualize capital gains/losses, asset holdings, etc.
[15]
logger.info("Starting demonstration of the crypto tax report generation.")

# 1. Initialize State
state = create_transaction_state()
print("\n--- Initial State ---")
print(f"Raw transactions empty: {state['raw_transactions'].empty}")
print(f"Processed transactions empty: {state['processed_transactions'].empty}")

# 2. Add Raw Transactions
# Example transactions for demonstration within a two-month period (late 2025 - early 2026)
transactions_to_add = [
    # November 2025 Transactions
    {
        'timestamp': '2025-11-01 10:00:00', 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 500.0,
        'to_currency': 'BTC', 'to_amount': 0.01, 'fee_currency': 'USD', 'fee_amount': 2.0, 'description': 'Nov BTC Buy'
    },
    {
        'timestamp': '2025-11-05 12:30:00', 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 200.0,
        'to_currency': 'ETH', 'to_amount': 0.05, 'fee_currency': 'USD', 'fee_amount': 1.0, 'description': 'Nov ETH Buy'
    },
    {
        'timestamp': '2025-11-10 14:00:00', 'transaction_type': 'SELL', 'from_currency': 'BTC', 'from_amount': 0.005,
        'to_currency': 'USD', 'to_amount': 250.0, 'fee_currency': 'USD', 'fee_amount': 1.5, 'description': 'Nov BTC Sell (short-term)'
    },
    {
        'timestamp': '2025-11-15 09:00:00', 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 100.0,
        'to_currency': 'SOL', 'to_amount': 1.0, 'fee_currency': 'USD', 'fee_amount': 0.5, 'description': 'Nov SOL Buy'
    },

    # December 2025 Transactions
    {
        'timestamp': '2025-12-05 10:00:00', 'transaction_type': 'SELL', 'from_currency': 'ETH', 'from_amount': 0.02,
        'to_currency': 'USD', 'to_amount': 100.0, 'fee_currency': 'USD', 'fee_amount': 0.75, 'description': 'Dec ETH Sell (short-term)'
    },
    {
        'timestamp': '2025-12-10 14:00:00', 'transaction_type': 'SWAP', 'from_currency': 'SOL', 'from_amount': 0.5,
        'to_currency': 'BTC', 'to_amount': 0.001, 'fee_currency': 'SOL', 'fee_amount': 0.01, 'description': 'Dec Swap SOL for BTC'
    },
    {
        'timestamp': '2025-12-15 16:00:00', 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 300.0,
        'to_currency': 'ETH', 'to_amount': 0.08, 'fee_currency': 'USD', 'fee_amount': 1.2, 'description': 'Dec ETH Buy'
    },

    # January 2026 Transactions
    {
        'timestamp': '2026-01-10 09:00:00', 'transaction_type': 'BUY', 'from_currency': 'USD', 'from_amount': 150.0,
        'to_currency': 'ETH', 'to_amount': 0.04, 'fee_currency': 'USD', 'fee_amount': 0.8, 'description': 'Jan ETH Buy (2026)'
    },
    {
        'timestamp': '2026-01-20 11:00:00', 'transaction_type': 'SELL', 'from_currency': 'ETH', 'from_amount': 0.03,
        'to_currency': 'USD', 'to_amount': 180.0, 'fee_currency': 'USD', 'fee_amount': 1.0, 'description': 'Jan ETH Sell (2026, short-term)'
    }
]

for tx in transactions_to_add:
    state = add_raw_transaction(state, tx)

print("\n--- Raw Transactions Added ---")
print(state['raw_transactions'].head())
print(f"Total raw transactions: {len(state['raw_transactions'])}")
# 3. Process Transactions with Fiat Values
vs_currency = 'usd' # Explicitly define vs_currency
state = process_raw_transactions_with_fiat_value(state, vs_currency=vs_currency)
print("\n--- Processed Transactions with Fiat Values ---")
print(state['processed_transactions'].head())

# 4. Calculate Cost Basis (FIFO)
state = calculate_cost_basis_fifo(state)
print("\n--- Transactions with FIFO Cost Basis ---")
print(state['processed_transactions'][state['processed_transactions']['taxable_event']].head())
print("\n--- Current Asset Holdings ---")
print(state['asset_holdings'])

# 5. Generate Tax Report Summary for 2025 (November/December transactions)
tax_summary_2025 = generate_tax_report_summary(state, tax_year=2025, vs_currency=vs_currency)
print("\n--- Tax Report Summary for 2025 ---")
print(tax_summary_2025.to_markdown(index=False))

# 6. Generate Tax Report Summary for 2026 (for any future transactions, though none in this example)
tax_summary_2026 = generate_tax_report_summary(state, tax_year=2026, vs_currency=vs_currency)
print("\n--- Tax Report Summary for 2026 ---")
print(tax_summary_2026.to_markdown(index=False))

logger.info("Demonstration complete.")

# --- Visualizing Capital Gains/Losses ---
import matplotlib.pyplot as plt
import seaborn as sns

# 2025 Capital Gains/Losses Summary Chart
print("\n--- 2025 Capital Gains/Losses Summary Chart ---")
summary_2025_chart_data = tax_summary_2025[tax_summary_2025['Category'].isin(['Total Short-Term Capital Gains/Losses', 'Total Long-Term Capital Gains/Losses'])]

plt.figure(figsize=(10, 6))
sns.barplot(x='Category', y='Amount', data=summary_2025_chart_data, palette='viridis')
plt.title('2025 Capital Gains/Losses Summary')
plt.ylabel(f'Amount ({vs_currency.upper()})')
plt.xlabel('')
plt.xticks(rotation=45, ha='right')
plt.grid(axis='y', linestyle='--', alpha=0.7)
plt.tight_layout()
plt.show()

# 2026 Capital Gains/Losses Summary Chart
print("\n--- 2026 Capital Gains/Losses Summary Chart ---")
summary_2026_chart_data = tax_summary_2026[tax_summary_2026['Category'].isin(['Total Short-Term Capital Gains/Losses', 'Total Long-Term Capital Gains/Losses'])]

plt.figure(figsize=(10, 6))
sns.barplot(x='Category', y='Amount', data=summary_2026_chart_data, palette='magma')
plt.title('2026 Capital Gains/Losses Summary')
plt.ylabel(f'Amount ({vs_currency.upper()})')
plt.xlabel('')
plt.xticks(rotation=45, ha='right')
plt.grid(axis='y', linestyle='--', alpha=0.7)
plt.tight_layout()
plt.show()

# Cumulative Capital Gains/Losses Over Time Chart
print("\n--- Cumulative Capital Gains/Losses Over Time Chart ---")
taxable_events = state['processed_transactions'][
    state['processed_transactions']['taxable_event'] == True
].sort_values('timestamp').copy()

if not taxable_events.empty:
    taxable_events['cumulative_gain_loss'] = taxable_events['gain_loss'].cumsum()

    plt.figure(figsize=(14, 7))
    sns.lineplot(x='timestamp', y='cumulative_gain_loss', data=taxable_events, marker='o')
    plt.title(f'Cumulative Capital Gains/Losses Over Time ({vs_currency.upper()})')
    plt.xlabel('Date')
    plt.ylabel(f'Cumulative Gain/Loss ({vs_currency.upper()})')
    plt.grid(True, linestyle='--', alpha=0.7)
    plt.xticks(rotation=45)
    plt.tight_layout()
    plt.show()
else:
    print("No taxable events to visualize cumulative gain/loss.")

--- Initial State ---
Raw transactions empty: True
Processed transactions empty: True

--- Raw Transactions Added ---
            timestamp transaction_type from_currency  from_amount to_currency  \
0 2025-11-01 10:00:00              BUY           USD      500.000         BTC   
1 2025-11-05 12:30:00              BUY           USD      200.000         ETH   
2 2025-11-10 14:00:00             SELL           BTC        0.005         USD   
3 2025-11-15 09:00:00              BUY           USD      100.000         SOL   
4 2025-12-05 10:00:00             SELL           ETH        0.020         USD   

   to_amount fee_currency  fee_amount                description  
0       0.01          USD        2.00                Nov BTC Buy  
1       0.05          USD        1.00                Nov ETH Buy  
2     250.00          USD        1.50  Nov BTC Sell (short-term)  
3       1.00          USD        0.50                Nov SOL Buy  
4     100.00          USD        0.75  Dec ETH Sell (short-term)  
Total raw transactions: 9
Processing solana (SOL)
/tmp/ipykernel_1357/2902126469.py:77: FutureWarning: The behavior of DataFrame concatenation with empty or all-NA entries is deprecated. In a future version, this will no longer exclude empty or all-NA columns when determining the result dtypes. To retain the old behavior, exclude the relevant entries before the concat operation.
  state['raw_transactions'] = pd.concat([state['raw_transactions'], new_df], ignore_index=True)
Processing ethereum (ETH)
Processing bitcoin (BTC)

--- Processed Transactions with Fiat Values ---
            timestamp transaction_type from_currency  from_amount  \
0 2025-11-01 10:00:00              BUY           USD      500.000   
1 2025-11-05 12:30:00              BUY           USD      200.000   
2 2025-11-10 14:00:00             SELL           BTC        0.005   
3 2025-11-15 09:00:00              BUY           USD      100.000   
4 2025-12-05 10:00:00             SELL           ETH        0.020   

   fiat_value_from to_currency  to_amount  fiat_value_to fee_currency  \
0       500.000000         BTC       0.01    1100.320409          USD   
1       200.000000         ETH       0.05     167.788283          USD   
2       529.164312         USD     250.00     250.000000          USD   
3       100.000000         SOL       1.00     141.400522          USD   
4        62.185913         USD     100.00     100.000000          USD   

   fee_amount  fiat_value_fee                description vs_currency  
0        2.00            2.00                Nov BTC Buy         usd  
1        1.00            1.00                Nov ETH Buy         usd  
2        1.50            1.50  Nov BTC Sell (short-term)         usd  
3        0.50            0.50                Nov SOL Buy         usd  
4        0.75            0.75  Dec ETH Sell (short-term)         usd  

--- Transactions with FIFO Cost Basis ---
            timestamp transaction_type from_currency  from_amount  \
2 2025-11-10 14:00:00             SELL           BTC        0.005   
4 2025-12-05 10:00:00             SELL           ETH        0.020   
5 2025-12-10 14:00:00             SWAP           SOL        0.500   
8 2026-01-20 11:00:00             SELL           ETH        0.030   

   fiat_value_from to_currency  to_amount  fiat_value_to fee_currency  \
2       529.164312         USD    250.000     250.000000          USD   
4        62.185913         USD    100.000     100.000000          USD   
5        68.932804         BTC      0.001      92.403625          SOL   
8        92.602852         USD    180.000     180.000000          USD   

   fee_amount  fiat_value_fee                      description vs_currency  \
2        1.50        1.500000        Nov BTC Sell (short-term)         usd   
4        0.75        0.750000        Dec ETH Sell (short-term)         usd   
5        0.01        1.378656             Dec Swap SOL for BTC         usd   
8        1.00        1.000000  Jan ETH Sell (2026, short-term)         usd   

   cost_basis  gain_loss    holding_period  taxable_event processed_asset  
2  550.160204 -22.495893   9 days 04:00:00           True             BTC  
4   67.115313  -5.679400  29 days 21:30:00           True             ETH  
5   70.700261  -3.146113  25 days 05:00:00           True             SOL  
8  100.672970  -9.070118  75 days 22:30:00           True             ETH  

--- Current Asset Holdings ---
  asset  quantity  cost_basis_per_unit  total_cost_basis
0   BTC     0.006        107093.971579        642.563829
1   ETH     0.120          3072.345845        368.681501
2   SOL     0.500           141.400522         70.700261

--- Tax Report Summary for 2025 ---
| Category                              |   Amount |
|:--------------------------------------|---------:|
| Total Short-Term Capital Gains/Losses |   -31.32 |
| Total Long-Term Capital Gains/Losses  |     0    |
| Total Taxable Capital Events          |   -31.32 |

--- Tax Report Summary for 2026 ---
| Category                              |   Amount |
|:--------------------------------------|---------:|
| Total Short-Term Capital Gains/Losses |    -9.07 |
| Total Long-Term Capital Gains/Losses  |     0    |
| Total Taxable Capital Events          |    -9.07 |

--- 2025 Capital Gains/Losses Summary Chart ---
/tmp/ipykernel_1357/1527728109.py:135: FutureWarning: Setting an item of incompatible dtype is deprecated and will raise an error in a future version of pandas. Value '9 days 04:00:00' has dtype incompatible with datetime64[ns], please explicitly cast to a compatible dtype first.
  processed_tx.loc[i, 'holding_period'] = holding_period
/tmp/ipykernel_1357/683480752.py:95: FutureWarning: 

Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `x` variable to `hue` and set `legend=False` for the same effect.

  sns.barplot(x='Category', y='Amount', data=summary_2025_chart_data, palette='viridis')
cell output

--- 2026 Capital Gains/Losses Summary Chart ---
/tmp/ipykernel_1357/683480752.py:109: FutureWarning: 

Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `x` variable to `hue` and set `legend=False` for the same effect.

  sns.barplot(x='Category', y='Amount', data=summary_2026_chart_data, palette='magma')
cell output

--- Cumulative Capital Gains/Losses Over Time Chart ---
cell output

Conclusion

This notebook provides a foundational framework for generating crypto tax reports using the FIFO method. It demonstrates the process from raw transaction input, through fiat valuation, cost basis calculation, and finally to a summarized tax report. The visualizations offer a clear overview of capital gains and losses, which is essential for tax compliance and financial planning.