KhizarImran 7627094c63 feat: partial close and in-place stop loss updates
Adds two Broker methods for path-dependent strategies:

- close_partial(id, lot_size) scales out of a position and leaves the
  remainder open under the same id, so it can keep being managed.
- update_sl(id, stop_loss) moves the stop in place. A trailing stop no
  longer has to close and reopen, which paid spread and commission on
  every step.

Both are reachable from Python as Strategy.close_partial and
Strategy.update_sl.

Closing a position, part of one, or the whole book previously repeated
the same PnL and commission arithmetic in four places. That is now one
settle() helper taking the number of lots to close, so a partial close
is the same code with a smaller number and the feature made broker.rs
shorter rather than longer.

Commission splits across the legs: the entry was charged on the full
size when the position opened, so settle only takes the exit leg for
the lots being closed. Two 0.5 closes land on the same cash as one 1.0
close, which is what closing_partially_twice_matches_closing_once pins.

update_sl deliberately does not check which way the stop moves, since
widening a stop is a legitimate thing for a strategy to do.
2026-08-30 11:16:37 +01:00
2026-08-13 23:20:21 +01:00
2026-08-13 23:20:21 +01:00
2026-08-13 23:20:21 +01:00
2026-06-03 23:38:12 +01:00

backtestingfx

PyPI

A Rust-powered FX backtesting library for Python. Write your strategy in Python, let Rust handle the heavy lifting.

Inspired by 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

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:

pip install "backtestingfx[report]"
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.

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
self.close_partial(id, lot_size) Close part of a position, leaving the rest open
self.update_sl(id, stop_loss) Move a position's stop loss (returns False if the id is gone)

Trailing stops and scaling out

update_sl moves the stop on an open position in place, so a trailing stop costs nothing — closing and reopening would pay spread and commission again. close_partial banks part of a position and leaves the remainder running under the same id, so you can keep trailing it.

class TrailingBreakout(Strategy):
    def next(self):
        if not self.positions:
            self.buy(1.0, stop_loss=self._bar.close - 0.0050)
            return

        position = self.positions[0]

        # take half off once the trade is 50 pips up
        if self._bar.close > position.entry_price + 0.0050 and position.lot_size > 0.5:
            self.close_partial(position.id, 0.5)

        # trail the stop 50 pips behind price, never backwards
        trail = self._bar.close - 0.0050
        if position.stop_loss is None or trail > position.stop_loss:
            self.update_sl(position.id, trail)

update_sl does not check which way the stop moves — widening a stop is a legitimate thing to do, so that call is yours to make (the trail > position.stop_loss line above). close_partial with a size at or above the position just closes it outright, and ignores zero or negative sizes.

Bar fields

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:

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

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)
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. 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

S
Description
No description provided
Readme MIT
3.4 MiB
Languages
Rust 50.2%
Python 49.8%