Files
2026-07-09 05:08:16 +08:00

2018 lines
76 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the advanced backtesting engine.
Covers all 10 test groups from the plan:
1. backtest_ohlcv_core
2. compute_performance_metrics
3. extract_trades
4. backtest_multi_asset_core
5. monte_carlo_bootstrap
6. walk_forward_indices
7. kelly_fraction / half_kelly_fraction
8. BacktestEngine (Python API)
9. walk_forward() (Python API)
10. monte_carlo() (Python API)
"""
from __future__ import annotations
import math
import numpy as np
import numpy.testing as npt
import pytest
from ferro_ta._ferro_ta import (
backtest_core,
backtest_multi_asset_core,
backtest_ohlcv_core,
compute_performance_metrics,
drawdown_series,
half_kelly_fraction,
kelly_fraction,
monte_carlo_bootstrap,
walk_forward_indices,
)
from ferro_ta._ferro_ta import (
extract_trades_ohlcv as extract_trades,
)
from ferro_ta.analysis.backtest import (
AdvancedBacktestResult,
BacktestEngine,
BacktestResult,
MonteCarloResult,
PortfolioBacktestResult,
WalkForwardResult,
backtest,
backtest_portfolio,
monte_carlo,
rsi_strategy,
walk_forward,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_ohlcv(n: int = 100, seed: int = 42) -> tuple:
rng = np.random.default_rng(seed)
close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
open_ = close * (1 - rng.uniform(0, 0.005, n))
high = close * (1 + rng.uniform(0, 0.01, n))
low = close * (1 - rng.uniform(0, 0.01, n))
signals = np.where(np.arange(n) % 20 < 10, 1.0, -1.0).astype(np.float64)
return open_, high, low, close, signals
def _all_finite(arr: np.ndarray) -> bool:
return bool(np.all(np.isfinite(arr[~np.isnan(arr)])))
# ===========================================================================
# Group 1: backtest_ohlcv_core
# ===========================================================================
class TestBacktestOhlcvCore:
def test_returns_five_arrays(self):
o, h, l, c, s = _make_ohlcv()
result = backtest_ohlcv_core(o, h, l, c, s)
assert len(result) == 5
def test_shapes_match_input(self):
o, h, l, c, s = _make_ohlcv(n=80)
pos, fp, br, sr, eq = backtest_ohlcv_core(o, h, l, c, s)
for arr in (pos, fp, br, sr, eq):
assert arr.shape == (80,)
def test_equity_starts_at_one(self):
o, h, l, c, s = _make_ohlcv()
_, _, _, _, eq = backtest_ohlcv_core(o, h, l, c, s)
assert eq[0] == pytest.approx(1.0, abs=1e-9)
def test_no_lookahead_bias(self):
"""Position at bar 0 must always be 0 (signal not yet available)."""
o, h, l, c, s = _make_ohlcv()
pos, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s)
assert pos[0] == 0.0
def test_stop_loss_reduces_equity_relative_to_no_stop(self):
"""With a tight stop-loss, equity should differ from no-stop run."""
o, h, l, c, s = _make_ohlcv(n=200)
_, _, _, _, eq_no_stop = backtest_ohlcv_core(o, h, l, c, s)
_, _, _, _, eq_with_stop = backtest_ohlcv_core(
o, h, l, c, s, stop_loss_pct=0.005
)
# They should differ (stop-loss triggered on at least one bar)
assert not np.allclose(eq_no_stop, eq_with_stop)
def test_fill_prices_nan_when_flat(self):
"""fill_prices must be NaN whenever the position is 0."""
o, h, l, c, s = _make_ohlcv()
pos, fp, _, _, _ = backtest_ohlcv_core(o, h, l, c, s)
flat_mask = pos == 0.0
assert np.all(np.isnan(fp[flat_mask]))
def test_market_close_mode_different_from_open(self):
o, h, l, c, s = _make_ohlcv(n=150)
_, _, _, sr_open, _ = backtest_ohlcv_core(
o, h, l, c, s, fill_mode="market_open"
)
_, _, _, sr_close, _ = backtest_ohlcv_core(
o, h, l, c, s, fill_mode="market_close"
)
# Different fill modes → different returns
assert not np.allclose(sr_open, sr_close, equal_nan=True)
def test_raises_on_mismatched_lengths(self):
o, h, l, c, s = _make_ohlcv()
with pytest.raises(Exception):
backtest_ohlcv_core(o[:-1], h, l, c, s)
# ===========================================================================
# Group 2: compute_performance_metrics
# ===========================================================================
class TestComputePerformanceMetrics:
EXPECTED_KEYS = {
"total_return",
"cagr",
"annualized_vol",
"sharpe",
"sortino",
"calmar",
"max_drawdown",
"avg_drawdown",
"max_drawdown_duration_bars",
"avg_drawdown_duration_bars",
"ulcer_index",
"omega_ratio",
"win_rate",
"profit_factor",
"r_expectancy",
"avg_win",
"avg_loss",
"tail_ratio",
"skewness",
"kurtosis",
"best_bar",
"worst_bar",
"n_trades",
}
def _run(self, n: int = 200, seed: int = 0):
rng = np.random.default_rng(seed)
r = rng.standard_normal(n) * 0.01
eq = np.cumprod(1 + r)
return compute_performance_metrics(r, eq)
def test_all_expected_keys_present(self):
m = self._run()
assert self.EXPECTED_KEYS.issubset(set(m.keys()))
def test_sharpe_all_positive_returns(self):
"""Constant +1% daily returns → Sharpe = (annualised) > 0."""
r = np.full(252, 0.01)
eq = np.cumprod(1 + r)
m = compute_performance_metrics(r, eq)
assert m["sharpe"] > 0
def test_max_drawdown_matches_drawdown_series(self):
rng = np.random.default_rng(7)
r = rng.standard_normal(300) * 0.015
eq = np.cumprod(1 + r)
m = compute_performance_metrics(r, eq)
_, max_dd_ref = drawdown_series(eq)
assert m["max_drawdown"] == pytest.approx(max_dd_ref, abs=1e-9)
def test_cagr_formula(self):
r = np.full(252, 0.01)
eq = np.cumprod(1 + r)
m = compute_performance_metrics(r, eq)
# Rust computes CAGR as (eq[-1]/eq[0])^(ppy/n) - 1, treating eq[0] as start equity
expected_cagr = (eq[-1] / eq[0]) ** (252.0 / len(r)) - 1.0
assert m["cagr"] == pytest.approx(expected_cagr, rel=1e-6)
def test_win_rate_between_0_and_1(self):
m = self._run()
assert 0.0 <= m["win_rate"] <= 1.0
def test_max_drawdown_nonpositive(self):
m = self._run()
assert m["max_drawdown"] <= 0.0
def test_total_return_sign(self):
r = np.full(100, 0.005)
eq = np.cumprod(1 + r)
m = compute_performance_metrics(r, eq)
assert m["total_return"] > 0.0
def test_raises_on_short_input(self):
with pytest.raises(Exception):
compute_performance_metrics(np.array([0.01]), np.array([1.01]))
def test_raises_on_mismatched_lengths(self):
with pytest.raises(Exception):
compute_performance_metrics(np.ones(10) * 0.01, np.ones(20))
# ===========================================================================
# Group 3: extract_trades
# ===========================================================================
class TestExtractTrades:
def _run_ohlcv(self, n: int = 100):
o, h, l, c, s = _make_ohlcv(n=n)
pos, fp, _, _, _ = backtest_ohlcv_core(o, h, l, c, s)
return pos, fp, h, l
def test_returns_nine_arrays(self):
pos, fp, h, l = self._run_ohlcv()
result = extract_trades(pos, fp, h, l)
assert len(result) == 9
def test_all_arrays_same_length(self):
pos, fp, h, l = self._run_ohlcv(n=200)
arrays = extract_trades(pos, fp, h, l)
lengths = {len(a) for a in arrays}
assert len(lengths) == 1 # all same length
def test_duration_bars_positive(self):
pos, fp, h, l = self._run_ohlcv(n=200)
_, _, _, _, _, _, dur, _, _ = extract_trades(pos, fp, h, l)
assert np.all(dur >= 0)
def test_exit_bar_gte_entry_bar(self):
pos, fp, h, l = self._run_ohlcv(n=200)
eb, xb, _, _, _, _, _, _, _ = extract_trades(pos, fp, h, l)
assert np.all(xb >= eb)
def test_direction_is_plus_minus_one(self):
pos, fp, h, l = self._run_ohlcv(n=200)
_, _, d, _, _, _, _, _, _ = extract_trades(pos, fp, h, l)
if len(d) > 0:
assert set(np.unique(d)).issubset({1.0, -1.0})
def test_mfe_gte_mae(self):
"""MFE (best) must always be >= MAE (worst) within the trade."""
pos, fp, h, l = self._run_ohlcv(n=200)
_, _, _, _, _, _, _, mae, mfe = extract_trades(pos, fp, h, l)
if len(mae) > 0:
assert np.all(mfe >= mae)
def test_raises_on_mismatched_lengths(self):
pos, fp, h, l = self._run_ohlcv()
with pytest.raises(Exception):
extract_trades(pos[:-1], fp, h, l)
# ===========================================================================
# Group 4: backtest_multi_asset_core
# ===========================================================================
class TestBacktestMultiAssetCore:
def test_single_asset_matches_backtest_core(self):
"""1-asset multi_asset == scalar backtest_core with same weights."""
rng = np.random.default_rng(99)
n = 150
close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
signals = np.where(np.arange(n) % 15 < 7, 1.0, -1.0).astype(np.float64)
# Single asset via multi_asset (weights = signals)
close2d = close.reshape(n, 1)
w2d = signals.reshape(n, 1)
ar, pr, pe = backtest_multi_asset_core(close2d, w2d)
# Same via backtest_core
_, _, sr_ref, eq_ref = backtest_core(close, signals)
npt.assert_allclose(pe, np.asarray(eq_ref), rtol=1e-6)
def test_returns_shapes(self):
n, k = 100, 5
rng = np.random.default_rng(0)
c2d = np.cumprod(1 + rng.standard_normal((n, k)) * 0.01, axis=0) * 100
w2d = np.ones((n, k)) * 0.2
ar, pr, pe = backtest_multi_asset_core(c2d, w2d)
assert ar.shape == (n, k)
assert pr.shape == (n,)
assert pe.shape == (n,)
def test_parallel_equals_serial(self):
n, k = 120, 4
rng = np.random.default_rng(1)
c2d = np.cumprod(1 + rng.standard_normal((n, k)) * 0.01, axis=0) * 100
w2d = rng.choice([-1.0, 0.0, 1.0], size=(n, k)).astype(np.float64)
_, _, pe_par = backtest_multi_asset_core(c2d, w2d, parallel=True)
_, _, pe_ser = backtest_multi_asset_core(c2d, w2d, parallel=False)
npt.assert_allclose(pe_par, pe_ser, rtol=1e-10)
def test_raises_on_mismatched_shapes(self):
c2d = np.ones((50, 3))
w2d = np.ones((50, 4)) # wrong n_assets
with pytest.raises(Exception):
backtest_multi_asset_core(c2d, w2d)
def test_equity_starts_at_one(self):
n, k = 50, 2
c2d = np.ones((n, k)) * 100.0
w2d = np.zeros((n, k))
_, _, pe = backtest_multi_asset_core(c2d, w2d)
assert pe[0] == pytest.approx(1.0, abs=1e-9)
# ===========================================================================
# Group 5: monte_carlo_bootstrap
# ===========================================================================
class TestMonteCarloBootstrap:
def _returns(self, n: int = 200, seed: int = 5):
rng = np.random.default_rng(seed)
return rng.standard_normal(n) * 0.01
def test_output_shape(self):
r = self._returns()
mc = monte_carlo_bootstrap(r, n_sims=50)
assert mc.shape == (50, 200)
def test_seed_reproducibility(self):
r = self._returns()
mc1 = monte_carlo_bootstrap(r, n_sims=100, seed=7)
mc2 = monte_carlo_bootstrap(r, n_sims=100, seed=7)
npt.assert_array_equal(mc1, mc2)
def test_different_seeds_differ(self):
r = self._returns()
mc1 = monte_carlo_bootstrap(r, n_sims=50, seed=1)
mc2 = monte_carlo_bootstrap(r, n_sims=50, seed=2)
assert not np.allclose(mc1, mc2)
def test_equity_starts_at_one(self):
r = self._returns()
mc = monte_carlo_bootstrap(r, n_sims=20)
# Bootstrap resamples returns randomly, so mc[:,0] = 1 + random_return
# All first-bar equity values must be in range of possible (1+r) values
possible_first_bar = set(np.round(1.0 + r, 12))
for val in mc[:, 0]:
assert any(abs(val - p) < 1e-9 for p in possible_first_bar)
def test_block_bootstrap_shape(self):
r = self._returns(n=100)
mc = monte_carlo_bootstrap(r, n_sims=30, block_size=5)
assert mc.shape == (30, 100)
def test_raises_on_empty_input(self):
with pytest.raises(Exception):
monte_carlo_bootstrap(np.array([0.01]), n_sims=10)
# ===========================================================================
# Group 6: walk_forward_indices
# ===========================================================================
class TestWalkForwardIndices:
def test_output_shape(self):
idx = walk_forward_indices(500, 200, 50)
assert idx.ndim == 2
assert idx.shape[1] == 4
def test_non_anchored_fixed_train_window(self):
idx = walk_forward_indices(400, 200, 50)
n_folds = idx.shape[0]
assert n_folds >= 2
for fold in idx:
tr_len = fold[1] - fold[0]
assert tr_len == 200
def test_anchored_growing_train_window(self):
idx = walk_forward_indices(400, 150, 50, anchored=True)
for fold in idx:
assert fold[0] == 0 # always starts at 0
train_lengths = idx[:, 1] - idx[:, 0]
assert train_lengths[-1] >= train_lengths[0]
def test_no_test_fold_overlap(self):
idx = walk_forward_indices(500, 200, 50)
# Test intervals should be non-overlapping (step = test_bars by default)
for i in range(len(idx) - 1):
assert idx[i, 3] <= idx[i + 1, 2]
def test_all_test_folds_within_bounds(self):
n = 600
idx = walk_forward_indices(n, 200, 100)
assert np.all(idx[:, 0] >= 0)
assert np.all(idx[:, 3] <= n)
def test_step_bars_parameter(self):
idx_default = walk_forward_indices(500, 200, 50)
idx_step = walk_forward_indices(500, 200, 50, step_bars=25)
# Smaller step → more folds
assert idx_step.shape[0] >= idx_default.shape[0]
def test_raises_when_no_folds_fit(self):
with pytest.raises(Exception):
walk_forward_indices(100, 80, 80) # 80+80 > 100
# ===========================================================================
# Group 7: kelly_fraction / half_kelly_fraction
# ===========================================================================
class TestKellyFraction:
def test_positive_expectancy(self):
k = kelly_fraction(0.6, 0.02, 0.01)
assert k > 0.0
def test_zero_edge_returns_zero(self):
"""win_rate = loss_rate AND avg_win = avg_loss → Kelly = 0."""
k = kelly_fraction(0.5, 0.01, 0.01)
assert k == pytest.approx(0.0, abs=1e-9)
def test_negative_expectancy_clamped_to_zero(self):
k = kelly_fraction(0.3, 0.01, 0.02)
assert k == 0.0
def test_half_kelly_is_half_of_kelly(self):
k = kelly_fraction(0.6, 0.03, 0.015)
hk = half_kelly_fraction(0.6, 0.03, 0.015)
assert hk == pytest.approx(k / 2.0, rel=1e-9)
def test_result_clamped_to_one(self):
k = kelly_fraction(0.99, 0.5, 0.001)
assert k <= 1.0
def test_raises_on_invalid_win_rate(self):
with pytest.raises(Exception):
kelly_fraction(1.5, 0.01, 0.01)
def test_raises_on_nonpositive_avg_win(self):
with pytest.raises(Exception):
kelly_fraction(0.6, 0.0, 0.01)
# ===========================================================================
# Group 8: BacktestEngine (Python API)
# ===========================================================================
class TestBacktestEngine:
def _close(self, n: int = 200, seed: int = 10) -> np.ndarray:
rng = np.random.default_rng(seed)
return np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
def test_run_returns_advanced_result(self):
c = self._close()
r = BacktestEngine().run(c, "rsi_30_70")
assert isinstance(r, AdvancedBacktestResult)
def test_advanced_result_is_backtest_result(self):
c = self._close()
r = BacktestEngine().run(c, "rsi_30_70")
assert isinstance(r, BacktestResult)
def test_chaining_returns_self(self):
engine = BacktestEngine()
assert engine.with_commission(0.001) is engine
assert engine.with_slippage(5.0) is engine
assert engine.with_stop_loss(0.02) is engine
def test_all_metric_keys_present(self):
c = self._close()
r = BacktestEngine().run(c, "rsi_30_70")
assert "sharpe" in r.metrics
assert "max_drawdown" in r.metrics
assert "cagr" in r.metrics
def test_drawdown_series_shape(self):
c = self._close()
r = BacktestEngine().run(c)
assert r.drawdown_series.shape == c.shape
def test_drawdown_series_nonpositive(self):
c = self._close()
r = BacktestEngine().run(c)
assert np.all(r.drawdown_series <= 0.0)
def test_engine_close_only_matches_backtest_func(self):
c = self._close()
r_engine = BacktestEngine().run(c, "rsi_30_70")
r_func = backtest(c, strategy="rsi_30_70")
npt.assert_allclose(r_engine.equity, r_func.equity, rtol=1e-9)
def test_ohlcv_mode_runs(self):
c = self._close()
h = c * 1.01
l = c * 0.99
o = c * 0.999
r = (
BacktestEngine()
.with_ohlcv(high=h, low=l, open_=o)
.with_stop_loss(0.02)
.run(c)
)
assert r.equity.shape == c.shape
def test_trades_dataframe_columns(self):
c = self._close()
r = BacktestEngine().run(c, "sma_crossover")
if r.trades is not None:
expected_cols = {
"entry_bar",
"exit_bar",
"direction",
"entry_price",
"exit_price",
"pnl_pct",
"duration_bars",
"mae",
"mfe",
}
assert expected_cols.issubset(set(r.trades.columns))
def test_invalid_fill_mode_raises(self):
with pytest.raises(Exception):
BacktestEngine().with_fill_mode("invalid")
# ===========================================================================
# Group 9: walk_forward() Python API
# ===========================================================================
class TestWalkForward:
def _setup(self, n: int = 400):
rng = np.random.default_rng(99)
close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
param_grid = [{"timeperiod": p} for p in [10, 14, 20]]
return close, param_grid
def test_returns_walk_forward_result(self):
c, pg = self._setup()
r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50)
assert isinstance(r, WalkForwardResult)
def test_fold_count_matches_indices(self):
c, pg = self._setup()
r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50)
assert len(r.fold_results) == r.fold_indices.shape[0]
def test_oos_equity_length(self):
c, pg = self._setup()
r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50)
total_test_bars = sum(
int(r.fold_indices[i, 3]) - int(r.fold_indices[i, 2])
for i in range(len(r.fold_results))
)
assert len(r.oos_equity) == total_test_bars
def test_oos_metrics_has_sharpe(self):
c, pg = self._setup()
r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50)
assert "sharpe" in r.oos_metrics
def test_anchored_mode(self):
c, pg = self._setup()
r = walk_forward(
c, rsi_strategy, pg, train_bars=200, test_bars=50, anchored=True
)
# In anchored mode, training always starts at 0
assert np.all(r.fold_indices[:, 0] == 0)
def test_param_stability_populated(self):
c, pg = self._setup()
r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50)
assert "timeperiod" in r.param_stability
assert "most_chosen" in r.param_stability["timeperiod"]
# ===========================================================================
# Group 10: monte_carlo() Python API
# ===========================================================================
class TestMonteCarlo:
def _result(self, n: int = 200):
rng = np.random.default_rng(77)
c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
return BacktestEngine().run(c, "rsi_30_70")
def test_returns_monte_carlo_result(self):
r = self._result()
mc = monte_carlo(r, n_sims=100)
assert isinstance(mc, MonteCarloResult)
def test_equity_curves_shape(self):
r = self._result(n=150)
mc = monte_carlo(r, n_sims=80)
assert mc.equity_curves.shape == (80, 150)
def test_confidence_bounds_cover_median(self):
r = self._result()
mc = monte_carlo(r, n_sims=500, confidence=0.95)
assert np.all(mc.confidence_lower <= mc.median_curve + 1e-9)
assert np.all(mc.confidence_upper >= mc.median_curve - 1e-9)
def test_prob_profit_in_range(self):
r = self._result()
mc = monte_carlo(r, n_sims=200)
assert 0.0 <= mc.prob_profit <= 1.0
def test_accepts_raw_array(self):
rng = np.random.default_rng(3)
returns = rng.standard_normal(100) * 0.01
mc = monte_carlo(returns, n_sims=50)
assert isinstance(mc, MonteCarloResult)
def test_seed_reproducibility(self):
r = self._result()
mc1 = monte_carlo(r, n_sims=50, seed=1)
mc2 = monte_carlo(r, n_sims=50, seed=1)
npt.assert_array_equal(mc1.equity_curves, mc2.equity_curves)
def test_var_is_low_percentile_of_terminal_equity(self):
r = self._result()
mc = monte_carlo(r, n_sims=1000, confidence=0.95)
# VaR = 5th percentile of terminal equity
expected_var = float(np.percentile(mc.terminal_equity, 5.0))
assert mc.var == pytest.approx(expected_var, rel=1e-6)
# ===========================================================================
# Backward compatibility guard
# ===========================================================================
class TestBackwardCompat:
def test_backtest_still_returns_backtest_result(self):
rng = np.random.default_rng(0)
c = np.cumprod(1 + rng.standard_normal(100) * 0.01) * 100.0
r = backtest(c, strategy="rsi_30_70")
assert type(r) is BacktestResult
def test_portfolio_backtest_result(self):
rng = np.random.default_rng(0)
n, k = 100, 3
c2d = np.cumprod(1 + rng.standard_normal((n, k)) * 0.01, axis=0) * 100.0
w2d = np.ones((n, k)) / k
r = backtest_portfolio(c2d, w2d)
assert isinstance(r, PortfolioBacktestResult)
assert r.portfolio_equity.shape == (n,)
# ===========================================================================
# Sprint 1: Limit orders, time-based exit, pct_range slippage
# ===========================================================================
class TestLimitOrders:
"""Tests for limit-price order fill logic in backtest_ohlcv_core."""
def _ohlcv(self):
n = 50
rng = np.random.default_rng(7)
close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0
open_ = close * (1 - rng.uniform(0, 0.003, n))
high = close * (1 + rng.uniform(0.002, 0.008, n))
low = close * (1 - rng.uniform(0.002, 0.008, n))
return open_, high, low, close, n
def test_limit_nan_behaves_like_market(self):
"""NaN limit prices should give identical results to no limit array."""
o, h, l, c, n = self._ohlcv()
signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(np.float64)
lp_nan = np.full(n, np.nan)
pos_mkt, fp_mkt, _, sr_mkt, eq_mkt = backtest_ohlcv_core(o, h, l, c, signals)
pos_lim, fp_lim, _, sr_lim, eq_lim = backtest_ohlcv_core(
o, h, l, c, signals, limit_prices=lp_nan
)
npt.assert_array_almost_equal(pos_mkt, pos_lim)
npt.assert_array_almost_equal(sr_mkt, sr_lim)
npt.assert_array_almost_equal(eq_mkt, eq_lim)
def test_buy_limit_fills_at_limit_price(self):
"""Buy limit fills when low <= limit_price and uses limit as fill price."""
n = 10
close = np.full(n, 100.0)
open_ = np.full(n, 100.0)
high = np.full(n, 102.0)
low = np.full(n, 98.0)
# Buy signal at bar 0, limit price 99 — low=98 <= 99 so should fill
signals = np.zeros(n)
signals[0] = 1.0 # want to go long at bar 1
limit_prices = np.full(n, np.nan)
limit_prices[0] = 99.0 # limit for bar 1 execution
_, fp, _, _, _ = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
fill_mode="market_close",
limit_prices=limit_prices,
)
# Bar 1 should have a fill at 99.0 (the limit price)
assert fp[1] == pytest.approx(99.0, rel=1e-6)
def test_buy_limit_not_hit_no_fill(self):
"""Buy limit is not filled when low > limit_price."""
n = 10
close = np.full(n, 100.0)
open_ = np.full(n, 100.0)
high = np.full(n, 102.0)
low = np.full(n, 98.0) # low=98
signals = np.zeros(n)
signals[0] = 1.0 # go long at bar 1
limit_prices = np.full(n, np.nan)
limit_prices[0] = 97.0 # limit=97, but low=98 > 97 → no fill
pos, fp, _, _, _ = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
fill_mode="market_close",
limit_prices=limit_prices,
)
# Position should stay 0 at bar 1 (limit not hit)
assert pos[1] == pytest.approx(0.0)
assert np.isnan(fp[1])
def test_sell_limit_fills_when_high_hits(self):
"""Sell limit fills when high >= limit_price."""
n = 10
close = np.full(n, 100.0)
open_ = np.full(n, 100.0)
high = np.full(n, 103.0)
low = np.full(n, 97.0)
signals = np.zeros(n)
signals[0] = -1.0 # go short at bar 1
limit_prices = np.full(n, np.nan)
limit_prices[0] = 102.0 # high=103 >= 102 → fill
_, fp, _, _, _ = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
fill_mode="market_close",
limit_prices=limit_prices,
)
assert fp[1] == pytest.approx(102.0, rel=1e-6)
def test_engine_with_limit_orders(self):
"""BacktestEngine.with_limit_orders with NaN limits matches market orders."""
o, h, l, c, n = self._ohlcv()
# NaN limit prices = market orders; result must match engine without limit array
limit_prices = np.full(n, np.nan)
result_mkt = (
BacktestEngine()
.with_ohlcv(high=h, low=l, open_=o)
.run(c, strategy="sma_crossover", fast=5, slow=20)
)
result_lim = (
BacktestEngine()
.with_ohlcv(high=h, low=l, open_=o)
.with_limit_orders(limit_prices)
.run(c, strategy="sma_crossover", fast=5, slow=20)
)
assert isinstance(result_lim, AdvancedBacktestResult)
npt.assert_array_almost_equal(result_mkt.equity, result_lim.equity)
class TestMaxHold:
"""Tests for time-based exit (max_hold_bars)."""
def _flat_ohlcv(self, n=30):
close = np.ones(n) * 100.0
open_ = close.copy()
high = close * 1.005
low = close * 0.995
signals = np.ones(n) # always long signal
return open_, high, low, close, signals
def test_position_exits_after_n_bars(self):
"""Position should be closed after max_hold_bars regardless of signal."""
o, h, l, c, s = self._flat_ohlcv(n=20)
max_hold = 5
pos, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s, max_hold_bars=max_hold)
# Find first entry
entry_bar = None
for i in range(len(pos)):
if pos[i] != 0.0:
entry_bar = i
break
assert entry_bar is not None
# Position should be 0 at entry_bar + max_hold
exit_bar = entry_bar + max_hold
if exit_bar < len(pos):
assert pos[exit_bar] == pytest.approx(0.0), (
f"Expected exit at bar {exit_bar}, pos={pos[exit_bar]}"
)
def test_max_hold_zero_is_disabled(self):
"""max_hold_bars=0 should not affect behaviour (disabled)."""
o, h, l, c, s = self._flat_ohlcv(n=20)
pos_no_hold, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s)
pos_hold_0, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s, max_hold_bars=0)
npt.assert_array_almost_equal(pos_no_hold, pos_hold_0)
def test_engine_with_max_hold(self):
"""BacktestEngine.with_max_hold integrates correctly."""
rng = np.random.default_rng(99)
n = 100
c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
h = c * 1.01
l = c * 0.99
o = c * 1.001
result = (
BacktestEngine()
.with_ohlcv(high=h, low=l, open_=o)
.with_max_hold(5)
.run(c, strategy="rsi_30_70")
)
assert isinstance(result, AdvancedBacktestResult)
def test_max_hold_stop_takes_priority(self):
"""A stop-loss that triggers before max_hold should exit early."""
n = 20
close = np.array([100.0] * 5 + [95.0] * 15) # price drops on bar 5
open_ = close.copy()
high = close * 1.002
low = np.array([100.0] * 5 + [93.0] * 15) # low hits stop at bar 5
signals = np.ones(n) # always long
pos_sl, _, _, _, _ = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
stop_loss_pct=0.05,
max_hold_bars=10,
)
pos_hold, _, _, _, _ = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
stop_loss_pct=0.05,
)
# Both should exit around the same time (stop triggers before hold limit)
# At least the stop-loss exit should happen — position goes to 0 before bar 10+1
assert any(pos_sl[5:11] == 0.0), (
"Stop-loss should have triggered before max_hold"
)
class TestSlippagePctRange:
"""Tests for pct_range slippage mode."""
def _ohlcv_wide_range(self, n=20):
"""OHLCV with a wide bar range to make pct_range slippage measurable."""
close = np.full(n, 100.0)
open_ = np.full(n, 100.0)
high = np.full(n, 110.0) # range = 10 (10%)
low = np.full(n, 90.0)
signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(np.float64)
return open_, high, low, close, signals
def test_pct_range_more_costly_than_zero_slippage(self):
"""With wide bar range, pct_range slip should reduce final equity vs no slip."""
o, h, l, c, s = self._ohlcv_wide_range()
_, _, _, _, eq_no_slip = backtest_ohlcv_core(o, h, l, c, s)
_, _, _, _, eq_pct = backtest_ohlcv_core(o, h, l, c, s, slippage_pct_range=0.10)
# pct_range slippage = 0.10 × (110-90)/100 = 0.02 = 200bps per trade
assert eq_pct[-1] < eq_no_slip[-1]
def test_pct_range_more_costly_than_bps_equivalent(self):
"""pct_range with wide range should be costlier than modest bps slip."""
o, h, l, c, s = self._ohlcv_wide_range()
# bps slip: 5bps = 0.05% of fill, small
_, _, _, _, eq_bps = backtest_ohlcv_core(o, h, l, c, s, slippage_bps=5.0)
# pct_range: 10% of 20-wide range = 2.0 absolute, or 2% of close=100
_, _, _, _, eq_pct = backtest_ohlcv_core(o, h, l, c, s, slippage_pct_range=0.10)
assert eq_pct[-1] < eq_bps[-1]
def test_pct_range_zero_equals_no_slippage(self):
"""slippage_pct_range=0 should give same result as no slippage."""
o, h, l, c, s = self._ohlcv_wide_range()
_, _, _, _, eq_base = backtest_ohlcv_core(o, h, l, c, s)
_, _, _, _, eq_zero = backtest_ohlcv_core(o, h, l, c, s, slippage_pct_range=0.0)
npt.assert_array_almost_equal(eq_base, eq_zero)
def test_engine_with_slippage_pct_range(self):
"""BacktestEngine.with_slippage_pct_range integrates correctly."""
rng = np.random.default_rng(17)
n = 80
c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
h = c * 1.01
l = c * 0.99
o = c * 1.001
result = (
BacktestEngine()
.with_ohlcv(high=h, low=l, open_=o)
.with_slippage_pct_range(0.05)
.run(c, strategy="sma_crossover", fast=5, slow=20)
)
assert isinstance(result, AdvancedBacktestResult)
# ===========================================================================
# Group 11: Phase 1 Features (spread_bps, breakeven_stop, bracket order priority)
# ===========================================================================
from ferro_ta._ferro_ta import CommissionModel as RustCommissionModel
class TestPhase1Features:
"""Tests for Phase 1 features: spread_bps, breakeven_pct, bracket order priority."""
def test_spread_bps_increases_cost(self):
"""CommissionModel with spread_bps=10 should produce lower equity than spread_bps=0."""
rng = np.random.default_rng(99)
n = 200
c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
h = c * 1.005
l = c * 0.995
o = c * 0.999
signals = np.where(np.arange(n) % 20 < 10, 1.0, 0.0).astype(np.float64)
# Build a commission model with spread_bps=0
cm_no_spread = RustCommissionModel()
cm_no_spread.spread_bps = 0.0
# Build a commission model with spread_bps=10
cm_with_spread = RustCommissionModel()
cm_with_spread.spread_bps = 10.0
_, _, _, _, eq_no_spread = backtest_ohlcv_core(
o, h, l, c, signals, commission=cm_no_spread
)
_, _, _, _, eq_with_spread = backtest_ohlcv_core(
o, h, l, c, signals, commission=cm_with_spread
)
# Spread adds cost on each trade leg → should produce lower or equal final equity
assert eq_with_spread[-1] <= eq_no_spread[-1], (
f"spread equity {eq_with_spread[-1]:.6f} should be <= no-spread equity {eq_no_spread[-1]:.6f}"
)
def test_spread_bps_getter_setter(self):
"""CommissionModel spread_bps getter/setter round-trip works correctly."""
m = RustCommissionModel()
assert m.spread_bps == 0.0
m.spread_bps = 5.0
assert m.spread_bps == pytest.approx(5.0)
def test_spread_bps_total_cost(self):
"""CommissionModel.total_cost includes spread cost at correct magnitude."""
m = RustCommissionModel()
m.spread_bps = 20.0 # 20 bps total round-trip = 10 bps each leg
trade_value = 100_000.0
cost = m.total_cost(trade_value, 1.0, True)
# Expected: 10 bps = 0.001 * 100_000 = 100 per leg
assert cost == pytest.approx(100.0, rel=1e-6)
def test_breakeven_stop_prevents_loss(self):
"""With breakeven_pct=0.02, after price rises 3% then falls, exit should be near entry."""
# Build synthetic data: entry at bar 1, then price rises 3%, then falls below entry
# Bar layout: [100, 103, 103, 101, 99, 99, 99, 99, 99]
# We want a long signal from bar 0 onwards
n = 20
# Create price data: starts at 100, rises to 103 at bar 3, then drops to 97
close = np.array([100.0] * 3 + [103.0] * 3 + [97.0] * (n - 6), dtype=np.float64)
open_ = close.copy()
high = close * 1.002
low = close * 0.998
# Set high of bar 3 to clearly trigger breakeven (>= 103 = entry * 1.03)
# entry happens at bar 1 (open of bar 1 = 100), so entry_price ≈ 100
# breakeven triggers when h >= 100 * 1.02 = 102 → triggers at bar 3 (close=103, high≥103)
high[3] = 103.5 # clearly above 102 (entry * 1.02)
# At bar 6, low drops below entry (100), breakeven stop should trigger
low[6] = 99.0 # below entry price 100 → breakeven stop fires
signals = np.ones(n, dtype=np.float64) # always long
_, fp, _, _, _ = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
stop_loss_pct=0.0,
breakeven_pct=0.02,
)
# Find first non-NaN fill price after the entry bar (entry at bar 1)
# Breakeven exit should happen at or near entry price (100), not at a big loss
exit_fps = fp[~np.isnan(fp)]
# The breakeven stop exit should be at entry_price (~100), not at 97 or lower
# Entry fill is at open of bar 1 = 100.0
# After breakeven activates, stop = entry (~100). So exit fill should be ~100
assert len(exit_fps) >= 1
# The exit fill from breakeven should be close to entry price (within 1%)
# (first fill = entry, subsequent fills = exits)
if len(exit_fps) >= 2:
breakeven_exit = exit_fps[1]
assert breakeven_exit >= 99.0, (
f"breakeven exit {breakeven_exit} should be >= 99 (near entry 100)"
)
def test_bracket_order_tp_fires_before_sl(self):
"""When both SL and TP are breached in same bar, and open is near TP → TP fires."""
# Long trade: entry at price 100
# Bar where both trigger: open=109 (very close to TP=110), high=112, low=90
# SL = 100*(1-0.10) = 90, TP = 100*(1+0.10) = 110
# open=109 is closer to TP=110 (dist=1) than to SL=90 (dist=19) → TP fires
entry_price = 100.0
close = np.array(
[entry_price, entry_price, entry_price, 108.0, 108.0], dtype=np.float64
)
open_ = np.array(
[entry_price, entry_price, entry_price, 109.0, 108.0], dtype=np.float64
)
high = np.array(
[entry_price, entry_price, entry_price, 112.0, 108.0], dtype=np.float64
)
low = np.array(
[entry_price, entry_price, entry_price, 88.0, 108.0], dtype=np.float64
)
signals = np.array([0.0, 1.0, 1.0, 1.0, 0.0], dtype=np.float64)
_, fp, _, sr, _ = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
stop_loss_pct=0.10,
take_profit_pct=0.10,
)
# Bar 3 is where both trigger. TP=110. SL=90. Open=109 → TP fires.
# fill price at bar 3 should be ~110 (TP), not 90 (SL)
assert not np.isnan(fp[3]), "Expected a fill at bar 3"
tp_level = entry_price * 1.10 # 110
assert fp[3] == pytest.approx(tp_level, rel=1e-6), (
f"Expected TP fill at ~{tp_level}, got {fp[3]}"
)
def test_bracket_order_sl_fires_before_tp(self):
"""When both SL and TP are breached in same bar, and open is near SL → SL fires."""
# Long trade: entry at 100
# Bar where both trigger: open=91 (very close to SL=90), high=112, low=88
# SL=90, TP=110. open=91 is closer to SL=90 (dist=1) than to TP=110 (dist=19) → SL fires
entry_price = 100.0
close = np.array(
[entry_price, entry_price, entry_price, 95.0, 95.0], dtype=np.float64
)
open_ = np.array(
[entry_price, entry_price, entry_price, 91.0, 95.0], dtype=np.float64
)
high = np.array(
[entry_price, entry_price, entry_price, 112.0, 95.0], dtype=np.float64
)
low = np.array(
[entry_price, entry_price, entry_price, 88.0, 95.0], dtype=np.float64
)
signals = np.array([0.0, 1.0, 1.0, 1.0, 0.0], dtype=np.float64)
_, fp, _, sr, _ = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
stop_loss_pct=0.10,
take_profit_pct=0.10,
)
# Bar 3: both SL(90) and TP(110) are triggered. open=91 is close to SL → SL fires.
assert not np.isnan(fp[3]), "Expected a fill at bar 3"
sl_level = entry_price * 0.90 # 90
assert fp[3] == pytest.approx(sl_level, rel=1e-6), (
f"Expected SL fill at ~{sl_level}, got {fp[3]}"
)
def test_breakeven_engine_integration(self):
"""BacktestEngine.with_breakeven_stop integrates correctly."""
rng = np.random.default_rng(77)
n = 150
c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
h = c * 1.01
l = c * 0.99
o = c * 0.999
result = (
BacktestEngine()
.with_ohlcv(high=h, low=l, open_=o)
.with_breakeven_stop(0.02)
.run(c, strategy="sma_crossover", fast=5, slow=20)
)
assert isinstance(result, AdvancedBacktestResult)
assert np.all(np.isfinite(result.equity))
# ===========================================================================
# Phase 2: Portfolio & Risk Features
# ===========================================================================
class TestPhase2Features:
"""Tests for Phase 2: short borrow cost, margin/leverage, circuit breakers,
and portfolio constraints."""
# -----------------------------------------------------------------------
# Helper: synthetic OHLCV with controllable direction
# -----------------------------------------------------------------------
def _make_short_ohlcv(self, n: int = 100, seed: int = 7) -> tuple:
"""Produce OHLCV where the price trends downward (good for shorts)."""
rng = np.random.default_rng(seed)
# Steady downtrend
close = 100.0 * np.cumprod(1 - np.abs(rng.standard_normal(n)) * 0.005)
open_ = close * (1 + rng.uniform(-0.002, 0.002, n))
high = np.maximum(open_, close) * (1 + rng.uniform(0, 0.003, n))
low = np.minimum(open_, close) * (1 - rng.uniform(0, 0.003, n))
# Always short
signals = np.full(n, -1.0, dtype=np.float64)
return open_, high, low, close, signals
# -----------------------------------------------------------------------
# 1. Short borrow cost
# -----------------------------------------------------------------------
def test_short_borrow_cost_reduces_equity(self):
"""Short position with short_borrow_rate_annual=0.10 should produce lower
final equity than the same run with no borrow cost."""
from ferro_ta._ferro_ta import CommissionModel
o, h, l, c, signals = self._make_short_ohlcv(n=252)
# Commission model without borrow cost
cm_no_borrow = CommissionModel()
# Commission model with 10% annual borrow cost
cm_with_borrow = CommissionModel()
cm_with_borrow.short_borrow_rate_annual = 0.10
_, _, _, _, eq_no_borrow = backtest_ohlcv_core(
o, h, l, c, signals, commission=cm_no_borrow
)
_, _, _, _, eq_with_borrow = backtest_ohlcv_core(
o, h, l, c, signals, commission=cm_with_borrow
)
# With borrow cost, final equity must be strictly lower
assert float(eq_with_borrow[-1]) < float(eq_no_borrow[-1]), (
f"Expected borrow-cost equity {eq_with_borrow[-1]:.6f} < "
f"no-borrow equity {eq_no_borrow[-1]:.6f}"
)
def test_short_borrow_cost_getter_setter(self):
"""CommissionModel.short_borrow_rate_annual getter/setter works."""
from ferro_ta._ferro_ta import CommissionModel
cm = CommissionModel()
assert cm.short_borrow_rate_annual == pytest.approx(0.0)
cm.short_borrow_rate_annual = 0.05
assert cm.short_borrow_rate_annual == pytest.approx(0.05)
def test_short_borrow_zero_rate_no_effect(self):
"""With short_borrow_rate_annual=0, borrow cost should not affect equity."""
from ferro_ta._ferro_ta import CommissionModel
o, h, l, c, signals = self._make_short_ohlcv(n=50)
cm_zero = CommissionModel()
cm_zero.short_borrow_rate_annual = 0.0
_, _, _, sr1, eq1 = backtest_ohlcv_core(o, h, l, c, signals)
_, _, _, sr2, eq2 = backtest_ohlcv_core(o, h, l, c, signals, commission=cm_zero)
npt.assert_allclose(eq1, eq2, rtol=1e-10)
def test_short_borrow_engine_integration(self):
"""BacktestEngine with commission model including short_borrow_rate_annual runs."""
from ferro_ta._ferro_ta import CommissionModel
o, h, l, c, sigs = self._make_short_ohlcv(n=80)
cm = CommissionModel()
cm.short_borrow_rate_annual = 0.08
result = (
BacktestEngine()
.with_ohlcv(high=h, low=l, open_=o)
.with_commission_model(cm)
.run(c, lambda x: np.full(len(x), -1.0))
)
assert isinstance(result, AdvancedBacktestResult)
assert np.all(np.isfinite(result.equity))
# -----------------------------------------------------------------------
# 2. Margin call force-close
# -----------------------------------------------------------------------
def test_margin_call_force_closes_position(self):
"""A declining price sequence triggers a margin call and force-closes the long."""
n = 20
# Price drops sharply — enough to trigger a margin call on a long
open_ = np.ones(n) * 100.0
high = np.ones(n) * 101.0
low = np.ones(n) * 99.0
close = np.ones(n) * 100.0
# After bar 5, price tanks sharply every bar
for i in range(5, n):
drop = 0.30 # 30% per bar — guaranteed to exceed margin
open_[i] = open_[i - 1] * (1 - drop)
high[i] = open_[i] * 1.001
low[i] = open_[i] * 0.999
close[i] = open_[i]
# Always long
signals = np.ones(n, dtype=np.float64)
# margin_ratio=0.2 means 20% margin (5x leverage)
# margin_call_pct=0.5 means call when equity hits 50% of initial margin
_, _, _, sr_margin, eq_margin = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
margin_ratio=0.2,
margin_call_pct=0.5,
)
_, _, _, sr_no_margin, eq_no_margin = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
)
# Margin call should cause a forced exit, resulting in different equity
# (the margin version stops losses earlier)
assert not np.allclose(eq_margin, eq_no_margin), (
"Expected margin call to alter equity curve"
)
def test_margin_disabled_when_ratio_zero(self):
"""margin_ratio=0 should behave identically to not passing the parameter."""
o, h, l, c, signals = _make_ohlcv(n=80)
_, _, _, _, eq_default = backtest_ohlcv_core(o, h, l, c, signals)
_, _, _, _, eq_zero_margin = backtest_ohlcv_core(
o, h, l, c, signals, margin_ratio=0.0
)
npt.assert_allclose(eq_default, eq_zero_margin, rtol=1e-10)
def test_margin_engine_builder(self):
"""BacktestEngine.with_leverage builder sets parameters without error."""
o, h, l, c, _ = _make_ohlcv(n=60)
result = (
BacktestEngine()
.with_ohlcv(high=h, low=l, open_=o)
.with_leverage(margin_ratio=0.2, margin_call_pct=0.5)
.run(c, lambda x: np.ones(len(x)))
)
assert isinstance(result, AdvancedBacktestResult)
assert np.all(np.isfinite(result.equity))
# -----------------------------------------------------------------------
# 3. Total loss limit (circuit breaker)
# -----------------------------------------------------------------------
def test_total_loss_limit_halts_trading(self):
"""total_loss_limit=0.10 should halt trading once equity drops 10%."""
n = 100
# Construct a losing price sequence: steady decline
close = 100.0 * np.cumprod(np.full(n, 0.99)) # -1% per bar
open_ = close * 1.001
high = close * 1.005
low = close * 0.995
# Always long (so position loses money as price falls)
signals = np.ones(n, dtype=np.float64)
pos_with_limit, _, _, _, eq_with_limit = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
total_loss_limit=0.10,
)
pos_no_limit, _, _, _, eq_no_limit = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
)
# After circuit break the position should be 0
# Check that at some point positions go to 0 in the limited version
# while the unlimited version stays long
assert np.any(pos_with_limit == 0.0), (
"Expected some bars with no position after circuit break"
)
# Unlimited version should stay long throughout (except bar 0)
assert np.all(pos_no_limit[1:] == 1.0), "No-limit should stay long"
def test_total_loss_limit_does_not_trip_with_no_loss(self):
"""total_loss_limit does not trip on a profitable sequence."""
n = 60
close = 100.0 * np.cumprod(np.full(n, 1.005)) # +0.5% per bar
open_ = close * 0.999
high = close * 1.003
low = close * 0.997
signals = np.ones(n, dtype=np.float64)
pos, _, _, _, _ = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
total_loss_limit=0.20,
)
# No circuit break should fire; position stays long
assert np.all(pos[1:] == 1.0)
# -----------------------------------------------------------------------
# 4. Daily (per-bar) loss limit circuit breaker
# -----------------------------------------------------------------------
def test_daily_loss_limit_halts_after_large_bar_loss(self):
"""A single large losing bar triggers the daily_loss_limit circuit breaker."""
n = 30
close = np.ones(n) * 100.0
open_ = np.ones(n) * 100.0
high = np.ones(n) * 101.0
low = np.ones(n) * 99.0
# Create one very large losing bar at bar 10 (price drops 15%)
# Strategy is long, so this is a large loss
crash_bar = 10
close[crash_bar] = close[crash_bar - 1] * 0.85
open_[crash_bar] = close[crash_bar - 1] * 0.86
high[crash_bar] = open_[crash_bar] * 1.001
low[crash_bar] = close[crash_bar] * 0.999
signals = np.ones(n, dtype=np.float64)
pos, _, _, sr, _ = backtest_ohlcv_core(
open_,
high,
low,
close,
signals,
daily_loss_limit=0.05, # 5% per-bar loss limit
)
# After the crash bar, circuit breaker should fire and position should go to 0
# Check bars after crash_bar+1 have position 0
assert np.any(pos[crash_bar + 1 :] == 0.0), (
"Expected circuit breaker to zero out position after crash bar"
)
def test_daily_loss_limit_zero_is_disabled(self):
"""daily_loss_limit=0 (default) should not change behavior."""
o, h, l, c, signals = _make_ohlcv(n=80)
_, _, _, _, eq_default = backtest_ohlcv_core(o, h, l, c, signals)
_, _, _, _, eq_zero_limit = backtest_ohlcv_core(
o, h, l, c, signals, daily_loss_limit=0.0
)
npt.assert_allclose(eq_default, eq_zero_limit, rtol=1e-10)
def test_loss_limits_engine_builder(self):
"""BacktestEngine.with_loss_limits builder sets parameters."""
o, h, l, c, _ = _make_ohlcv(n=80)
result = (
BacktestEngine()
.with_ohlcv(high=h, low=l, open_=o)
.with_loss_limits(daily=0.05, total=0.20)
.run(c, strategy="sma_crossover")
)
assert isinstance(result, AdvancedBacktestResult)
assert np.all(np.isfinite(result.equity))
# -----------------------------------------------------------------------
# 5. Portfolio constraints
# -----------------------------------------------------------------------
def test_portfolio_max_asset_weight_clamps_signal(self):
"""max_asset_weight=0.5 should clamp signals from ±1 to ±0.5."""
rng = np.random.default_rng(99)
n_bars, n_assets = 100, 3
close_2d = (
np.cumprod(1 + rng.standard_normal((n_bars, n_assets)) * 0.01, axis=0)
* 100.0
)
# Alternating ±1 signals — shape (n_bars, n_assets)
row_flags = (np.arange(n_bars) % 10 < 5)[:, None] # (n, 1)
weights_2d = np.where(np.tile(row_flags, (1, n_assets)), 1.0, -1.0).astype(
np.float64
)
# Run without constraint (unit signals)
asset_ret_unconstrained, port_ret_unconstrained, _ = backtest_multi_asset_core(
np.ascontiguousarray(close_2d),
np.ascontiguousarray(weights_2d),
max_asset_weight=1.0,
)
# Run with max_asset_weight=0.5
asset_ret_constrained, port_ret_constrained, _ = backtest_multi_asset_core(
np.ascontiguousarray(close_2d),
np.ascontiguousarray(weights_2d),
max_asset_weight=0.5,
)
# Constrained returns should have smaller magnitude
assert np.abs(port_ret_constrained).sum() < np.abs(
port_ret_unconstrained
).sum() or np.allclose(
np.abs(port_ret_constrained).sum(),
np.abs(port_ret_unconstrained).sum() * 0.5,
rtol=0.05,
), "max_asset_weight=0.5 should reduce absolute returns by ~50%"
def test_portfolio_max_gross_exposure_constrains_sum(self):
"""max_gross_exposure=1.0 should limit total abs(weights)."""
rng = np.random.default_rng(55)
n_bars, n_assets = 80, 4
close_2d = (
np.cumprod(1 + rng.standard_normal((n_bars, n_assets)) * 0.01, axis=0)
* 100.0
)
# Always long all assets = gross exposure of 4.0
weights_2d = np.ones((n_bars, n_assets), dtype=np.float64)
# With max_gross_exposure=1.0, total abs weight should be normalized to 1
ar_constrained, pr_constrained, _ = backtest_multi_asset_core(
np.ascontiguousarray(close_2d),
np.ascontiguousarray(weights_2d),
max_gross_exposure=1.0,
)
ar_unconstrained, pr_unconstrained, _ = backtest_multi_asset_core(
np.ascontiguousarray(close_2d),
np.ascontiguousarray(weights_2d),
)
# Constrained portfolio should have ~1/4 the returns magnitude
ratio = np.abs(pr_constrained).sum() / (np.abs(pr_unconstrained).sum() + 1e-12)
assert ratio < 0.5, (
f"Expected constrained to be much smaller, got ratio={ratio:.3f}"
)
def test_portfolio_constraints_engine_builder(self):
"""BacktestEngine.with_portfolio_constraints stores the parameters."""
engine = BacktestEngine().with_portfolio_constraints(
max_asset_weight=0.3,
max_gross_exposure=1.5,
max_net_exposure=0.5,
)
assert engine._max_asset_weight == pytest.approx(0.3)
assert engine._max_gross_exposure == pytest.approx(1.5)
assert engine._max_net_exposure == pytest.approx(0.5)
def test_backtest_portfolio_with_constraints(self):
"""backtest_portfolio accepts portfolio constraint kwargs."""
from ferro_ta.analysis.backtest import backtest_portfolio
rng = np.random.default_rng(11)
n_bars, n_assets = 60, 2
close_2d = (
np.cumprod(1 + rng.standard_normal((n_bars, n_assets)) * 0.01, axis=0)
* 100.0
)
row_flags = (np.arange(n_bars) % 10 < 5)[:, None]
weights_2d = np.where(np.tile(row_flags, (1, n_assets)), 1.0, -1.0).astype(
np.float64
)
result = backtest_portfolio(
close_2d,
weights_2d,
max_asset_weight=0.5,
max_gross_exposure=0.8,
)
assert isinstance(result, PortfolioBacktestResult)
assert np.all(np.isfinite(result.portfolio_equity))
# ===========================================================================
# Phase 3: Data & UX features
# ===========================================================================
class TestPhase3Features:
"""Tests for Phase 3: resample, adjust, multitf, and plot modules."""
# -----------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------
def _make_ohlcv(self, n=100, seed=42):
rng = np.random.default_rng(seed)
close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0
open_ = close * (1 - rng.uniform(0, 0.002, n))
high = close * (1 + rng.uniform(0.001, 0.006, n))
low = close * (1 - rng.uniform(0.001, 0.006, n))
volume = rng.uniform(1_000, 10_000, n)
return open_, high, low, close, volume
# -----------------------------------------------------------------------
# 1. resample_ohlcv — factor=4, 20 bars → 5 coarse bars
# -----------------------------------------------------------------------
def test_resample_ohlcv_factor4(self):
from ferro_ta.analysis.resample import resample_ohlcv
o, h, l, c, v = self._make_ohlcv(n=20)
co, ch, cl, cc, cv = resample_ohlcv(o, h, l, c, v, factor=4)
assert co.shape == (5,)
assert ch.shape == (5,)
assert cl.shape == (5,)
assert cc.shape == (5,)
assert cv.shape == (5,)
# open = first bar of each group
for i in range(5):
assert co[i] == pytest.approx(o[i * 4])
# high = max of group
for i in range(5):
assert ch[i] == pytest.approx(h[i * 4 : i * 4 + 4].max())
# low = min of group
for i in range(5):
assert cl[i] == pytest.approx(l[i * 4 : i * 4 + 4].min())
# close = last bar of group
for i in range(5):
assert cc[i] == pytest.approx(c[i * 4 + 3])
# volume = sum of group
for i in range(5):
assert cv[i] == pytest.approx(v[i * 4 : i * 4 + 4].sum())
# -----------------------------------------------------------------------
# 2. resample_ohlcv — non-divisible length: 22 bars, factor=4 → 5 coarse bars
# -----------------------------------------------------------------------
def test_resample_ohlcv_non_divisible(self):
from ferro_ta.analysis.resample import resample_ohlcv
o, h, l, c, v = self._make_ohlcv(n=22)
co, ch, cl, cc, cv = resample_ohlcv(o, h, l, c, v, factor=4)
# 22 // 4 = 5 complete bars, last 2 fine bars are dropped
assert len(co) == 5
assert len(ch) == 5
# -----------------------------------------------------------------------
# 3. align_to_coarse — roundtrip test
# -----------------------------------------------------------------------
def test_align_to_coarse_roundtrip(self):
from ferro_ta.analysis.resample import align_to_coarse
coarse = np.array([10.0, 20.0, 30.0, 40.0, 50.0])
factor = 4
n_fine = 20
fine = align_to_coarse(coarse, factor, n_fine)
assert len(fine) == n_fine
for i, val in enumerate(coarse):
expected = np.full(factor, val)
npt.assert_array_equal(fine[i * factor : i * factor + factor], expected)
# -----------------------------------------------------------------------
# 4. adjust_for_splits — 2-for-1 split at bar 50 in 100-bar series
# -----------------------------------------------------------------------
def test_adjust_for_splits_halves_historical(self):
from ferro_ta.analysis.adjust import adjust_for_splits
close = np.ones(100) * 100.0
adjusted = adjust_for_splits(close, split_factors=[2.0], split_indices=[50])
# Prices before split (bars 0-49) should be halved
npt.assert_array_almost_equal(adjusted[:50], np.full(50, 50.0))
# Prices from split onwards unchanged
npt.assert_array_almost_equal(adjusted[50:], np.full(50, 100.0))
# -----------------------------------------------------------------------
# 5. adjust_for_dividends — dividend at bar 50; prices before reduced
# -----------------------------------------------------------------------
def test_adjust_for_dividends_reduces_historical(self):
from ferro_ta.analysis.adjust import adjust_for_dividends
close = np.ones(100) * 100.0
# bar 49 close = 100.0, dividend = 5.0 → factor = 95/100 = 0.95
adjusted = adjust_for_dividends(close, dividends=[5.0], ex_date_indices=[50])
# Prices before ex-date should be scaled by 0.95
expected_factor = (100.0 - 5.0) / 100.0
npt.assert_array_almost_equal(
adjusted[:50], np.full(50, 100.0 * expected_factor)
)
# Prices from ex-date onwards unchanged
npt.assert_array_almost_equal(adjusted[50:], np.full(50, 100.0))
# -----------------------------------------------------------------------
# 6. adjust_ohlcv — volume doubles on 2-for-1 split (inverse adjustment)
# -----------------------------------------------------------------------
def test_adjust_ohlcv_volume_increases_on_split(self):
from ferro_ta.analysis.adjust import adjust_ohlcv
n = 100
close = np.ones(n) * 100.0
open_ = close.copy()
high = close.copy()
low = close.copy()
volume = np.ones(n) * 1000.0
ao, ah, al, ac, av = adjust_ohlcv(
open_,
high,
low,
close,
volume,
split_factors=[2.0],
split_indices=[50],
)
# Volume before the split is multiplied by factor (2x) — more shares pre-split
npt.assert_array_almost_equal(av[:50], np.full(50, 2000.0))
# Volume at or after split unchanged
npt.assert_array_almost_equal(av[50:], np.full(50, 1000.0))
# Prices before split halved
npt.assert_array_almost_equal(ac[:50], np.full(50, 50.0))
npt.assert_array_almost_equal(ac[50:], np.full(50, 100.0))
# -----------------------------------------------------------------------
# 7. MultiTimeframeEngine — runs on 200 fine bars, returns valid result
# -----------------------------------------------------------------------
def test_multitf_engine_runs(self):
from ferro_ta.analysis.multitf import MultiTimeframeEngine
rng = np.random.default_rng(99)
n_fine = 200
close_fine = np.cumprod(1 + rng.standard_normal(n_fine) * 0.005) * 100.0
result = (
MultiTimeframeEngine(factor=4)
.with_htf_strategy("rsi_30_70")
.run(close_fine)
)
assert isinstance(result, AdvancedBacktestResult)
assert len(result.equity) == n_fine
assert np.all(np.isfinite(result.equity))
assert result.equity[0] == pytest.approx(1.0, rel=1e-6)
# -----------------------------------------------------------------------
# 8. plot_backtest — returns a plotly Figure (skip if plotly not installed)
# -----------------------------------------------------------------------
def test_plot_backtest_returns_figure(self):
pytest.importorskip("plotly", reason="plotly not installed")
from plotly.graph_objects import Figure
from ferro_ta.analysis.plot import plot_backtest
rng = np.random.default_rng(7)
n = 100
close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0
high = close * 1.01
low = close * 0.99
open_ = close * 0.999
result = (
BacktestEngine()
.with_ohlcv(high=high, low=low, open_=open_)
.run(close, strategy="rsi_30_70")
)
fig = plot_backtest(result, show=False, return_fig=True)
assert isinstance(fig, Figure)
# ===========================================================================
# Phase 4: Regime Detection, Portfolio Optimization, PaperTrader
# ===========================================================================
class TestPhase4Features:
"""Tests for Phase 4 differentiation features."""
# -----------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------
def _make_close(self, n: int = 300, seed: int = 77) -> np.ndarray:
rng = np.random.default_rng(seed)
return np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
def _make_ohlcv_local(self, n: int = 300, seed: int = 77):
rng = np.random.default_rng(seed)
close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
open_ = close * (1 - rng.uniform(0, 0.005, n))
high = close * (1 + rng.uniform(0, 0.01, n))
low = close * (1 - rng.uniform(0, 0.01, n))
return open_, high, low, close
# -----------------------------------------------------------------------
# 1. detect_volatility_regime
# -----------------------------------------------------------------------
def test_volatility_regime_labels_three_states(self):
from ferro_ta.analysis.regime import detect_volatility_regime
close = self._make_close(300)
labels = detect_volatility_regime(close, window=20, n_regimes=3)
assert labels.shape == (300,)
valid_values = {-1, 0, 1, 2}
assert set(np.unique(labels)).issubset(valid_values)
# Some valid (non-warmup) bars should be labeled
assert np.any(labels >= 0)
# -----------------------------------------------------------------------
# 2. detect_trend_regime
# -----------------------------------------------------------------------
def test_trend_regime_bull_bear(self):
from ferro_ta.analysis.regime import detect_trend_regime
# Uptrend: price steadily rising
n = 300
close_up = np.linspace(100, 200, n)
labels_up = detect_trend_regime(close_up, fast=10, slow=50)
valid = labels_up[labels_up != 0]
assert len(valid) > 0, "Expected some labeled bars after warmup"
# Most valid bars should be bull (1)
bull_frac = (valid == 1).sum() / len(valid)
assert bull_frac > 0.5, (
f"Expected mostly bull bars in uptrend, got {bull_frac:.2%}"
)
# Downtrend: price steadily declining
close_dn = np.linspace(200, 100, n)
labels_dn = detect_trend_regime(close_dn, fast=10, slow=50)
valid_dn = labels_dn[labels_dn != 0]
assert len(valid_dn) > 0
bear_frac = (valid_dn == -1).sum() / len(valid_dn)
assert bear_frac > 0.5, (
f"Expected mostly bear bars in downtrend, got {bear_frac:.2%}"
)
# -----------------------------------------------------------------------
# 3. detect_combined_regime
# -----------------------------------------------------------------------
def test_combined_regime_states(self):
from ferro_ta.analysis.regime import detect_combined_regime
close = self._make_close(500)
labels = detect_combined_regime(close, vol_window=20, fast=20, slow=50)
assert labels.shape == (500,)
valid_values = {-1, 0, 1, 2, 3, 4, 5}
assert set(np.unique(labels)).issubset(valid_values)
# -----------------------------------------------------------------------
# 4. RegimeFilter
# -----------------------------------------------------------------------
def test_regime_filter_zeros_disallowed(self):
from ferro_ta.analysis.regime import RegimeFilter, detect_combined_regime
n = 500
close = self._make_close(n)
signals = np.ones(n)
# Only allow regime 0 (bull + low vol)
rf = RegimeFilter(allowed_regimes=[0], vol_window=20, fast=20, slow=50)
filtered = rf.filter(signals, close)
regimes = detect_combined_regime(close, vol_window=20, fast=20, slow=50)
# Bars NOT in regime 0 should have filtered signal = 0
disallowed_mask = regimes != 0
assert np.all(filtered[disallowed_mask] == 0.0)
# Bars in regime 0 should retain their signal
allowed_mask = regimes == 0
if np.any(allowed_mask):
assert np.all(filtered[allowed_mask] == 1.0)
# -----------------------------------------------------------------------
# 5. mean_variance_optimize
# -----------------------------------------------------------------------
def test_mean_variance_weights_sum_to_one(self):
pytest.importorskip("scipy", reason="scipy not installed")
from ferro_ta.analysis.optimize import mean_variance_optimize
rng = np.random.default_rng(0)
returns = rng.standard_normal((252, 4)) * 0.01
w = mean_variance_optimize(returns)
assert w.shape == (4,)
assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6)
assert np.all(w >= -1e-9), "Weights should be non-negative (no short)"
# -----------------------------------------------------------------------
# 6. risk_parity_optimize
# -----------------------------------------------------------------------
def test_risk_parity_weights_sum_to_one(self):
pytest.importorskip("scipy", reason="scipy not installed")
from ferro_ta.analysis.optimize import risk_parity_optimize
rng = np.random.default_rng(1)
returns = rng.standard_normal((252, 3)) * 0.01
w = risk_parity_optimize(returns)
assert w.shape == (3,)
assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6)
assert np.all(w >= 0.0)
# -----------------------------------------------------------------------
# 7. max_sharpe_optimize
# -----------------------------------------------------------------------
def test_max_sharpe_weights_sum_to_one(self):
pytest.importorskip("scipy", reason="scipy not installed")
from ferro_ta.analysis.optimize import max_sharpe_optimize
rng = np.random.default_rng(2)
returns = rng.standard_normal((252, 5)) * 0.01
w = max_sharpe_optimize(returns)
assert w.shape == (5,)
assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6)
assert np.all(w >= -1e-9)
# -----------------------------------------------------------------------
# 8. PortfolioOptimizer fluent builder
# -----------------------------------------------------------------------
def test_portfolio_optimizer_fluent(self):
pytest.importorskip("scipy", reason="scipy not installed")
from ferro_ta.analysis.optimize import PortfolioOptimizer
rng = np.random.default_rng(3)
returns = rng.standard_normal((252, 3)) * 0.01
for method in ("min_variance", "risk_parity", "max_sharpe"):
w = (
PortfolioOptimizer()
.with_method(method)
.with_lookback(100)
.optimize(returns)
)
assert w.shape == (3,)
assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6)
# -----------------------------------------------------------------------
# 9. PaperTrader: basic fills
# -----------------------------------------------------------------------
def test_paper_trader_fills_on_signal(self):
from ferro_ta.analysis.live import PaperTrader
rng = np.random.default_rng(10)
n = 20
close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0
open_ = close * (1 - rng.uniform(0, 0.003, n))
high = close * (1 + rng.uniform(0.001, 0.005, n))
low = close * (1 - rng.uniform(0.001, 0.005, n))
trader = PaperTrader(initial_capital=100_000)
signals = np.where(np.arange(n) % 6 < 3, 1.0, -1.0).astype(float)
results = []
for i in range(n):
r = trader.on_bar(open_[i], high[i], low[i], close[i], signals[i])
results.append(r)
# Should have produced at least one fill after first bar
fills = [r for r in results if r.filled]
assert len(fills) > 0
# Equity curve length should match bars
assert len(trader.equity_curve) == n
# Final equity should be finite
assert math.isfinite(trader.equity)
# -----------------------------------------------------------------------
# 10. PaperTrader: stop-loss triggers
# -----------------------------------------------------------------------
def test_paper_trader_stop_loss_triggers(self):
from ferro_ta.analysis.live import PaperTrader
# Price rises on entry then falls sharply — SL should trigger
n = 20
close = np.array(
[100.0] * 5
+ [98.0, 96.0, 94.0, 92.0, 90.0] # declining
+ [88.0, 86.0, 84.0, 82.0, 80.0, 78.0, 76.0, 74.0, 72.0, 70.0],
dtype=float,
)
open_ = close * 1.001
high = close * 1.005
low = close * 0.99 # Low drops to trigger SL
sl_pct = 0.03 # 3% stop-loss
trader = PaperTrader(initial_capital=100_000, stop_loss_pct=sl_pct)
# Signal: go long on bar 0
signals = np.zeros(n)
signals[0] = 1.0 # enter long
for i in range(n):
trader.on_bar(open_[i], high[i], low[i], close[i], signals[i])
# With 3% SL and price dropping >3% below entry, we expect a trade to close
# Final position should be 0 (SL triggered exit)
assert trader.position == 0.0 or len(trader.trades) > 0
# -----------------------------------------------------------------------
# 11. PaperTrader: reset clears state
# -----------------------------------------------------------------------
def test_paper_trader_reset_clears_state(self):
from ferro_ta.analysis.live import PaperTrader
rng = np.random.default_rng(20)
n = 30
close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
open_ = close * 0.999
high = close * 1.01
low = close * 0.99
signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(float)
trader = PaperTrader(initial_capital=50_000)
for i in range(n):
trader.on_bar(open_[i], high[i], low[i], close[i], signals[i])
assert len(trader.equity_curve) > 0
trader.reset()
assert trader.position == 0.0
assert trader.equity == pytest.approx(1.0)
assert len(trader.trades) == 0
assert len(trader.equity_curve) == 0
assert trader.equity_abs == pytest.approx(50_000.0)
# -----------------------------------------------------------------------
# 12. PaperTrader equity matches backtest_ohlcv_core
# -----------------------------------------------------------------------
def test_paper_trader_equity_matches_backtest(self):
from ferro_ta.analysis.live import PaperTrader
rng = np.random.default_rng(42)
n = 50
close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0
open_ = close * (1 - rng.uniform(0, 0.003, n))
high = close * (1 + rng.uniform(0.001, 0.005, n))
low = close * (1 - rng.uniform(0.001, 0.005, n))
signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(np.float64)
# Vectorized Rust engine
_, _, _, _, eq_rust = backtest_ohlcv_core(open_, high, low, close, signals)
# PaperTrader bar-by-bar
trader = PaperTrader(initial_capital=100_000)
for i in range(n):
trader.on_bar(open_[i], high[i], low[i], close[i], signals[i])
eq_paper = np.array(trader.equity_curve)
assert eq_paper.shape == eq_rust.shape
npt.assert_allclose(
eq_paper,
eq_rust,
rtol=1e-6,
atol=1e-9,
err_msg="PaperTrader equity curve does not match backtest_ohlcv_core",
)