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,58 @@
|
||||
"""Policies observe actual post-fill holdings and only schedule the next open."""
|
||||
|
||||
import pytest
|
||||
|
||||
from quant_engine.execution import ExecutionConfig, simulate_daily_ledger_with_audit
|
||||
|
||||
|
||||
def test_policy_next_open_actual_holdings_and_immutable_past_snapshots():
|
||||
seen = []
|
||||
|
||||
def decide(position):
|
||||
seen.append((position.date, dict(position.holdings), position.cash))
|
||||
position.holdings.clear()
|
||||
return {"A": 0.5} if position.date == "d1" else None
|
||||
|
||||
result = simulate_daily_ledger_with_audit(
|
||||
[],
|
||||
[("d1", {"A": 10}), ("d2", {"A": 20}), ("d3", {"A": 30})],
|
||||
[("d1", {"A": 10}), ("d2", {"A": 25}), ("d3", {"A": 40})],
|
||||
1000,
|
||||
ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
|
||||
decision_policy=decide,
|
||||
)
|
||||
assert seen[0][1] == {}
|
||||
assert seen[1][1] == {"A": 25}
|
||||
assert result.nav_series.tolist() == [1000, 1125, 1500]
|
||||
assert result.positions[1].holdings == {"A": 25}
|
||||
assert len(result.trades_frame) == 1
|
||||
|
||||
|
||||
def test_rejected_entry_does_not_create_a_position_for_policy():
|
||||
holdings = []
|
||||
|
||||
def decide(position):
|
||||
holdings.append(dict(position.holdings))
|
||||
return {"A": 1} if position.date == "d1" else None
|
||||
|
||||
result = simulate_daily_ledger_with_audit(
|
||||
[],
|
||||
[("d1", {"A": 10}), ("d2", {"A": 10})],
|
||||
[("d1", {"A": 10}), ("d2", {"A": 10})],
|
||||
1000,
|
||||
ExecutionConfig(min_trade_amount=2000),
|
||||
decision_policy=decide,
|
||||
)
|
||||
assert holdings == [{}, {}]
|
||||
assert result.trades_frame.empty
|
||||
|
||||
|
||||
def test_policy_and_fixed_schedule_cannot_be_mixed():
|
||||
with pytest.raises(ValueError, match="fixed"):
|
||||
simulate_daily_ledger_with_audit(
|
||||
[("d1", {"A": 1})],
|
||||
[("d1", {"A": 10})],
|
||||
[("d1", {"A": 10})],
|
||||
1000,
|
||||
decision_policy=lambda p: None,
|
||||
)
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Bounded optimizer runs actual core strategies and rejects ambiguous ranking."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from quant_engine import strategy_optimizer as optimizer
|
||||
from quant_engine.strategy_contracts import STRATEGIES, strategy_parameters
|
||||
from quant_engine.strategy_research import BenchmarkInput, run_strategy_research
|
||||
from quant_engine import strategy_contracts
|
||||
|
||||
|
||||
def feed(values=None):
|
||||
values = np.asarray(
|
||||
values if values is not None else 10 + 2 * np.sin(np.arange(80) / 2), dtype=float
|
||||
)
|
||||
return pd.DataFrame(
|
||||
dict.fromkeys(("open", "high", "low", "close"), values),
|
||||
index=pd.date_range("2026-01-01", periods=len(values), freq="B"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", STRATEGIES)
|
||||
def test_each_default_strategy_uses_the_real_ledger(name):
|
||||
defaults = strategy_parameters(name)
|
||||
key = next(iter(defaults))
|
||||
result = optimizer.optimize_strategy_research(
|
||||
name,
|
||||
feed(),
|
||||
asset="SYNTHETIC",
|
||||
param_grid={key: [defaults[key]]},
|
||||
objective="total_return",
|
||||
initial_cash=1000,
|
||||
commission=0.01,
|
||||
stamp_duty=0.002,
|
||||
)
|
||||
direct = run_strategy_research(
|
||||
name, feed(), asset="SYNTHETIC", initial_cash=1000, commission=0.01, stamp_duty=0.002
|
||||
)
|
||||
assert len(result.trials) == 1
|
||||
assert result.trials[0].result.ledger.positions == direct.ledger.positions
|
||||
assert result.trials[0].result.ledger.daily_executions == direct.ledger.daily_executions
|
||||
assert result.trials[0].score == direct.metrics["total_return"]
|
||||
assert result.decision_eligible is False
|
||||
|
||||
|
||||
def test_real_negative_zero_scores_and_explicit_costs_sort_without_defaults():
|
||||
result = optimizer.optimize_strategy_research(
|
||||
"BuyAndHold",
|
||||
feed([10, 10, 9, 8]),
|
||||
asset="SYNTHETIC",
|
||||
param_grid={"buy_pct": [0.5, 0, 0.25]},
|
||||
objective="total_return",
|
||||
initial_cash=1000,
|
||||
commission=0.01,
|
||||
stamp_duty=0.002,
|
||||
)
|
||||
assert [trial.parameters["buy_pct"] for trial in result.trials] == [0, 0.25, 0.5]
|
||||
assert [trial.score for trial in result.trials] == pytest.approx([0, -0.0525, -0.105])
|
||||
assert [trial.result.ledger.total_costs for trial in result.trials] == pytest.approx(
|
||||
[0, 2.5, 5]
|
||||
)
|
||||
|
||||
|
||||
def test_stable_ties_retain_canonical_axis_and_candidate_order():
|
||||
result = optimizer.optimize_strategy_research(
|
||||
"MACross",
|
||||
feed([10] * 40),
|
||||
asset="SYNTHETIC",
|
||||
param_grid={"atr_period": [2, 0], "fast": [5, 4]},
|
||||
objective="total_return",
|
||||
commission=0,
|
||||
stamp_duty=0,
|
||||
)
|
||||
assert [
|
||||
(trial.parameters["fast"], trial.parameters["atr_period"]) for trial in result.trials
|
||||
] == [(5, 2), (5, 0), (4, 2), (4, 0)]
|
||||
|
||||
|
||||
def test_hundred_combinations_are_unique_actual_results():
|
||||
result = optimizer.optimize_strategy_research(
|
||||
"MACross",
|
||||
feed(),
|
||||
asset="SYNTHETIC",
|
||||
param_grid={"fast": list(range(1, 11)), "slow": list(range(11, 21))},
|
||||
objective="total_return",
|
||||
commission=0,
|
||||
stamp_duty=0,
|
||||
)
|
||||
assert len(result.trials) == 100
|
||||
assert len({tuple(trial.parameters.items()) for trial in result.trials}) == 100
|
||||
assert all(len(trial.result.ledger.positions) == 80 for trial in result.trials)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"grid,objective",
|
||||
[
|
||||
({"fast": []}, "total_return"),
|
||||
({"fast": [5, 40]}, "total_return"),
|
||||
({"fast": [True]}, "total_return"),
|
||||
({"fast": [5]}, "unknown"),
|
||||
({"fast": [5, 5]}, "total_return"),
|
||||
(
|
||||
{"fast": list(range(1, 11)), "slow": list(range(11, 21)), "atr_period": [0, 1]},
|
||||
"total_return",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_or_partly_invalid_grid_never_starts_a_strategy(monkeypatch, grid, objective):
|
||||
calls = []
|
||||
monkeypatch.setattr(optimizer, "run_strategy_research", lambda *a, **kw: calls.append(1))
|
||||
with pytest.raises(
|
||||
ValueError, match=r"grid|Grid|candidate|number|less than|objective|Duplicate"
|
||||
):
|
||||
optimizer.optimize_strategy_research(
|
||||
"MACross", feed(), asset="SYNTHETIC", param_grid=grid, objective=objective
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_history_failure_for_one_candidate_prevents_all_runs(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(optimizer, "run_strategy_research", lambda *a, **kw: calls.append(1))
|
||||
with pytest.raises(ValueError, match=r"history"):
|
||||
optimizer.optimize_strategy_research(
|
||||
"MACross",
|
||||
feed([10] * 40),
|
||||
asset="SYNTHETIC",
|
||||
param_grid={"slow": [30, 100]},
|
||||
objective="total_return",
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_requested_benchmark_error_prevents_every_run(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(optimizer, "run_strategy_research", lambda *a, **kw: calls.append(1))
|
||||
with pytest.raises(ValueError, match=r"benchmark source"):
|
||||
optimizer.optimize_strategy_research(
|
||||
"BuyAndHold",
|
||||
feed(),
|
||||
asset="SYNTHETIC",
|
||||
param_grid={"buy_pct": [0.5, 1]},
|
||||
objective="total_return",
|
||||
benchmark=BenchmarkInput("source_error"),
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_undefined_sharpe_fails_the_ranking_instead_of_winning_as_zero():
|
||||
with pytest.raises(ValueError, match=r"objective.*unavailable"):
|
||||
optimizer.optimize_strategy_research(
|
||||
"BuyAndHold",
|
||||
feed([10] * 4),
|
||||
asset="SYNTHETIC",
|
||||
param_grid={"buy_pct": [0, 0.5]},
|
||||
objective="sharpe_ratio",
|
||||
commission=0,
|
||||
stamp_duty=0,
|
||||
)
|
||||
|
||||
|
||||
def test_large_grid_fails_before_creating_cartesian_product(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(strategy_contracts, "product", lambda *a, **kw: calls.append("product"))
|
||||
monkeypatch.setattr(optimizer, "run_strategy_research", lambda *a, **kw: calls.append("run"))
|
||||
with pytest.raises(ValueError, match=r"limit"):
|
||||
optimizer.optimize_strategy_research(
|
||||
"MACross",
|
||||
feed(),
|
||||
asset="SYNTHETIC",
|
||||
param_grid={
|
||||
"fast": list(range(1, 11)),
|
||||
"slow": list(range(11, 21)),
|
||||
"atr_period": list(range(10)),
|
||||
"atr_mult": list(range(1, 11)),
|
||||
},
|
||||
)
|
||||
assert calls == []
|
||||
@@ -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"
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Cost-aware FIFO pairing uses actual ledger cash flows, including both fees."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from quant_engine.execution import ExecutionConfig, simulate_daily_ledger_with_audit
|
||||
from quant_engine.trade_pairing import pair_ledger_trades
|
||||
|
||||
|
||||
def test_loss_after_both_fees_is_not_a_win():
|
||||
ledger = simulate_daily_ledger_with_audit(
|
||||
[("d1", {"A": 0.1}), ("d2", {})],
|
||||
[("d1", {"A": 10}), ("d2", {"A": 9})],
|
||||
[("d1", {"A": 10}), ("d2", {"A": 9})],
|
||||
1000,
|
||||
ExecutionConfig(commission_bps=100, stamp_tax_bps=200, slippage_bps=0, min_trade_amount=0),
|
||||
)
|
||||
pairing = pair_ledger_trades(ledger)
|
||||
assert len(pairing.closed_lots) == 1
|
||||
assert pairing.closed_lots[0].quantity == 10
|
||||
assert pairing.closed_lots[0].cost == 101
|
||||
assert pairing.closed_lots[0].net_proceeds == pytest.approx(87.3)
|
||||
assert pairing.realized_net_pnl == pytest.approx(-13.7)
|
||||
assert pairing.win_rate == 0
|
||||
assert pairing.open_lots == ()
|
||||
assert ledger.nav_series.tolist() == pytest.approx([999, 986.3])
|
||||
|
||||
|
||||
def test_last_day_multiple_fills_each_pay_once_and_match_nav():
|
||||
ledger = simulate_daily_ledger_with_audit(
|
||||
[("d2", {"A": 0.25, "B": 0.5})],
|
||||
[("d2", {"A": 10, "B": 20})],
|
||||
[("d1", {"A": 10, "B": 20}), ("d2", {"A": 10, "B": 20})],
|
||||
1000,
|
||||
ExecutionConfig(commission_bps=100, stamp_tax_bps=200, slippage_bps=0, min_trade_amount=0),
|
||||
)
|
||||
assert ledger.nav_series.tolist() == pytest.approx([1000, 992.5])
|
||||
assert ledger.trades_frame["fee"].tolist() == [2.5, 5.0]
|
||||
pairing = pair_ledger_trades(ledger)
|
||||
assert len(pairing.open_lots) == 2
|
||||
assert pairing.closed_lots == ()
|
||||
assert pairing.realized_net_pnl == 0
|
||||
assert pairing.win_rate is None
|
||||
|
||||
|
||||
def test_partial_fifo_sales_allocate_entry_cost_and_keep_unclosed_lot_out_of_win_rate():
|
||||
ledger = simulate_daily_ledger_with_audit(
|
||||
[("d1", {"A": 0.2}), ("d2", {"A": 0.1}), ("d3", {})],
|
||||
[("d1", {"A": 10}), ("d2", {"A": 10}), ("d3", {"A": 10})],
|
||||
[("d1", {"A": 10}), ("d2", {"A": 10}), ("d3", {"A": 10})],
|
||||
1000,
|
||||
ExecutionConfig(commission_bps=100, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
|
||||
)
|
||||
pairing = pair_ledger_trades(ledger)
|
||||
assert len(pairing.matches) == 2
|
||||
assert len(pairing.closed_lots) == 1
|
||||
assert pairing.closed_lots[0].quantity == 20
|
||||
assert pairing.closed_lots[0].cost == 202
|
||||
assert pairing.closed_lots[0].net_proceeds == pytest.approx(198)
|
||||
assert pairing.realized_net_pnl == pytest.approx(-4)
|
||||
assert pairing.win_rate == 0
|
||||
|
||||
|
||||
def test_same_day_sell_and_buy_are_different_lots_with_no_duplicate_fees():
|
||||
ledger = simulate_daily_ledger_with_audit(
|
||||
[("d1", {"A": 0.5}), ("d2", {"B": 0.5})],
|
||||
[("d1", {"A": 10, "B": 10}), ("d2", {"A": 10, "B": 10})],
|
||||
[("d1", {"A": 10, "B": 10}), ("d2", {"A": 10, "B": 10})],
|
||||
1000,
|
||||
ExecutionConfig(commission_bps=100, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
|
||||
)
|
||||
assert ledger.nav_series.tolist() == pytest.approx([995, 985.025])
|
||||
pairing = pair_ledger_trades(ledger)
|
||||
assert pairing.closed_lots[0].asset == "A"
|
||||
assert pairing.closed_lots[0].net_pnl == pytest.approx(-10)
|
||||
assert pairing.open_lots[0].asset == "B"
|
||||
|
||||
|
||||
def test_small_fractional_holding_is_not_destroyed_after_partial_sale():
|
||||
ledger = simulate_daily_ledger_with_audit(
|
||||
[("d1", {"A": 0.5}), ("d2", {"A": 0.25})],
|
||||
[("d1", {"A": 1e9}), ("d2", {"A": 1e9})],
|
||||
[("d1", {"A": 1e9}), ("d2", {"A": 1e9})],
|
||||
1000,
|
||||
ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
|
||||
)
|
||||
assert ledger.nav_series.tolist() == [1000, 1000]
|
||||
assert ledger.positions[-1].holdings["A"] == 2.5e-7
|
||||
pairing = pair_ledger_trades(ledger)
|
||||
assert pairing.open_lots[0].quantity == 2.5e-7
|
||||
assert pairing.open_lots[0].remaining_cost == 250
|
||||
|
||||
|
||||
def test_real_tiny_remaining_lot_is_not_treated_as_a_completed_trade():
|
||||
ledger = simulate_daily_ledger_with_audit(
|
||||
[("d1", {"A": 1}), ("d2", {"A": 1e-13})],
|
||||
[("d1", {"A": 1}), ("d2", {"A": 1})],
|
||||
[("d1", {"A": 1}), ("d2", {"A": 1})],
|
||||
1e12,
|
||||
ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
|
||||
)
|
||||
pairing = pair_ledger_trades(ledger)
|
||||
assert pairing.closed_lots == ()
|
||||
assert pairing.win_rate is None
|
||||
assert pairing.open_lots[0].quantity == ledger.positions[-1].holdings["A"]
|
||||
assert pairing.open_lots[0].remaining_cost == pytest.approx(ledger.positions[-1].holdings["A"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("price", [3, 11, 13])
|
||||
def test_complete_exit_closes_all_accumulated_lots_without_rounding_residue(price):
|
||||
prices = [(date, {"A": price}) for date in ("d1", "d2", "d3", "d4")]
|
||||
ledger = simulate_daily_ledger_with_audit(
|
||||
[("d1", {"A": 0.1}), ("d2", {"A": 0.2}), ("d3", {"A": 0.3}), ("d4", {})],
|
||||
prices,
|
||||
prices,
|
||||
1000,
|
||||
ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
|
||||
)
|
||||
pairing = pair_ledger_trades(ledger)
|
||||
assert ledger.positions[-1].holdings == {}
|
||||
assert pairing.open_lots == ()
|
||||
assert len(pairing.closed_lots) == 3
|
||||
assert sum(lot.cost for lot in pairing.closed_lots) == pytest.approx(300)
|
||||
assert sum(match.net_proceeds for match in pairing.matches) == pytest.approx(300)
|
||||
assert pairing.realized_net_pnl == pytest.approx(0)
|
||||
assert pairing.win_rate == 0
|
||||
Reference in New Issue
Block a user