feat(strategy): share causal ledger and bounded seven-strategy research
CI / lite (pull_request) Canceled after 0s
CI / lite (pull_request) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user