Files

2018 lines
76 KiB
Python
Raw Permalink Normal View History

2026-07-09 05:08:16 +08:00
"""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",
)