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.
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
| Concept | Description |
|---|---|
| Capital Gains/Losses | The 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 Basis | The 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 Events | Actions 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 Period | The 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 Rule | A 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 Keeping | Maintaining 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.
# Install necessary packages
%pip install pandas numpy requests tenacity loguruRequirement 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) [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m61.6/61.6 kB[0m [31m4.2 MB/s[0m eta [36m0:00:00[0m [?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.
# 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:
- Initialize an empty pandas DataFrame for
raw_transactionswith predefined columns such astimestamp,transaction_type,from_currency,from_amount,to_currency,to_amount,fee_currency,fee_amount, anddescription. - Initialize another empty pandas DataFrame for
processed_transactionswith columns relevant for tax calculations, includingtimestamp,transaction_type,asset,amount,fiat_value,cost_basis,gain_loss,holding_period,taxable_event, anddescription. - Initialize an empty dictionary for
api_configsto store any necessary API keys or configuration parameters. - 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 containingraw_transactions,processed_transactions, andapi_configsDataFrames/dictionaries.
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 stateFunction 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:
- Validate that the input
transactiondictionary contains all required keys. - Convert the
timestampto a datetime object if it's not already. - Create a pandas Series from the
transactiondictionary. - Append the new Series as a new row to the
raw_transactionsDataFrame in thestate. - Log the successful addition of the transaction.
Parameters:
state(dict): The current state dictionary containing theraw_transactionsDataFrame.transaction(dict): A dictionary representing a single transaction with keys liketimestamp,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 toraw_transactions.
Raises:
ValueError: If a required key is missing in the transaction data.
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 stateFunction 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:
- Construct the API endpoint URL for CoinGecko's historical price data, specifying the cryptocurrency ID, target currency (e.g., USD), and date range.
- Use
@retrydecorator fromtenacityto implement exponential backoff and retry up to a specified number of attempts for API calls. - Make an HTTP GET request to the CoinGecko API.
- Parse the JSON response and extract the daily price data.
- Convert the price data into a pandas DataFrame with
timestampandpricecolumns. - Handle potential API errors (e.g., invalid currency, rate limits).
- 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.
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}")
raiseFunction 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:
- 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'). - Convert the input
symbolto lowercase for case-insensitive matching. - Check if the lowercase
symbolexists as a key in the mapping dictionary. - If a match is found, return the corresponding CoinGecko ID.
- If no direct match is found, assume the input
symbolitself is a valid CoinGecko ID (or a close enough approximation) and return it, after converting to lowercase. - 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.
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_idFunction 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:
- Initialize a DataFrame to store processed transactions.
- Identify all unique cryptocurrencies (from
from_currencyandto_currencyfields that are not fiat) and the overall date range needed for price fetching. - For each unique cryptocurrency, fetch its historical price data using
fetch_crypto_price_history. - Store the fetched prices in a dictionary for quick lookup.
- Iterate through each raw transaction:
a. Determine the fiat value of the
from_amountandto_amountusing the fetched prices andget_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. - Convert the list of enriched transactions into a pandas DataFrame.
- Update the
processed_transactionsDataFrame in the state.
Parameters:
state(dict): The current state dictionary containingraw_transactions.vs_currency(str): The target fiat currency for valuation (e.g., 'usd').
Returns:
dict: The updated state dictionary with theprocessed_transactionsDataFrame populated with fiat values.
Raises:
ValueError: If a required price for a transaction date cannot be found.
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 stateFunction 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:
- Initialize or update the
asset_holdingsDataFrame within the state, which tracks the quantity and cost basis of each held asset. - Sort the
processed_transactionsDataFrame by timestamp to ensure chronological processing. - Iterate through each transaction in the sorted DataFrame:
a. BUY/RECEIVE: Add the acquired amount to the
asset_holdingsat 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. Updateasset_holdingsby 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. - Update the
processed_transactionsDataFrame with calculatedcost_basis,gain_loss,holding_period, andtaxable_eventflags. - Log important steps and outcomes, including any remaining holdings or warnings about insufficient assets for sale.
Parameters:
state(dict): The current state dictionary containingprocessed_transactionsandasset_holdingsDataFrames.
Returns:
dict: The updated state dictionary withprocessed_transactionsenriched with cost basis information andasset_holdingsreflecting current positions.
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 stateFunction 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:
- Filter the
processed_transactionsDataFrame to include only taxable events within the specifiedtax_year. - Separate these taxable events into short-term (holding period <= 365 days) and long-term (holding period > 365 days) categories.
- Calculate the total capital gain/loss for both short-term and long-term categories.
- Optionally, include other taxable events like income from staking, mining, or airdrops (though these might be handled by separate functions not yet defined).
- Aggregate the results into a clear, tabular format.
- Log the summary generation process and the results.
Parameters:
state(dict): The current state dictionary containing theprocessed_transactionsDataFrame.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.
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_dfDemonstration 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:
- Initialize State: Start with a fresh transaction state.
- Add Raw Transactions: Populate the state with various types of crypto transactions (BUY, SELL, SWAP, STAKING_REWARD).
- Process Transactions with Fiat Values: Convert all transaction amounts to their equivalent fiat values at the time of the transaction.
- Calculate Cost Basis (FIFO): Apply the FIFO method to determine the cost basis and calculate capital gains/losses for taxable events.
- Generate Tax Report Summary: Summarize short-term and long-term capital gains/losses for a specific tax year.
- Visualize Results: (To be implemented) Create charts to visualize capital gains/losses, asset holdings, etc.
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')
--- 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')
--- Cumulative Capital Gains/Losses Over Time Chart ---
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.