The "keep it simple" backtesting framework
kissbt
kissbt ("keep it simple backtesting") is a small Python backtesting framework for people who want clear execution semantics, a compact API, and deterministic CLI output. It is built for pandas-based strategy research, scripted backtest runs, and machine-friendly result handling without the weight of a large framework. It stays comfortable for simple single-asset ideas, but is also flexible enough for multi-asset and whole-universe workflows.
Why kissbt?
- Small public API:
Strategy,Broker,Engine,Analyzer - Clear next-bar execution model with explicit
OPEN,CLOSE, andLIMITbehavior - Works directly with pandas
DataFrameinput using("timestamp", "ticker")MultiIndex data - Flexible enough for single-asset, multi-asset, and whole-universe strategies
- Supports long-only and long/short workflows
- Structured backtest results for Python code and deterministic JSON output for shell/CI/agents
- Fails fast on invalid inputs instead of silently guessing
Installation
Install with pip:
pip install kissbt
Install with parquet support:
pip install "kissbt[parquet]"
Install with uv:
uv add kissbt
Or with conda:
conda install -c conda-forge kissbt
Supported Python versions: 3.12 to 3.14.
Quickstart
This example is intentionally small enough to verify by inspection.
import pandas as pd
from kissbt import Broker, Engine, Order, Strategy
class BuyAndHoldOnce(Strategy): def initialize(self) -> None: self.has_bought = False
def generateorders(self, currentdata, current_timestamp) -> None: if not self.has_bought: self.broker.place_order(Order(ticker="AAPL", size=10)) self.has_bought = True
index = pd.MultiIndex.from_tuples( [ (pd.Timestamp("2024-01-01"), "AAPL"), (pd.Timestamp("2024-01-02"), "AAPL"), ], names=["timestamp", "ticker"], )
market_data = pd.DataFrame( { "open": [100.0, 101.0], "high": [102.0, 103.0], "low": [99.0, 100.0], "close": [101.0, 102.0], }, index=index, )
broker = Broker(startcapital=10000) strategy = BuyAndHoldOnce(broker) engine = Engine(broker, strategy) result = engine.run(market_data)
trade = result.closed_positions[0] print(trade.entry_price) print(trade.exit_price) print(round(result.finalportfoliovalue, 2))
Expected output:
101.0
102.0
10007.97
Why those numbers:
- The order is placed on
2024-01-01 - It executes on the next bar at the
2024-01-02openprice of101.0 Engine.run(...)liquidates the remaining position on the final bar at the same day'scloseprice of102.0- With the default
0.1%fee on entry and exit, final portfolio value becomes10007.97
Execution Model
kissbt uses a simple next-bar execution model:
Strategy.generate_orders(...)runs after the broker has processed the current bar- Orders placed during bar
tare evaluated on bart + 1 OPENorders use the next baropenCLOSEorders use the next barcloseLIMITorders use the next baropen/high/lowaccording to the limit-fill rulesEngine.run(...)liquidates any remaining positions on the final bar after strategy execution
- If a held ticker disappears from the current universe,
Broker.update(...)closes it at the previous barclose - Good-till-cancel orders remain pending across later bars when unfilled, including when a ticker is temporarily missing
Input Data Requirements
Engine.run(data) expects a pandas DataFrame with:
- Exactly two index levels named
("timestamp", "ticker") - Unique
("timestamp", "ticker")rows - Required columns:
open,close - Additional columns for
LIMITorders:high,low - If
Broker(benchmark=...)is configured, the benchmark ticker must be present for every timestamp
timestamp and ticker columns and converts them into the required MultiIndex shape.
Flexible Strategy Workflows
Each call to Strategy.generate_orders(...) receives the full bar for the current timestamp as a DataFrame indexed by ticker. That makes it natural to:
- Run a single instrument strategy
- Scan a watchlist of symbols
- Rank, filter, or rebalance across a whole universe on each bar
Engine.run(...).
Python API
Define a strategy:
from kissbt import Order, OrderType, Strategy
class MyStrategy(Strategy): def generateorders(self, currentdata, current_timestamp) -> None: for ticker in current_data.index: closeprice = currentdata.loc[ticker, "close"] sma128 = currentdata.loc[ticker, "sma_128"] if closeprice > sma128: self.broker.place_order( Order(ticker=ticker, size=10, order_type=OrderType.OPEN) )
current_data is the full cross-section for the current timestamp, so the same strategy shape works for one ticker, a small basket, or a full universe.
Create a broker and engine, then run the backtest:
from kissbt import Broker, Engine
broker = Broker(start_capital=100000, fees=0.001) strategy = MyStrategy(broker) engine = Engine(broker, strategy) result = engine.run(market_data)
result is a BacktestResult with:
historyclosed_positionsfinalportfoliovalue
from kissbt import Analyzer
metrics = Analyzer(broker).getperformancemetrics() print(metrics["total_return"])
CLI And Automation
The CLI is designed for shell scripts, CI jobs, and agent workflows that need strict, machine-consumable output.
Run a backtest
Create a strategy module, for example mystrategies/goldencross.py:
from kissbt import Order, Strategy
class GoldenCrossStrategy(Strategy): def generateorders(self, currentdata, current_timestamp) -> None: for ticker in current_data.index: if ( currentdata.loc[ticker, "sma128"] >= currentdata.loc[ticker, "sma256"] and ticker not in self.broker.open_positions ): self.broker.place_order(Order(ticker=ticker, size=1))
Write JSON to a file:
kissbt backtest \
--input tests/data/tech_stocks.parquet \
--strategy mystrategies.goldencross:GoldenCrossStrategy \
--benchmark SPY \
--output backtest_result.json
Or write JSON to stdout:
kissbt backtest \
--input tests/data/tech_stocks.parquet \
--strategy mystrategies.goldencross:GoldenCrossStrategy
Parquet input requires an installed parquet engine such as pyarrow. The kissbt[parquet] extra installs that dependency for you.
Useful flags:
--input-format auto|csv|parquet--start-capital 100000--fees 0.001--allow-short--short-fee-rate 0.005--benchmark SPY--bar-size 1D
- Invalid inputs exit with a non-zero status
- Errors are printed as concise user-facing messages
- Non-finite numeric values in JSON are normalized to
null
JSON output contract
The command writes a JSON report with:
summarymetricsclosed_positionsevents
summary contains:
barsfinalportfoliovalueclosed_positionsevents
Example shape:
{
"summary": {
"bars": 252,
"finalportfoliovalue": 108734.12,
"closed_positions": 14,
"events": 28
},
"metrics": {
"total_return": 0.0873412,
"profit_factor": 1.91
},
"closed_positions": [
{
"ticker": "AAPL",
"size": 10.0,
"entry_price": 100.0,
"entry_timestamp": "2024-01-02T00:00:00",
"exit_price": 108.5,
"exit_timestamp": "2024-01-15T00:00:00",
"pnl": 85.0
}
],
"events": [
{
"type": "order_executed",
"timestamp": "2024-01-02T00:00:00",
"ticker": "AAPL",
"size": 10.0,
"order_type": "open",
"price": 100.0
}
]
}
When kissbt is a good fit
kissbt is a good fit if you want:
- A Python backtesting engine that stays easy to read end-to-end
- pandas-first research workflows over OHLC or indicator-enriched data
- Flexible strategies that scale from one instrument to a whole universe
- Reproducible batch backtests from the command line
- Deterministic JSON output for automation, reporting, or agent orchestration
Development
For development, this repository uses uv.
- Development baseline: Python
3.13 - Supported Python versions:
3.12to3.14
uv python install 3.13
uv venv --python 3.13
uv sync --extra dev
uv run ruff format .
uv run ruff check .
uv run mypy kissbt tests
uv run pytest