437 lines
16 KiB
Python
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"
|