# backtestingfx [![PyPI](https://img.shields.io/pypi/v/backtestingfx)](https://pypi.org/project/backtestingfx/) A Rust-powered FX backtesting library for Python. Write your strategy in Python, let Rust handle the heavy lifting. Inspired by [backtesting.py](https://kernc.github.io/backtesting.py/) but built specifically for forex — lot sizes, pip-based PnL, stop loss, take profit, and realistic account currency conversion. ``` pip install backtestingfx ``` ## Quick Start ```python import pandas as pd from backtestingfx import Backtest, Strategy class MyCrossStrategy(Strategy): def next(self): self.close_all() self.buy(lot_size=0.1) df = pd.read_csv("EURUSD_1H.csv") bt = Backtest(df, MyCrossStrategy, cash=10000.0, spread=0.0001) stats = bt.run() print(stats) ``` ``` --- Backtest Results --- Initial Cash: 10000.00 Final Cash: 9823.50 Total Return: -1.77% Trades: 248 Win Rate: 52.4% Avg PnL: -0.71380 Best Trade: 84.20000 Worst Trade: -61.30000 Profit Factor: 0.94 Max Drawdown: 3.21% ``` ### Interactive HTML report Install the optional report dependency and generate a self-contained HTML file: ```bash pip install "backtestingfx[report]" ``` ```python bt = Backtest(df, MyCrossStrategy, cash=10000.0, spread=0.0001) stats = bt.run() bt.plot("strategy-report.html") ``` The report includes candlesticks, trade entries and exits, equity, drawdown, trade diagnostics, and a complete trade ledger. Plotly is embedded in the file, so the report works offline without a server. ## Installation ``` pip install backtestingfx ``` Requires Python 3.9+. ## Writing a Strategy Inherit from `Strategy` and implement `next()`. It is called once per bar. ```python from backtestingfx import Backtest, Strategy class MyStrategy(Strategy): def init(self): # called once before the loop starts # self._bars contains all Bar objects if you need to pre-compute pass def next(self): # self._bar — current bar (open, high, low, close, volume, timestamp) # self._broker — the broker instance (advanced use) if self._bar.close > 1.1000: self.buy(lot_size=0.1, stop_loss=1.0950, take_profit=1.1100) else: self.close_all() ``` ### Strategy methods | Method | Description | |--------|-------------| | `self.buy(lot_size, stop_loss=None, take_profit=None)` | Open a long position | | `self.sell(lot_size, stop_loss=None, take_profit=None)` | Open a short position | | `self.close_all()` | Close all open positions | | `self.close_position(id)` | Close a specific position by ID | ### Bar fields ```python self._bar.open self._bar.high self._bar.low self._bar.close self._bar.volume self._bar.timestamp # unix timestamp (int) ``` ## Optimization Grid-search parameters with the simulations running in parallel Rust threads: ```python import numpy as np from backtestingfx import Backtest def sma_cross(df, fast, slow): fast_sma = df["close"].rolling(fast).mean() slow_sma = df["close"].rolling(slow).mean() return np.where(fast_sma > slow_sma, 0.1, 0.0) # target lots per bar bt = Backtest(df, cash=10_000, commission=3.5) results = bt.optimize(sma_cross, maximize="total_return_pct", fast=range(5, 26), slow=range(30, 101, 5)) best_params, best_stats = results[0] ``` `optimize()` returns `[(params, stats), ...]` sorted best-first by the named `Stats` field. Re-sort it yourself to minimise something instead. ### Why a signal function instead of `next()` `next()` runs in Python, so every bar needs the GIL and threads can't help. A signal function is called **once per parameter combination**, not once per bar — it returns the target lot size for each bar (positive long, negative short, `0.0` flat), and Rust runs every simulation natively with the GIL released. On a 315-combination grid that is **157x faster** than looping `Backtest.run()` over the same grid. The trade-off: a signal function can't see the broker, so path-dependent logic (trailing stops, pyramiding, "exit after N bars") still needs `next()` and a plain loop. Indicator warmup must come out as `0.0`, not `NaN` — a `NaN` signal is rejected rather than silently treated as "hold". ## Backtest Parameters ```python Backtest( df, # pandas DataFrame with OHLCV columns StrategyClass, cash=10000.0, # starting account balance in USD commission=0.0, # commission per lot (e.g. 7.0 = $7/lot) spread=0.0, # spread in price units (e.g. 0.0001 = 1 pip) contract_size=100000.0, # standard FX lot size, don't change this quote_to_account=1.0, # conversion rate from quote currency to USD ) ``` ### Trading non-USD pairs By default `quote_to_account=1.0` which is correct for USD-quoted pairs (EURUSD, GBPUSD). For other pairs, pass the rate that converts the quote currency to USD: | Pair | quote_to_account | |------|-----------------| | EURUSD, GBPUSD | `1.0` (default) | | EURGBP | GBPUSD rate (e.g. `1.27`) | | USDCAD, GBPCAD | CADUSD rate (e.g. `0.74`) | | USDJPY | JPYUSD rate (e.g. `0.0067`) | ```python bt = Backtest(df, MyStrategy, cash=10000.0, spread=0.00015, quote_to_account=1.27) ``` ## Stats | Field | Description | |-------|-------------| | `initial_cash` | Starting balance | | `final_cash` | Ending balance | | `total_return_pct` | Total return as a percentage | | `num_trades` | Number of completed trades | | `num_wins` | Number of winning trades | | `win_rate_pct` | Win rate as a percentage | | `avg_pnl` | Average PnL per trade in USD | | `best_trade` | Best single trade PnL in USD | | `worst_trade` | Worst single trade PnL in USD | | `profit_factor` | Gross profit / gross loss | | `max_drawdown_pct` | Maximum drawdown as a percentage | | `sharpe_ratio` | Unannualized Sharpe ratio | | `equity_curve` | Account equity from initial cash through final liquidation | | `trades` | Completed trades with entry, exit, size, direction, and net PnL | ## Data Format Pass a pandas DataFrame with these columns: ``` open, high, low, close, volume ``` The index should be a `DatetimeIndex`, or include a `timestamp` column. Volume is optional (defaults to 0). ## Why Rust? The backtesting engine is written in Rust and compiled as a native Python extension via [PyO3](https://pyo3.rs). This means the event loop, broker simulation, and stats computation run at native speed while your strategy stays in plain Python. ## License MIT — see [LICENSE](LICENSE)