A Python backtesting library for portfolio management and trading strategies
Project description
Pyfolium
Hätte hätte Fahrradkette — If only, if only...
Pyfolium is an income-aware, tax-aware, and fee-aware portfolio backtesting engine for Python. It simulates historical portfolio evolution period-by-period, enforces correct transaction ordering, and returns clean pandas DataFrames (portfolio.history, portfolio.transactions) that plug into whichever analytics or visualization libraries you prefer. It deliberately excludes performance analytics, visualization, data fetching, and optimization — keeping the library small, fast, and composable.
A Python backtesting library for portfolio management with support for taxes, fees, income, and custom trading strategies.
Philosophy & Scope
Pyfolium is a backtesting engine, not a full-featured analytics platform.
What it does: Income-aware, tax-aware, fee-aware portfolio backtesting with enforced transaction ordering, an extensible strategy framework, and pandas-based data structures.
What it doesn't do: Data fetching, performance analytics, visualization, optimization, risk models.
Pyfolium outputs clean pandas DataFrames (portfolio.history, portfolio.transactions) that plug into whichever libraries you prefer for those tasks.
Installation
pip install pyfolium
Or with uv:
uv add pyfolium
Quick Start
import pandas as pd
from pyfolium import (
Asset,
AssetUniverse,
BacktestRunner,
BaseStrategy,
OutputMode,
Portfolio,
)
# 1. Create asset universe
universe = AssetUniverse(data_frequency='D')
# 2. Add assets with price data
dates = pd.period_range('2023-01-01', periods=252, freq='D')
data = pd.DataFrame({
'price': [100 + i * 0.5 for i in range(252)],
'income': [1.0 if i % 60 == 0 else 0.0 for i in range(252)]
}, index=dates)
Asset('Stock', universe, data)
print(f"{universe}\n")
# 3. Define strategy — initial_cash seeds the portfolio on the first period
class BuyAndHold(BaseStrategy):
def __init__(self, portfolio, initial_cash):
super().__init__(portfolio, initial_cash=initial_cash)
self.invested = False
def get_trades(self):
if not self.invested:
self.invested = True
price = self.asset_universe.price_matrix.loc[
self.portfolio.current_period, 'Stock'
]
return [('Stock', self.initial_cash/price)]
return []
# 4. Create portfolio and run backtest
portfolio = Portfolio(universe)
runner = BacktestRunner(portfolio, BuyAndHold(portfolio, 50000))
result = runner.run(output=OutputMode.PROGRESS)
print(f"\nFinal portfolio value: {result.portfolio.total_value:,.2f}")
print(portfolio)
BacktestRunner
Automates the period-by-period simulation loop:
# Simple usage
runner = BacktestRunner(portfolio, strategy)
result = runner.run()
# With progress bar
result = runner.run(output=OutputMode.PROGRESS)
# Custom hooks (callback receives the runner instance)
def log_value(runner):
print(f"Period {runner.current_period}: {runner.portfolio.cash:,.2f}")
runner.register_hook('period_end', log_value)
result = runner.run()
# Step-by-step control
runner = BacktestRunner(portfolio, strategy)
for _ in range(10):
runner.run_period()
if runner.portfolio.cash < 0:
break
Available Hooks: period_start, period_end, backtest_start, backtest_end, error
Logging & Observability
BacktestRunner captures structured log entries during execution. Control terminal output with OutputMode:
result = runner.run(output=OutputMode.SILENT) # default — log in result only
result = runner.run(output=OutputMode.SUMMARY) # one-line summary at end
result = runner.run(output=OutputMode.PROGRESS) # tqdm bar + summary
After a run, inspect result.log (list of LogEntry), result.log_df (DataFrame view), result.errors, result.warnings, and result.success. Strategies can emit entries via self.log(Severity.WARNING, "message") inside get_trades().
Strategy Comparison
Clone portfolios to compare strategies:
base = Portfolio(universe)
clone1 = base.clone()
clone2 = base.clone()
result1 = BacktestRunner(clone1, Strategy1(clone1, initial_cash=100000)).run()
result2 = BacktestRunner(clone2, Strategy2(clone2, initial_cash=100000)).run()
print(f"Strategy 1: {result1.portfolio.cash:,.2f}")
print(f"Strategy 2: {result2.portfolio.cash:,.2f}")
Custom Strategies
Subclass BaseStrategy and implement get_trades():
class MomentumStrategy(BaseStrategy):
def __init__(self, portfolio, lookback=20, **kwargs):
super().__init__(portfolio, parameters={'lookback': lookback}, **kwargs)
self.lookback = lookback
def get_trades(self):
# Return list of (symbol, quantity) tuples
# Positive quantity = buy, negative = sell
return [('Stock', 10), ('Aktie', -5)]
# initial_cash and start_period are keyword-only params on BaseStrategy
strategy = MomentumStrategy(portfolio, lookback=30, initial_cash=50000)
# start_period lets a strategy self-describe its warmup requirement
strategy = MomentumStrategy(portfolio, initial_cash=50000, start_period=dates[30])
Tax and Fee Configuration
from pyfolium import TaxConfig, FeeConfig
tax_config = TaxConfig(
short_term_rate=0.30,
long_term_rate=0.15,
long_term_holding_period=pd.DateOffset(years=1),
withhold_tax=True,
tax_strategy='FIFO', # or 'LIFO' or 'AVERAGE'
allow_specific_lot=False, # True to enable sell_lot()
)
fee_config = FeeConfig(
fixed_fee=1.0,
percentage_fee=0.001, # 0.1%
minimum_fee=1.0,
maximum_fee=20.0
)
portfolio = Portfolio(universe, tax_config=tax_config, fee_config=fee_config)
Both TaxConfig and FeeConfig can be subclassed for custom rules. Some of their methods receive trade context — including the asset's metadata dict — enabling per-asset-class taxes and fees. Example:
Asset(symbol="Anleihe", asset_universe=universe, data=bond_data,
price_column="price", metadata={"asset_class": "fixed_income"})
class AssetClassFeeConfig(FeeConfig):
def calculate_fee(self, transaction_value, *, metadata=None, **kw):
if (metadata or {}).get("asset_class") == "fixed_income":
return transaction_value * 0.001 # 0.1% for bonds
return transaction_value * 0.01 # 1% for equities
Data Loading
from pyfolium import load_from_csv, load_from_dataframe
# load_from_csv and load_from_dataframe return DataFrames
# ready to pass to the Asset constructor
data = load_from_csv('prices.csv', frequency='D', income_column='Dividend')
universe = AssetUniverse(data_frequency='D')
Asset('Stock', universe, data, income_column='income')
Development Setup
# First-time setup
git clone https://github.com/whnr/pyfolium
cd pyfolium
uv sync
uv run setup-dev # Installs pre-commit hooks
# Run tests
uv run pytest
# Format code
uv run ruff format .
# Lint
uv run ruff check --fix .
# Type check
uv run pyright pyfolium/
# Run all pre-commit checks manually
uv run pre-commit run --all-files
Project Structure
pyfolium/
├── core.py # Portfolio, Asset, AssetUniverse, TaxLot, configs
├── strategy.py # BaseStrategy ABC
├── simulation.py # BacktestRunner, BacktestResult
├── logging.py # Severity, LogEntry, OutputMode
└── data.py # Data loading utilities
tests/
├── core/ # Core component tests
├── strategy/ # Strategy framework tests
└── simulation/ # BacktestRunner tests
examples/ # Usage examples
Architecture
For the full design rationale — modeling philosophy, data flow, tax system design, and architectural decisions — see DESIGN.md.
Period State Machine
Each period follows strict sequencing:
COLLECT_INCOME → TRANSACT → DONE → advance_period() → COLLECT_INCOME
collect_income()- Distribute dividendsbuy_asset()/sell_asset()/move_cash()- Execute tradesupdate_history()- Record period stateadvance_period()- Move to next period
Tax Lot Tracking
TaxLotdataclass: first-class lot records with symbol, period, quantity, cost basis- FIFO, LIFO, or AVERAGE cost basis accounting via
sell_asset() - Specific lot targeting via
sell_lot(lot, quantity)for tax-loss harvesting (requiresallow_specific_lot=True) - Per-share cost basis tracking
- Automatic short/long-term classification
- Optional immediate tax withholding
portfolio.open_lotsexposes open lots to strategies
Examples
See examples/backtest_runner_example.py for comprehensive examples including:
- Simple usage
- Output modes (silent, summary, progress bar)
- Custom hooks
- Strategy logging
- Strategy comparison
- Step-by-step execution
License
MIT License - see LICENSE file for details.
Contributing
- Follow existing code style (use
ruff format) - Add tests for new features
- Run
uv run pre-commit run --all-files
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyfolium-0.1.0.tar.gz.
File metadata
- Download URL: pyfolium-0.1.0.tar.gz
- Upload date:
- Size: 26.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
68fc9edd8f2098d31b698fbcad125f655b801cf55cc0578ebe80500389b5b8a9
|
|
| MD5 |
bac9a0c5ebd7edf59e76a6376f82a775
|
|
| BLAKE2b-256 |
1c06f6c5f20d86b42001db7c6c936850c643c1fb6f14b8d873f3c5b9d643fe17
|
Provenance
The following attestation bundles were made for pyfolium-0.1.0.tar.gz:
Publisher:
publish.yml on whnr/pyfolium
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyfolium-0.1.0.tar.gz -
Subject digest:
68fc9edd8f2098d31b698fbcad125f655b801cf55cc0578ebe80500389b5b8a9 - Sigstore transparency entry: 1088861529
- Sigstore integration time:
-
Permalink:
whnr/pyfolium@844f3f6e7c9e30d13e135d8566bf6a7258fd903b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/whnr
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@844f3f6e7c9e30d13e135d8566bf6a7258fd903b -
Trigger Event:
release
-
Statement type:
File details
Details for the file pyfolium-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pyfolium-0.1.0-py3-none-any.whl
- Upload date:
- Size: 29.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
df1d0684afa869e01d1848a400d20afafd4aa93eee5f39cbd7e556104787262e
|
|
| MD5 |
e596200c09add7283c9ccbfa19f7693f
|
|
| BLAKE2b-256 |
cffa63d4b1a568cb5c28f618df2fbb43393b6f16cbf413ee367be678af604c2d
|
Provenance
The following attestation bundles were made for pyfolium-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on whnr/pyfolium
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyfolium-0.1.0-py3-none-any.whl -
Subject digest:
df1d0684afa869e01d1848a400d20afafd4aa93eee5f39cbd7e556104787262e - Sigstore transparency entry: 1088861589
- Sigstore integration time:
-
Permalink:
whnr/pyfolium@844f3f6e7c9e30d13e135d8566bf6a7258fd903b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/whnr
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@844f3f6e7c9e30d13e135d8566bf6a7258fd903b -
Trigger Event:
release
-
Statement type: