Files
quant_engine/tests/test_strategy_research.py
T
2026-10-04 11:10:01 +08:00

437 lines
16 KiB
Python

"""Caller-supplied OHLC, causal signals and the real shared daily ledger."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from quant_engine import strategy_research as research
def bars(closes, opens=None):
close = np.asarray(closes, dtype=float)
opening = np.asarray(opens if opens is not None else closes, dtype=float)
return pd.DataFrame(
{
"open": opening,
"high": np.maximum(close, opening),
"low": np.minimum(close, opening),
"close": close,
},
index=pd.date_range("2026-01-01", periods=len(close), freq="B"),
)
def run(name, feed, **kwargs):
return research.run_strategy_research(
name, feed, asset="SYNTHETIC", initial_cash=1000, commission=0, stamp_duty=0, **kwargs
)
def test_buy_hold_signal_close_next_open_and_last_close_value():
result = research.run_strategy_research(
"BuyAndHold",
bars([10, 11, 12, 13], [10, 10, 11, 12]),
asset="SYNTHETIC",
initial_cash=1000,
commission=0.01,
stamp_duty=0,
params={"buy_pct": 1},
)
assert result.ledger.nav_series.tolist() == pytest.approx(
[1000, 1100 / 1.01, 1200 / 1.01, 1300 / 1.01]
)
assert result.ledger.total_rebalances == 1
trade = result.ledger.trades_frame.iloc[0]
assert trade["trade_date"] == "2026-01-02"
assert trade["price"] == 10
assert trade["fee"] == pytest.approx(1000 - 1000 / 1.01)
assert result.signals[0].decision_date == "2026-01-01"
assert result.signals[0].execution_date == "2026-01-02"
assert result.signals[0].status == "partial_fill"
assert result.metrics["total_return"] == pytest.approx(1300 / 1.01 / 1000 - 1)
assert result.pairing.win_rate is None
@pytest.mark.parametrize(
"name,params,closes",
[
("SmaCross", {"fast": 1, "slow": 2}, [10, 8, 12, 6, 14, 5, 12]),
("MACross", {"fast": 1, "slow": 2}, [10, 8, 12, 6, 14, 5, 12]),
("RSI", {"period": 2}, [10, 8, 6, 10, 14, 8, 6, 10]),
("BollingerBreakout", {"period": 2, "std_mult": 0.5}, [10, 10, 12, 8, 12, 8]),
("DualThrust", {"period": 2, "k1": 0.5, "k2": 0.5}, [10, 10, 12, 8, 12, 8]),
("TurtleBreakout", {"entry_period": 2, "exit_period": 2}, [10, 10, 12, 8, 12, 8]),
],
)
def test_each_strategy_has_real_entry_exit_and_sparse_execution(name, params, closes):
opening = [closes[0], *closes[:-1]]
result = run(name, bars(closes, opening), params=params)
trades = result.ledger.trades_frame
assert trades["side"].iloc[:2].tolist() == ["buy", "sell"]
assert result.signals[0].decision_date == "2026-01-05"
assert trades["trade_date"].iloc[0] == "2026-01-06"
for signal in result.signals:
if signal.execution_date:
assert signal.execution_date > signal.decision_date
assert len(result.ledger.positions) == len(closes)
assert all(position.cash >= -1e-9 for position in result.ledger.positions)
assert result.pairing.closed_lots
def test_final_day_signal_is_recorded_without_same_close_execution():
result = run("SmaCross", bars([10, 8, 12]), params={"fast": 1, "slow": 2})
assert result.ledger.trades_frame.empty
assert len(result.signals) == 1
assert result.signals[0].status == "no_next_session"
assert result.signals[0].execution_date is None
def test_atr_stop_uses_prior_peak_and_prior_atr_and_can_trigger():
feed = bars([10, 8, 8, 10, 20, 19, 18], [10, 10, 8, 8, 10, 20, 19])
params = {"fast": 2, "slow": 3, "atr_period": 1, "atr_mult": 0.1}
result = run("MACross", feed, params=params)
stop = next(signal for signal in result.signals if signal.reason == "atr_stop")
assert stop.decision_date == "2026-01-08"
assert stop.execution_date == "2026-01-09"
assert result.ledger.trades_frame.iloc[-1]["side"] == "sell"
disabled = run("MACross", feed, params=params | {"atr_period": 0})
assert all(signal.reason != "atr_stop" for signal in disabled.signals)
def test_flat_rsi_is_neutral_and_does_not_create_artificial_trades():
result = run("RSI", bars([10] * 8), params={"period": 2, "oversold": 40, "overbought": 60})
assert result.ledger.trades_frame.empty
assert result.metrics["sharpe"] is None
assert result.metric_unavailable["sharpe"] == "zero_volatility"
@pytest.mark.parametrize(
"name",
[
"BuyAndHold",
"SmaCross",
"MACross",
"RSI",
"BollingerBreakout",
"DualThrust",
"TurtleBreakout",
],
)
def test_default_parameters_and_future_perturbation_preserve_observed_prefix(name):
values = 10 + np.sin(np.arange(80) / 2) * 2
original = bars(values, np.r_[values[0], values[:-1]])
changed = original.copy()
changed.iloc[55:] *= 7
first = run(name, original)
second = run(name, changed)
assert first.ledger.positions[:55] == second.ledger.positions[:55]
assert first.ledger.daily_executions[:55] == second.ledger.daily_executions[:55]
observed = original.index[53].strftime("%Y-%m-%d")
assert [s for s in first.signals if s.decision_date <= observed] == [
s for s in second.signals if s.decision_date <= observed
]
assert first.parameters == research.strategy_parameters(name)
@pytest.mark.parametrize(
"mutation",
[
"missing_open",
"missing_high",
"missing_low",
"null",
"boolean",
"string",
"infinite",
"bad_bounds",
"duplicate",
"unsorted",
],
)
def test_invalid_ohlc_fails_before_ledger(monkeypatch, mutation):
feed = bars([10] * 40)
if mutation.startswith("missing_"):
feed = feed.drop(columns=mutation[8:])
elif mutation == "duplicate":
feed.index = [feed.index[0]] * len(feed)
elif mutation == "unsorted":
feed = feed.iloc[::-1]
elif mutation == "bad_bounds":
feed.iloc[0, feed.columns.get_loc("high")] = 9
else:
feed = feed.astype(object)
feed.iloc[0, 0] = {"null": None, "boolean": True, "string": "10", "infinite": float("inf")}[
mutation
]
calls = []
monkeypatch.setattr(
research, "simulate_daily_ledger_with_audit", lambda *a, **kw: calls.append(1)
)
with pytest.raises(ValueError, match=r"OHLC|prices|DatetimeIndex"):
run("DualThrust", feed)
assert calls == []
@pytest.mark.parametrize(
"name,params",
[
("SmaCross", {"fast": True}),
("SmaCross", {"fast": 20}),
("RSI", {"oversold": 70}),
("MACross", {"atr_period": -1}),
("TurtleBreakout", {"entry_period": "20"}),
("BuyAndHold", {"buy_pct": float("nan")}),
("DualThrust", {"unknown": 1}),
],
)
def test_invalid_parameters_before_ledger(monkeypatch, name, params):
calls = []
monkeypatch.setattr(
research, "simulate_daily_ledger_with_audit", lambda *a, **kw: calls.append(1)
)
with pytest.raises(ValueError, match=r"parameter|finite|less than|bounds|integer"):
run(name, bars([10] * 40), params=params)
assert calls == []
def test_insufficient_history_is_failure_before_ledger(monkeypatch):
calls = []
monkeypatch.setattr(
research, "simulate_daily_ledger_with_audit", lambda *a, **kw: calls.append(1)
)
with pytest.raises(ValueError, match=r"history"):
run("MACross", bars([10] * 30))
assert calls == []
def test_zero_allocation_is_no_change_not_a_filled_position():
result = run("BuyAndHold", bars([10, 11, 12]), params={"buy_pct": 0})
assert result.ledger.trades_frame.empty
assert result.signals[0].status == "no_change"
assert result.ledger.nav_series.tolist() == [1000, 1000, 1000]
def test_exit_below_minimum_is_not_filled_and_state_keeps_actual_holdings():
result = run(
"SmaCross",
bars([10, 8, 12, 6, 14, 5, 12], [10, 10, 8, 12, 6, 14, 5]),
params={"fast": 1, "slow": 2},
min_trade_amount=900,
)
exit_signal = result.signals[1]
assert exit_signal.target_weight == 0
assert exit_signal.status == "not_filled"
assert result.ledger.positions[4].holdings == {"SYNTHETIC": pytest.approx(1000 / 12)}
assert len(result.ledger.trades_frame) == 1
def test_total_loss_from_explicit_full_sell_fee_is_reported_as_zero_nav():
result = research.run_strategy_research(
"SmaCross",
bars([10, 8, 12, 6, 14, 5]),
asset="SYNTHETIC",
initial_cash=1000,
commission=0,
stamp_duty=1,
params={"fast": 1, "slow": 2},
)
assert result.ledger.nav_series.iloc[-1] == 0
assert result.metrics["total_return"] == -1
assert result.pairing.win_rate == 0
def test_turtle_entry_does_not_wait_for_longer_exit_lookback():
result = run(
"TurtleBreakout",
bars([10, 10, 12, 13, 14, 15, 16]),
params={"entry_period": 2, "exit_period": 5},
)
assert result.signals[0].decision_date == "2026-01-05"
def test_ma_entry_does_not_wait_for_optional_atr_calibration():
result = run(
"MACross", bars([10, 8, 12, 6, 14, 5, 12]), params={"fast": 1, "slow": 2, "atr_period": 5}
)
assert result.signals[0].decision_date == "2026-01-05"
def test_benchmark_exact_calendar_and_distinct_empty_unrequested_states():
feed = bars([10, 10, 10])
benchmark = pd.Series([100.0, 90.0, 99.0], index=feed.index)
result = run("BuyAndHold", feed, benchmark=research.BenchmarkInput("present", benchmark))
assert result.benchmark_status == "present"
assert result.benchmark_nav.tolist() == pytest.approx([1, 0.9, 0.99])
assert result.benchmark_metrics["total_return"] == pytest.approx(-0.01)
empty = run(
"BuyAndHold", feed, benchmark=research.BenchmarkInput("empty", pd.Series(dtype=float))
)
none = run("BuyAndHold", feed)
assert empty.benchmark_status == "empty"
assert empty.benchmark_nav is None
assert none.benchmark_status == "not_requested"
assert none.benchmark_nav is None
@pytest.mark.parametrize("status", ["missing_day", "duplicate", "source_error"])
def test_bad_benchmark_fails_before_any_strategy_execution(monkeypatch, status):
feed = bars([10, 10, 10])
series = pd.Series([100.0, 90.0, 99.0], index=feed.index)
if status == "missing_day":
series = series.iloc[:2]
elif status == "duplicate":
series.index = [feed.index[0]] * 3
observation = research.BenchmarkInput(
"source_error" if status == "source_error" else "present",
series if status != "source_error" else None,
)
calls = []
monkeypatch.setattr(
research, "simulate_daily_ledger_with_audit", lambda *a, **kw: calls.append(1)
)
with pytest.raises(ValueError, match=r"Benchmark|benchmark|DatetimeIndex"):
run("BuyAndHold", feed, benchmark=observation)
assert calls == []
def test_benchmark_numeric_underflow_is_not_filled_as_zero_return(monkeypatch):
feed = bars([10] * 4)
benchmark = research.BenchmarkInput(
"present", pd.Series([1e300, 1e300, 1e-300, 1e-300], index=feed.index)
)
calls = []
real_ledger = research.simulate_daily_ledger_with_audit
def observed(*args, **kwargs):
calls.append(1)
return real_ledger(*args, **kwargs)
monkeypatch.setattr(research, "simulate_daily_ledger_with_audit", observed)
with pytest.raises(ValueError, match=r"benchmark.*numeric|Benchmark.*numeric"):
run("BuyAndHold", feed, benchmark=benchmark)
assert calls == []
def test_nonfinite_portfolio_return_cannot_be_silently_dropped_from_metrics():
with pytest.raises(ValueError, match=r"return.*finite|return.*numeric"):
run("BuyAndHold", bars([10, 10, 1e-300, 1e300]), params={"buy_pct": 1})
@pytest.mark.parametrize(
"name,params,closes,expected_nav,expected_cash,prices,quantities,pnl",
[
(
"SmaCross",
{"fast": 1, "slow": 2},
[10, 8, 12, 6, 14, 5, 12],
[1000, 1000, 1000, 500, 500, 1250 / 7, 1250 / 7],
[1000, 1000, 1000, 0, 500, 0, 1250 / 7],
[12, 6, 14, 5],
[250 / 3, 250 / 3, 250 / 7, 250 / 7],
-5750 / 7,
),
(
"MACross",
{"fast": 1, "slow": 2},
[10, 8, 12, 6, 14, 5, 12],
[1000, 1000, 1000, 500, 500, 1250 / 7, 1250 / 7],
[1000, 1000, 1000, 0, 500, 0, 1250 / 7],
[12, 6, 14, 5],
[250 / 3, 250 / 3, 250 / 7, 250 / 7],
-5750 / 7,
),
(
"RSI",
{"period": 2},
[10, 8, 6, 10, 14, 8, 6, 10],
[1000, 1000, 1000, 5000 / 3, 7000 / 3, 7000 / 3, 7000 / 3, 35000 / 9],
[1000, 1000, 1000, 0, 0, 7000 / 3, 7000 / 3, 0],
[6, 14, 6],
[500 / 3, 500 / 3, 3500 / 9],
4000 / 3,
),
(
"BollingerBreakout",
{"period": 2, "std_mult": 0.5},
[10, 10, 12, 8, 12, 8],
[1000, 1000, 1000, 2000 / 3, 2000 / 3, 4000 / 9],
[1000, 1000, 1000, 0, 2000 / 3, 0],
[12, 8, 12],
[250 / 3, 250 / 3, 500 / 9],
-1000 / 3,
),
(
"DualThrust",
{"period": 2, "k1": 0.5, "k2": 0.5},
[10, 10, 12, 8, 12, 8],
[1000, 1000, 1000, 2000 / 3, 2000 / 3, 4000 / 9],
[1000, 1000, 1000, 0, 2000 / 3, 0],
[12, 8, 12],
[250 / 3, 250 / 3, 500 / 9],
-1000 / 3,
),
(
"TurtleBreakout",
{"entry_period": 2, "exit_period": 2},
[10, 10, 12, 8, 12, 8],
[1000, 1000, 1000, 2000 / 3, 2000 / 3, 2000 / 3],
[1000, 1000, 1000, 0, 2000 / 3, 2000 / 3],
[12, 8],
[250 / 3, 250 / 3],
-1000 / 3,
),
],
)
def test_hand_calculated_strategy_cash_nav_and_every_fill(
name, params, closes, expected_nav, expected_cash, prices, quantities, pnl
):
# These rational constants were calculated from the expected sparse trades,
# independently of the signal and ledger implementation.
result = run(name, bars(closes, [closes[0], *closes[:-1]]), params=params)
assert result.ledger.nav_series.tolist() == pytest.approx(expected_nav)
assert [position.cash for position in result.ledger.positions] == pytest.approx(expected_cash)
assert result.ledger.trades_frame["price"].tolist() == prices
assert result.ledger.trades_frame["qty"].tolist() == pytest.approx(quantities)
assert result.pairing.realized_net_pnl == pytest.approx(pnl)
assert result.metrics["total_return"] == pytest.approx(expected_nav[-1] / 1000 - 1)
@pytest.mark.parametrize(
"field,value",
[
("initial_cash", True),
("initial_cash", 0),
("commission", "0.01"),
("commission", -1),
("stamp_duty", float("nan")),
("stamp_duty", 2),
],
)
def test_money_contract_fails_before_ledger(monkeypatch, field, value):
calls = []
monkeypatch.setattr(
research, "simulate_daily_ledger_with_audit", lambda *a, **kw: calls.append(1)
)
with pytest.raises(ValueError, match=r"number|bounds|fee|Fee"):
research.run_strategy_research(
"BuyAndHold", bars([10, 10]), asset="SYNTHETIC", **{field: value}
)
assert calls == []
@pytest.mark.parametrize("scale", [1e-200, 1e200])
def test_bollinger_signal_is_invariant_to_representable_price_scaling(scale):
original = bars([10, 10, 12, 8, 12, 8])
normal = run("BollingerBreakout", original, params={"period": 2, "std_mult": 2})
scaled = run("BollingerBreakout", original * scale, params={"period": 2, "std_mult": 2})
assert scaled.signals == normal.signals
assert scaled.ledger.nav_series.tolist() == normal.ledger.nav_series.tolist()
def test_no_downside_sortino_is_explicitly_unavailable():
result = run("BuyAndHold", bars([10, 10, 11, 12]), params={"buy_pct": 0.5})
assert result.metrics["sortino"] is None
assert result.metric_unavailable["sortino"] == "no_downside_deviation"