GeneralBacktest is a flexible and efficient quantitative strategy backtesting framework designed for multi-asset portfolio strategies. Its core workflow is:
weight -> trades -> NAV
It supports arbitrary rebalancing schedules, realistic trading details, vectorized performance, and rich analytics.
- English (this file): README.md
- Chinese: README.zh-CN.md
- Changelog: CHANGELOG.md
- Flexible rebalancing: rebalance on any dates, not limited to fixed intervals.
- High performance: vectorized implementation for large universes and long histories.
- Realistic trading simulation:
- Rebalance threshold control (avoid tiny adjustments)
- Separate buy/sell transaction cost
- Slippage modeling
- Rebalance-day PnL split (hold/sell/buy)
- Dynamic total exposure control via
position_ratio_col(v1.1.0). - Cash-based backtest mode via
run_backtest_with_cash(v1.1.0). - Enhanced visualization (log-scale NAV and dual-scale NAV, v1.1.0).
- T+0 (intraday round-trip) backtesting via
TBacktest.run_t0_backtest()(v1.2.0). - 15+ performance metrics and 10+ plotting utilities.
pip install GeneralBacktestIf you need run_backtest_ETF() or run_backtest_stock():
pip install GeneralBacktest[database]pip install GeneralBacktest[full]- Required:
numpy,pandas,matplotlib - Optional:
quantchdbfor database-backed ETF/stock dataopenpyxlfor Excel export
from GeneralBacktest import GeneralBacktest
import pandas as pd
weights_data = pd.DataFrame({
"date": ["2023-01-01", "2023-01-01", "2023-06-01", "2023-06-01"],
"code": ["stock_A", "stock_B", "stock_A", "stock_B"],
"weight": [0.6, 0.4, 0.3, 0.7]
})
price_data = pd.DataFrame({
"date": pd.date_range("2023-01-01", "2023-12-31", freq="D"),
"code": "stock_A",
"open": [...],
"close": [...],
"adj_factor": [...]
})
bt = GeneralBacktest(start_date="2023-01-01", end_date="2023-12-31")
results = bt.run_backtest(
weights_data=weights_data,
price_data=price_data,
buy_price="open",
sell_price="close",
adj_factor_col="adj_factor",
close_price_col="close",
rebalance_threshold=0.005,
transaction_cost=[0.001, 0.001],
slippage=0.0005,
initial_capital=1.0
)
bt.print_metrics()
bt.plot_all()
bt.plot_nav_curve()
bt.plot_monthly_returns()Set total invested ratio per rebalance date (e.g., 80% invested, 20% cash):
weights_data = pd.DataFrame({
"date": ["2023-01-01", "2023-01-01", "2023-06-01", "2023-06-01"],
"code": ["stock_A", "stock_B", "stock_A", "stock_B"],
"weight": [0.6, 0.4, 0.3, 0.7],
"position_ratio": [0.8, 0.8, 0.9, 0.9]
})
results = bt.run_backtest(
weights_data=weights_data,
price_data=price_data,
position_ratio_col="position_ratio"
)Use actual capital and lot-size constraints for execution-accurate simulation:
results = bt.run_backtest_with_cash(
weights_data=weights_data,
price_data=price_data,
initial_capital=1_000_000,
buy_price="open",
sell_price="close",
close_price_col="close",
lot_size=100,
trade_critic="weight_desc",
transaction_cost=[0.001, 0.001],
slippage=0.001
)
print(f"Final NAV: {results['nav_series'].iloc[-1]:,.2f}")
print(f"Final Cash: {results['cash_series'].iloc[-1]:,.2f}")
print(f"Cash Ratio: {results['metrics']['Cash Ratio']:.2%}")Pass a volume_data DataFrame to add a strict tradable-volume upper bound
per (date, code). This decouples the framework from any specific intraday
model: the user computes tradable_shares externally (e.g., from minute
data, VWAP participation, order-book depth) and simply hands the result to
the backtester.
# volume_data columns: date, code, tradable_shares
# (any (date, code) not present -> STRICTLY 0, i.e. not tradable that day)
results = bt.run_backtest_with_cash(
weights_data=weights_data,
price_data=price_data,
initial_capital=1_000_000,
buy_price="open",
sell_price="close",
close_price_col="close",
lot_size=100,
volume_data=volume_df,
volume_col="tradable_shares", # or use buy_volume_col/sell_volume_col to split
)
m = results['metrics']
print(f"Avg Fill Ratio: {m['平均订单填充率']:.2%}")
print(f"Volume-Constrained %: {m['量约束订单占比']:.2%}")
print(f"Total Orders: {m['订单总数']}")Trade records add intended_shares and constraint_hit columns
('none' | 'cash' | 'volume').
When the buy slot is earlier than the sell slot (e.g. buy_price = VWAP
around 10:00 while sell_price = VWAP around 14:50), the classic
"sell-first" execution implicitly uses same-day sell proceeds that have
not yet physically occurred to fund the morning buy — violating the true
A-share T+1 timing. v1.3.0 introduces the execution_order parameter
plus a dual-track backtest to model this correctly.
# Dual-track: split initial_capital into two logical cash pools A / B
# Day T : A buys signal_T at 10:00 with cash_A ; B sells signal_{T-1} at 14:50
# Day T+1 : B buys signal_{T+1} with cash_B ; A sells signal_T
# Each track holds for exactly 1 day -> naturally T+1 compliant
results = bt.run_backtest_with_cash(
weights_data=weights_data,
price_data=price_data,
initial_capital=1_000_000,
buy_price="vwap_1000", # 10:00 VWAP
sell_price="vwap_1450", # 14:50 VWAP
close_price_col="close",
execution_order="buy_first", # enable dual-track
dual_track_config={
"imbalance_threshold": 0.10, # rebalance if |cap_A-cap_B|/total > 0.10
"rebalance_gain": 0.5, # first-order convergence gain
"initial_split": 0.5, # initial cash_A / total
"first_buy_track": "A", # Day 0 first-buy track
},
)
# Extra fields on the result dict
results["track_a"] # {'nav_series', 'cash_series'}
results["track_b"]
results["imbalance_series"] # per-day imbalance record
results["rebalance_events"] # cash rebalancing events
m = results["metrics"]
print(f"Max Imbalance: {m['最大不平衡度']:.2%}")
print(f"Rebalance Count: {m['再平衡次数']}")trade_records / daily_positions gain a track column ('A' / 'B')
when running in dual-track mode. If execution_order is left unspecified,
behavior is identical to v1.2.x.
run_backtest_ETF() and run_backtest_stock() need a valid database config. For general usage, run_backtest() is recommended.
TBacktest supports same-day sell → buy cycles (T+0 strategies). weight is the target position, not the trading amount. The phase column controls intraday execution order:
from GeneralBacktest import TBacktest
tb = TBacktest(start_date='2024-01-01', end_date='2024-12-31')
results = tb.run_t0_backtest(
weights_data=t0_weights, # includes 'phase' column
price_data=price_data,
buy_price='close', # configurable
sell_price='open', # configurable
adj_factor_col='adj_factor',
close_price_col='close',
transaction_cost=[0.001, 0.001]
)
# T+0 dedicated visualization
tb.plot_intraday_trades() # NAV + intraday trade markers
tb.plot_t0_returns_breakdown() # sell vs buy return decomposition
tb.plot_nav_vs_benchmark() # strategy vs benchmark comparisonWeights data format with phase column:
| date | code | weight | phase |
|---|---|---|---|
| 2024-01-02 | stock_A | 1.0 | NaN |
| 2024-01-03 | stock_A | 0.5 | sell |
| 2024-01-03 | stock_A | 1.0 | buy |
A-share compliance is enforced:
buy_phasemust followsell_phasein time order- Net buy on each day: total sell ≤ total buy (no naked shorting)
- Target positions capped at [0, 1]
T+0-specific metrics: sell win rate, buy win rate, sell/buy cumulative return, return contribution ratio, average commission rate.
When position_ratio_col is provided:
- Normalize per-date asset weights so they sum to 1.
- Multiply normalized weights by
position_ratioof that date. - Final sum of target weights equals
position_ratio; the remaining part is cash.
Example:
- Raw weights: A=0.6, B=0.4
position_ratio= 0.8- Final weights: A=0.48, B=0.32
- Cash = 0.2
| Item | run_backtest() |
run_backtest_with_cash() |
|---|---|---|
| Capital unit | Relative weight (0-1) | Absolute amount |
| Position tracking | Weights | Share quantity |
| Lot size constraint | No | Yes (lot_size) |
| Cash constraint | No | Yes |
| Adj factor | Required for adjusted return | Not required |
The framework includes return, risk, risk-adjusted, tail-risk, relative, and turnover metrics.
Cash-specific metrics in run_backtest_with_cash() include:
Final CashCash RatioAvg Cash Ratio
bt.plot_all()
bt.plot_nav_curve() # linear scale
bt.plot_nav_curve(log_scale=True) # log scale (v1.1.0)
bt.plot_nav_curve_dual() # dual linear/log (v1.1.0)
bt.plot_nav_vs_benchmark() # strategy vs benchmark
bt.plot_excess_returns() # excess return analysis
bt.plot_monthly_returns() # monthly heatmap
bt.plot_turnover() # turnover analysis
bt.plot_position_heatmap() # holdings heatmap
bt.plot_return_distribution() # return distribution
# T+0 specific (TBacktest)
tb.plot_intraday_trades() # NAV + trade markers
tb.plot_t0_returns_breakdown() # sell vs buy decompositionGeneralBacktest(start_date: str, end_date: str)run_backtest(...)— standard weight-based backtestrun_backtest_ETF(...)— ETF data from databaserun_backtest_stock(...)— stock data from databaserun_backtest_with_cash(...)— cash-constrained executionTBacktest.run_t0_backtest(...)— T+0 intraday round-trip
print_metrics()plot_all()plot_nav_curve(log_scale=False)plot_nav_curve_dual(...)plot_comparison()plot_excess_returns()plot_monthly_returns()plot_turnover()plot_positions()plot_return_distribution()
All v1.1.0 and v1.2.0 features are incremental and backward compatible:
position_ratio_colinrun_backtest()defaults toNone.log_scaleinplot_nav_curve()defaults toFalse.run_backtest_with_cash()andplot_nav_curve_dual()are new v1.1.0 methods.TBacktestis a new v1.2.0 class — it does not affect existingGeneralBacktestusage.
Issues and pull requests are welcome.
MIT License. See LICENSE.
Elen Young - yang13515360252@163.com
- GitHub: https://github.com/ElenYoung/GeneralBacktest
- Issues: https://github.com/ElenYoung/GeneralBacktest/issues
- Releases: https://github.com/ElenYoung/GeneralBacktest/releases
This framework is for research and educational purposes only and does not constitute investment advice.