\n",
" \n",
"\n",
""
],
"text/plain": [
- " statistic median p05 p95 mean \\\n",
- "0 cagr 0.056941 0.024305 0.091509 0.057132 \n",
- "1 sharpe 0.440494 0.089659 0.809244 0.440895 \n",
- "2 max_drawdown -0.181920 -0.288049 -0.124776 -0.190264 \n",
- "3 final_value 270550.906800 153970.547350 482428.885302 287009.475263 \n",
- "\n",
- " std \n",
- "0 0.020172 \n",
- "1 0.216179 \n",
- "2 0.050865 \n",
- "3 103309.798596 "
+ " statistic median p_lower p_upper mean std\n",
+ "0 CAGR 5.49% 2.22% 8.88% 5.51% 2.00%\n",
+ "1 Sharpe 0.43 0.07 0.79 0.42 0.22\n",
+ "2 Max Drawdown -18.31% -28.97% -12.50% -19.12% 5.16%\n",
+ "3 Final Value 261,246.26 148,376.54 461,585.16 277,007.31 99,029.24"
]
},
"execution_count": 4,
@@ -345,21 +340,23 @@
" periods_per_year=config.periods_per_year, initial_capital=config.initial_capital,\n",
" risk_free_rate=config.risk_free_rate,\n",
")\n",
- "boot.summary()\n"
+ "format_bootstrap_summary(boot.summary())\n"
]
},
{
"cell_type": "markdown",
- "id": "a353fc69",
+ "id": "2c48b6d2",
"metadata": {},
"source": [
"The median represents the typical result across the 1,000 synthetic histories.\n",
- "The `p05` and `p95` columns contain the middle 90% of the simulated outcomes:\n",
+ "The `p_lower` and `p_upper` columns contain the middle 90% of the simulated\n",
+ "outcomes (the 5th/95th percentiles by default; configurable via\n",
+ "`robustness.bootstrap.confidence_level`):\n",
"\n",
- "- for CAGR, Sharpe and final value, `p05` is the less favourable boundary and\n",
- " `p95` is the more favourable boundary;\n",
+ "- for CAGR, Sharpe and final value, `p_lower` is the less favourable boundary\n",
+ " and `p_upper` is the more favourable boundary;\n",
"- for maximum drawdown, a more negative value represents a worse loss, so\n",
- " `p05` is the more severe drawdown scenario.\n",
+ " `p_lower` is the more severe drawdown scenario.\n",
"\n",
"A narrow range suggests that the result is relatively insensitive to the\n",
"historical ordering of returns. A wide range means that performance depends\n",
@@ -372,7 +369,7 @@
},
{
"cell_type": "markdown",
- "id": "f2762934",
+ "id": "2b503279",
"metadata": {},
"source": [
"## Stress tests"
@@ -381,13 +378,13 @@
{
"cell_type": "code",
"execution_count": 5,
- "id": "76e10d58",
+ "id": "0423320b",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-08-25T17:42:35.820033Z",
- "iopub.status.busy": "2026-08-25T17:42:35.819798Z",
- "iopub.status.idle": "2026-08-25T17:44:19.355911Z",
- "shell.execute_reply": "2026-08-25T17:44:19.355040Z"
+ "iopub.execute_input": "2026-09-02T11:29:09.046979Z",
+ "iopub.status.busy": "2026-09-02T11:29:09.046511Z",
+ "iopub.status.idle": "2026-09-02T11:32:40.455226Z",
+ "shell.execute_reply": "2026-09-02T11:32:40.454262Z"
}
},
"outputs": [
@@ -425,70 +422,70 @@
"
\n",
"
0
\n",
"
baseline
\n",
- "
1.729543
\n",
- "
0.057462
\n",
- "
0.443716
\n",
- "
-0.158208
\n",
+ "
1.635962
\n",
+ "
0.055411
\n",
+ "
0.427824
\n",
+ "
-0.157609
\n",
"
ok
\n",
"
None
\n",
"
\n",
"
\n",
"
1
\n",
"
commission x2
\n",
- "
1.693180
\n",
- "
0.056673
\n",
- "
0.435414
\n",
- "
-0.158825
\n",
+ "
1.599435
\n",
+ "
0.054592
\n",
+ "
0.419038
\n",
+ "
-0.158248
\n",
"
ok
\n",
"
None
\n",
"
\n",
"
\n",
"
2
\n",
"
commission x5
\n",
- "
1.586945
\n",
- "
0.054309
\n",
- "
0.410480
\n",
- "
-0.160673
\n",
+ "
1.492836
\n",
+ "
0.052138
\n",
+ "
0.392657
\n",
+ "
-0.160164
\n",
"
ok
\n",
"
None
\n",
"
\n",
"
\n",
"
3
\n",
"
slippage x2
\n",
- "
1.693180
\n",
- "
0.056673
\n",
- "
0.435414
\n",
- "
-0.158825
\n",
+ "
1.599435
\n",
+ "
0.054592
\n",
+ "
0.419038
\n",
+ "
-0.158248
\n",
"
ok
\n",
"
None
\n",
"
\n",
"
\n",
"
4
\n",
"
execution delay +1
\n",
- "
1.681519
\n",
- "
0.056418
\n",
- "
0.432201
\n",
- "
-0.149058
\n",
+ "
1.571346
\n",
+ "
0.053955
\n",
+ "
0.411195
\n",
+ "
-0.149843
\n",
"
ok
\n",
"
None
\n",
"
\n",
"
\n",
"
5
\n",
"
best 10 days removed
\n",
- "
1.057687
\n",
- "
0.040967
\n",
- "
0.274617
\n",
- "
-0.168912
\n",
+ "
1.017310
\n",
+ "
0.039820
\n",
+ "
0.264549
\n",
+ "
-0.166871
\n",
"
ok
\n",
"
None
\n",
"
\n",
"
\n",
"
6
\n",
- "
reduced universe
\n",
- "
0.758890
\n",
- "
0.031919
\n",
- "
0.229232
\n",
- "
-0.105496
\n",
+ "
reduced universe (-1)
\n",
+ "
0.715095
\n",
+ "
0.030472
\n",
+ "
0.208463
\n",
+ "
-0.104675
\n",
"
ok
\n",
"
None
\n",
"
\n",
@@ -497,14 +494,14 @@
""
],
"text/plain": [
- " scenario total_return cagr sharpe max_drawdown \\\n",
- "0 baseline 1.729543 0.057462 0.443716 -0.158208 \n",
- "1 commission x2 1.693180 0.056673 0.435414 -0.158825 \n",
- "2 commission x5 1.586945 0.054309 0.410480 -0.160673 \n",
- "3 slippage x2 1.693180 0.056673 0.435414 -0.158825 \n",
- "4 execution delay +1 1.681519 0.056418 0.432201 -0.149058 \n",
- "5 best 10 days removed 1.057687 0.040967 0.274617 -0.168912 \n",
- "6 reduced universe 0.758890 0.031919 0.229232 -0.105496 \n",
+ " scenario total_return cagr sharpe max_drawdown \\\n",
+ "0 baseline 1.635962 0.055411 0.427824 -0.157609 \n",
+ "1 commission x2 1.599435 0.054592 0.419038 -0.158248 \n",
+ "2 commission x5 1.492836 0.052138 0.392657 -0.160164 \n",
+ "3 slippage x2 1.599435 0.054592 0.419038 -0.158248 \n",
+ "4 execution delay +1 1.571346 0.053955 0.411195 -0.149843 \n",
+ "5 best 10 days removed 1.017310 0.039820 0.264549 -0.166871 \n",
+ "6 reduced universe (-1) 0.715095 0.030472 0.208463 -0.104675 \n",
"\n",
" status error \n",
"0 ok None \n",
@@ -528,7 +525,7 @@
},
{
"cell_type": "markdown",
- "id": "d498daae",
+ "id": "e08d4f12",
"metadata": {},
"source": [
"## Monte Carlo permutation test\n",
@@ -555,13 +552,13 @@
{
"cell_type": "code",
"execution_count": 6,
- "id": "e5064f61",
+ "id": "f7ef01f2",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-08-25T17:44:19.357865Z",
- "iopub.status.busy": "2026-08-25T17:44:19.357588Z",
- "iopub.status.idle": "2026-08-25T17:44:19.675001Z",
- "shell.execute_reply": "2026-08-25T17:44:19.674147Z"
+ "iopub.execute_input": "2026-09-02T11:32:40.457395Z",
+ "iopub.status.busy": "2026-09-02T11:32:40.457137Z",
+ "iopub.status.idle": "2026-09-02T11:32:40.874787Z",
+ "shell.execute_reply": "2026-09-02T11:32:40.873685Z"
}
},
"outputs": [
@@ -569,7 +566,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
- "Real Sharpe: 0.666\n",
+ "Real Sharpe: 0.654\n",
"Empirical p-value (fraction of random sign-flips scoring >= real): 0.001\n"
]
}
@@ -583,7 +580,7 @@
},
{
"cell_type": "markdown",
- "id": "8364b5f4",
+ "id": "1444fcf1",
"metadata": {},
"source": [
"## Interpretation\n",
@@ -622,9 +619,9 @@
"version": "3.13.7"
},
"quantlab": {
- "code_hash": "01f453a13e89b4a77bb5e85538cd9963479ca9a60745ac9d35d77a2a58d5c3ba",
+ "code_hash": "9a7df03157f6e44d966d928f188e54f4fe7167e529588986b31ffb50ae228cbb",
"config_hashes": {
- "configs/momentum_sp500.yaml": "e2be7ba15eca4cf73930a4a991a252549c7a921d2c09d50bedc7c5c8f6552a7e"
+ "configs/momentum_sp500.yaml": "6fcd3ba14c9d49484b9d135237db69a828672a4e126d6c57f106a38ec47cac7f"
},
"generator": "scripts/build_notebooks.py"
}
diff --git a/scripts/generate_report.py b/scripts/generate_report.py
index a0d3a17..20cbb2e 100644
--- a/scripts/generate_report.py
+++ b/scripts/generate_report.py
@@ -19,8 +19,12 @@
import argparse
from pathlib import Path
-from quantlab.backtesting.result import save_with_walk_forward_reuse
+from quantlab.backtesting.result import (
+ resolve_experiment_directory,
+ save_with_walk_forward_reuse,
+)
from quantlab.backtesting.runner import run_backtest_from_config
+from quantlab.cli import _strategy_diagnostics_robustness
from quantlab.config import ExperimentConfig
from quantlab.constants import GENERATED_REPORTS_DIR
from quantlab.data.loader import DataLoader
@@ -36,6 +40,16 @@ def main() -> int:
configure_logging()
config = ExperimentConfig.from_yaml(args.config)
+ # This script's entire purpose is producing an HTML report, so it always
+ # renders one -- regardless of output.save_html_report/save_figures,
+ # which only govern whether *other* runs render the presentation layer.
+ config = config.revalidated_copy(
+ update={
+ "output": config.output.revalidated_copy(
+ update={"save_html_report": True, "save_figures": True}
+ )
+ }
+ )
data, report = DataLoader().load(config)
if report.warnings:
# Keep data-quality warnings visible for script users, matching the CLI.
@@ -43,10 +57,13 @@ def main() -> int:
for message in report.warnings:
print(f" - {message}")
result = run_backtest_from_config(data, config, data_quality_report=report)
- out_dir = GENERATED_REPORTS_DIR / config.experiment_name
+ out_dir = resolve_experiment_directory(config, default_root=GENERATED_REPORTS_DIR)
# Preserve compatible walk-forward evidence while regenerating the report.
# Reuse them only when the same compatibility checks as `quantlab report` pass.
- out = save_with_walk_forward_reuse(result, out_dir)
+ robustness_extra = _strategy_diagnostics_robustness(data, config)
+ out = save_with_walk_forward_reuse(
+ result, out_dir, robustness_extra=robustness_extra
+ )
if result.save_warnings:
# Numeric artefacts may still be saved when optional report rendering fails.
# Keep the status marker ASCII-only for legacy Windows console encodings.
diff --git a/scripts/notebook_cells.py b/scripts/notebook_cells.py
index 3bdbb5e..c47b130 100644
--- a/scripts/notebook_cells.py
+++ b/scripts/notebook_cells.py
@@ -289,8 +289,10 @@
"""\
The three indicators broadly agree on *when* SPY is stretched (their extremes
line up in time), but disagree on magnitude — this is exactly why the
-strategy config exposes `entry_zscore` / `exit_zscore` as tunable parameters
-rather than hard-coding one indicator's convention.
+strategy config exposes `entry_threshold` / `exit_threshold` as tunable
+parameters (on whichever `indicator` is selected — z-score, Bollinger %B, RSI,
+distance to a moving average, or percentile rank) rather than hard-coding
+one indicator's convention.
""",
),
("md", "## Full backtest: rolling z-score mean reversion"),
@@ -473,7 +475,7 @@
(
"code",
"""\
-z = rolling_zscore(spread, window=config.strategy.parameters["zscore_window"])
+z = rolling_zscore(spread, window=config.strategy.parameters["indicator_window"])
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(z.index, z.to_numpy(dtype=float), color="#2563eb", lw=0.8)
ax.axhline(2, color="#dc2626", lw=0.8, ls="--")
@@ -533,6 +535,7 @@
)
from quantlab.validation.bootstrap import bootstrap_returns
from quantlab.validation.robustness import run_stress_tests, monte_carlo_permutation
+from quantlab.reporting.tables import format_bootstrap_summary
config = ExperimentConfig.from_yaml("../configs/momentum_sp500.yaml")
data, report = DataLoader().load(config)
@@ -604,19 +607,21 @@
periods_per_year=config.periods_per_year, initial_capital=config.initial_capital,
risk_free_rate=config.risk_free_rate,
)
-boot.summary()
+format_bootstrap_summary(boot.summary())
""",
),
(
"md",
"""\
The median represents the typical result across the 1,000 synthetic histories.
-The `p05` and `p95` columns contain the middle 90% of the simulated outcomes:
+The `p_lower` and `p_upper` columns contain the middle 90% of the simulated
+outcomes (the 5th/95th percentiles by default; configurable via
+`robustness.bootstrap.confidence_level`):
-- for CAGR, Sharpe and final value, `p05` is the less favourable boundary and
- `p95` is the more favourable boundary;
+- for CAGR, Sharpe and final value, `p_lower` is the less favourable boundary
+ and `p_upper` is the more favourable boundary;
- for maximum drawdown, a more negative value represents a worse loss, so
- `p05` is the more severe drawdown scenario.
+ `p_lower` is the more severe drawdown scenario.
A narrow range suggests that the result is relatively insensitive to the
historical ordering of returns. A wide range means that performance depends
diff --git a/src/quantlab/backtesting/accounting.py b/src/quantlab/backtesting/accounting.py
index 6b566cb..bfc43c8 100644
--- a/src/quantlab/backtesting/accounting.py
+++ b/src/quantlab/backtesting/accounting.py
@@ -8,18 +8,23 @@
from __future__ import annotations
+from collections.abc import Sequence
from dataclasses import dataclass
from numbers import Real
import numpy as np
import pandas as pd
+from pandas.api.types import is_bool_dtype
from quantlab.constants import EPSILON
from quantlab.exceptions import BacktestError
from quantlab.execution.execution_model import ExecutionCosts, ExecutionModel
from quantlab.execution.orders import executed_weights as compute_executed_weights
+from quantlab.execution.orders import validate_execution_frame
from quantlab.execution.orders import weight_changes as compute_weight_changes
from quantlab.logging_config import get_logger
+from quantlab.portfolio.drift_compliance import restore_drift_compliance
+from quantlab.portfolio.rebalancing import _compliance_violations
from quantlab.risk.exposure import average_gross_exposure, average_net_exposure
logger = get_logger(__name__)
@@ -47,6 +52,27 @@ class AccountingResult:
# Net-equity estimate used to size volume-dependent slippage. Reuse it in
# the trade log to keep per-fill and aggregate costs consistent.
equity_for_costs: pd.Series
+ # True at every date from which prior-period equity was <= 0 -- trading
+ # stops there (see run_accounting's docstring/logging). Real provenance
+ # for the trade log's forced_liquidation adjustment, not a reconstruction:
+ # this is the exact same boolean condition run_accounting already uses to
+ # decide when to force positions flat.
+ ruined: pd.Series
+ # True on every (date, symbol) cell whose position was force-flattened
+ # by a stop-loss/take-profit breach on the REAL executed position (see
+ # `_detect_stop_loss_take_profit`) -- real provenance for the trade
+ # log's stop_loss/take_profit adjustments, mirroring how `ruined`
+ # already documents `forced_liquidation`. All-``False`` (never ``None``)
+ # when neither `stop_loss_pct` nor `take_profit_pct` was configured.
+ stop_loss_triggered: pd.DataFrame
+ take_profit_triggered: pd.DataFrame
+ # Real provenance from `apply_weight_drift` -- see `DriftProvenance`'s
+ # own field docs. All-``False`` (never ``None``) when
+ # `model_weight_drift` was not enabled for this run.
+ drift_compliance_forced: pd.DataFrame
+ drift_compliance_pending: pd.DataFrame
+ drift_turnover_actively_limited: pd.DataFrame
+ drift_turnover_touched: pd.DataFrame
def portfolio_metrics_from_accounting(
@@ -99,6 +125,7 @@ def _run_accounting_steps(
*,
force_flat: pd.Series | None = None,
cost_equity: pd.Series | None = None,
+ weight_changes_override: pd.DataFrame | None = None,
) -> AccountingResult:
"""Compute turnover, costs, returns and equity for one executed book.
@@ -112,9 +139,23 @@ def _run_accounting_steps(
bankruptcy, preventing a closing trade with no remaining capital.
cost_equity: Previous net-equity estimate used to size
volume-dependent slippage. The first pass uses gross equity.
+ weight_changes_override: When given, used as turnover/cost input
+ INSTEAD OF ``executed``'s own row-to-row diff -- required when
+ ``executed`` is not a plain step function (weight drift is
+ active), since consecutive rows then genuinely differ from
+ organic price movement alone, never a real trade; a naive diff
+ would charge phantom turnover/costs for every drifting row.
+ ``None`` (the default) computes turnover from ``executed``'s
+ own plain row-to-row diff.
"""
- # Turnover is the L1 change in the executed book.
- weight_changes = compute_weight_changes(executed)
+ # Turnover is the L1 change in the executed book -- from the real-
+ # trade-only override when given (see the docstring above), else the
+ # plain diff (correct on its own for a step-function `executed`).
+ weight_changes = (
+ compute_weight_changes(executed)
+ if weight_changes_override is None
+ else weight_changes_override
+ )
if force_flat is not None:
weight_changes = weight_changes.copy()
weight_changes.loc[force_flat, :] = 0.0
@@ -174,6 +215,28 @@ def _run_accounting_steps(
equity=equity,
gross_equity=gross_equity,
equity_for_costs=equity_for_costs,
+ # Overwritten by run_accounting (the only real caller of this
+ # internal helper) with the actual ruin/stop-loss/take-profit
+ # provenance -- these placeholders are never observed externally.
+ ruined=pd.Series(False, index=gross_returns.index),
+ stop_loss_triggered=pd.DataFrame(
+ False, index=executed.index, columns=executed.columns
+ ),
+ take_profit_triggered=pd.DataFrame(
+ False, index=executed.index, columns=executed.columns
+ ),
+ drift_compliance_forced=pd.DataFrame(
+ False, index=executed.index, columns=executed.columns
+ ),
+ drift_compliance_pending=pd.DataFrame(
+ False, index=executed.index, columns=executed.columns
+ ),
+ drift_turnover_actively_limited=pd.DataFrame(
+ False, index=executed.index, columns=executed.columns
+ ),
+ drift_turnover_touched=pd.DataFrame(
+ False, index=executed.index, columns=executed.columns
+ ),
)
@@ -184,6 +247,7 @@ def _solve_accounting(
initial_capital: float,
*,
force_flat: pd.Series | None = None,
+ weight_changes_override: pd.DataFrame | None = None,
) -> AccountingResult:
"""Solve equity-dependent costs to a self-consistent equity curve.
@@ -192,7 +256,12 @@ def _solve_accounting(
into the cost model until the maximum difference reaches the tolerance.
"""
result = _run_accounting_steps(
- executed, asset_returns, execution_model, initial_capital, force_flat=force_flat
+ executed,
+ asset_returns,
+ execution_model,
+ initial_capital,
+ force_flat=force_flat,
+ weight_changes_override=weight_changes_override,
)
if not len(result.equity):
return result
@@ -206,6 +275,7 @@ def _solve_accounting(
initial_capital,
force_flat=force_flat,
cost_equity=result.equity,
+ weight_changes_override=weight_changes_override,
)
residual = float((next_result.equity - result.equity).abs().max())
result = next_result
@@ -221,6 +291,858 @@ def _solve_accounting(
return result
+def _resolve_position_groups(
+ columns: pd.Index, position_groups: Sequence[tuple[str, ...]] | None
+) -> list[tuple[str, ...]]:
+ """Expand ``position_groups`` into a complete partition of ``columns``.
+
+ A symbol not mentioned in any declared group (or ``position_groups``
+ being ``None`` entirely) means "its own independent group" -- a
+ caller only needs to declare GENUINE multi-symbol groups (e.g.
+ pairs_trading's two legs via ``BaseStrategy.position_groups()``),
+ never every symbol individually.
+
+ Raises:
+ BacktestError: If any declared group is empty, repeats a symbol
+ within itself, references a symbol absent from ``columns``, or
+ overlaps a symbol already claimed by another declared group --
+ each would otherwise silently corrupt the stop-loss/take-profit
+ and weight-drift-compliance walks (double-processing a symbol
+ under two different entry timings, or applying the group-return
+ formula to a nonexistent column).
+ """
+ grouped: set[str] = set()
+ groups: list[tuple[str, ...]] = []
+ if position_groups is not None:
+ available = set(columns)
+ for group in position_groups:
+ members = tuple(group)
+ if not members:
+ raise BacktestError(
+ "position_groups entries must be non-empty; got an empty group."
+ )
+ if len(set(members)) != len(members):
+ raise BacktestError(
+ f"position_groups entry {members!r} repeats a symbol within itself."
+ )
+ unknown = [symbol for symbol in members if symbol not in available]
+ if unknown:
+ raise BacktestError(
+ f"position_groups entry {members!r} references symbol(s) "
+ f"{unknown} not present among the executed weights columns."
+ )
+ overlap = grouped & set(members)
+ if overlap:
+ raise BacktestError(
+ f"position_groups entry {members!r} overlaps symbol(s) "
+ f"{sorted(overlap)} already claimed by another declared "
+ "group -- every symbol may belong to at most one group."
+ )
+ groups.append(members)
+ grouped.update(members)
+ for column in columns:
+ if column not in grouped:
+ groups.append((column,))
+ return groups
+
+
+def _walk_group_stop_loss_take_profit(
+ gross_exposure: np.ndarray,
+ group_return: np.ndarray,
+ stop_loss_pct: float | None,
+ take_profit_pct: float | None,
+ reversed_without_flat: np.ndarray | None = None,
+) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ """Walk one position-group's own return path since its last entry.
+
+ ``group_return[t]`` is this group's realized return for period t,
+ per unit of gross exposure it represented that period (see
+ :func:`_detect_stop_loss_take_profit`'s docstring for the exact
+ formula and why it is correct under a static or dynamic hedge ratio,
+ rebalancing, long/short and partial entries/exits). "Entry" is the
+ first date after the group was fully flat (``gross_exposure <=
+ EPSILON``) that it becomes non-flat again, OR a date any leg's sign
+ flips directly (long to short or vice versa) without an intermediate
+ flat row (``reversed_without_flat[t]``) -- a same-bar reversal is
+ economically a close-then-reopen, so the new direction must start its
+ own fresh cumulative-return episode rather than silently inheriting
+ the old (opposite-direction) position's running total, which would
+ misattribute a break/breach to a position that was never actually
+ held.
+
+ A breach detected using periods THROUGH t (inclusive) force-flattens
+ period t+1 onward -- never period t itself, since t's own return has
+ already been realized by the time this decision could be made (no
+ look-ahead). Once force-flattened, the group stays flat until its
+ next flat-to-non-flat transition or same-bar reversal (no immediate
+ re-entry at a rebased price -- the same convention `mean_reversion`'s
+ own indicator-based stop uses, for consistency).
+
+ Returns ``(force_flat, stop_loss_triggered, take_profit_triggered)``,
+ each a boolean array aligned to ``gross_exposure``. The trigger
+ arrays mark the FIRST force-flattened date for their respective
+ cause (an "exit" event), not the date the breach was internally
+ detected.
+ """
+ n = len(gross_exposure)
+ force_flat = np.zeros(n, dtype=bool)
+ stop_loss_triggered = np.zeros(n, dtype=bool)
+ take_profit_triggered = np.zeros(n, dtype=bool)
+ reversed_flags = (
+ np.zeros(n, dtype=bool)
+ if reversed_without_flat is None
+ else reversed_without_flat
+ )
+ was_flat = True
+ cumulative = 1.0
+ stopped = False
+ stopped_reason: str | None = None
+ trigger_marked = False
+ for t in range(n):
+ if gross_exposure[t] <= EPSILON:
+ was_flat = True
+ stopped = False
+ stopped_reason = None
+ trigger_marked = False
+ cumulative = 1.0
+ continue
+ if was_flat or reversed_flags[t]:
+ cumulative = 1.0
+ stopped = False
+ stopped_reason = None
+ trigger_marked = False
+ was_flat = False
+ if stopped:
+ force_flat[t] = True
+ if not trigger_marked:
+ if stopped_reason == "stop_loss":
+ stop_loss_triggered[t] = True
+ else:
+ take_profit_triggered[t] = True
+ trigger_marked = True
+ continue
+ r = group_return[t]
+ if np.isfinite(r):
+ cumulative *= 1.0 + r
+ total_return = cumulative - 1.0
+ if stop_loss_pct is not None and total_return <= -stop_loss_pct:
+ stopped = True
+ stopped_reason = "stop_loss"
+ elif take_profit_pct is not None and total_return >= take_profit_pct:
+ stopped = True
+ stopped_reason = "take_profit"
+ return force_flat, stop_loss_triggered, take_profit_triggered
+
+
+def _detect_stop_loss_take_profit(
+ executed: pd.DataFrame,
+ asset_returns: pd.DataFrame,
+ position_groups: Sequence[tuple[str, ...]] | None,
+ stop_loss_pct: float | None,
+ take_profit_pct: float | None,
+ weight_changes: pd.DataFrame | None = None,
+) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame | None]:
+ """Detect and gate stop-loss/take-profit breaches on the REAL executed position.
+
+ Operates on ``executed`` (the actual post-shift, post-constraint,
+ post-rebalance/turnover-cap position a real portfolio would hold),
+ never on a strategy's raw signal -- a signal is not necessarily a
+ realized position (the allocator, portfolio constraints, rebalancing
+ schedule and turnover cap all sit between them), so gating on the
+ signal directly could force-flatten a position that was never
+ actually opened, or miss one that was.
+
+ For a position group ``G`` (one symbol, or e.g. pairs_trading's two
+ legs via ``position_groups``), at each date::
+
+ gross_exposure[t] = sum(|executed[s][t]| for s in G)
+ group_return[t] = sum(executed[s][t] * asset_returns[s][t] for s in G)
+ / gross_exposure[t]
+
+ ``group_return`` is the group's return per unit of ITS OWN gross
+ exposure at that date -- not a dollar contribution to total portfolio
+ equity (which would depend on how much capital was allocated to it,
+ irrelevant to "has this position itself moved against me by X%").
+ This normalization by the ACTUAL exposure held each period (not the
+ exposure at entry) is what makes the formula correct regardless of a
+ static or dynamic hedge ratio, weight changes, rebalancing, long/
+ short direction, or partial entries/exits: every period contributes
+ its realized return weighted by whatever was really held that
+ period, using EXACTLY the same ``executed``/``asset_returns`` this
+ module already computes internally (never a second, potentially
+ diverging calculation). For a single-symbol group this reduces
+ exactly to ``sign(executed[t]) * asset_returns[t]`` -- the standard
+ definition of a price-based stop-loss/take-profit.
+
+ Thresholds are evaluated on GROSS (pre-cost) return: QuantLab's
+ execution cost model is portfolio-level only (no per-symbol/per-group
+ cost decomposition exists), so an exact net-of-cost trigger is not
+ presently computable. This is a deliberate, disclosed design
+ convention -- not "the" universal definition of a stop-loss/take-
+ profit -- documented on ``stop_loss_pct``/``take_profit_pct``
+ themselves; a net-of-cost variant could be added separately if
+ per-position cost attribution is ever built.
+
+ Returns ``(gated_executed, stop_loss_triggered, take_profit_triggered,
+ gated_weight_changes)`` -- the trigger frames are booleans broadcast
+ across every column of the breaching group (matching the trade log's
+ row-per-symbol grain), all ``False`` when neither threshold is
+ configured. ``gated_weight_changes`` mirrors ``weight_changes`` (the
+ caller's own real-trade-only turnover series, e.g. from
+ :func:`apply_weight_drift`) with the forced flatten's own turnover
+ patched in correctly -- ``None`` in, ``None`` out (the caller then
+ falls back to plain re-diffing ``gated_executed``, which is already
+ exactly correct when every row-to-row change genuinely is a trade,
+ i.e. weight drift is not active).
+ """
+ if stop_loss_pct is None and take_profit_pct is None:
+ empty = pd.DataFrame(False, index=executed.index, columns=executed.columns)
+ return executed, empty, empty.copy(), weight_changes
+
+ groups = _resolve_position_groups(executed.columns, position_groups)
+ gated = executed.copy()
+ gated_weight_changes = None if weight_changes is None else weight_changes.copy()
+ # What was genuinely HELD immediately before whatever (if anything) this
+ # row itself already traded -- derived generically from the pre-gating
+ # (executed, weight_changes) pair, correct whether that row was a pure
+ # drift row (weight_changes == 0, so this is just `executed` itself) or
+ # a real trade/anchor row (subtracting that row's own delta recovers
+ # the pre-trade state) -- never a second, potentially diverging
+ # recomputation of the drift trajectory.
+ before_state = None if weight_changes is None else executed - weight_changes
+ stop_loss_triggered = pd.DataFrame(
+ False, index=executed.index, columns=executed.columns
+ )
+ take_profit_triggered = stop_loss_triggered.copy()
+ for group in groups:
+ columns = list(group)
+ group_executed = executed[columns]
+ gross_exposure = group_executed.abs().sum(axis=1).to_numpy(dtype=float)
+ with np.errstate(invalid="ignore", divide="ignore"):
+ group_return = (
+ (group_executed * asset_returns[columns]).sum(axis=1) / gross_exposure
+ ).to_numpy(dtype=float)
+ # A leg that flips sign directly (long to short or back) without an
+ # intermediate flat row is economically a close-then-reopen -- the
+ # new direction must start a fresh episode, never inherit the old
+ # (opposite) position's running cumulative return.
+ signs = np.sign(group_executed.to_numpy(dtype=float))
+ previous_signs = np.vstack([np.zeros((1, signs.shape[1])), signs[:-1]])
+ reversed_without_flat = np.any(
+ (previous_signs != 0.0) & (signs != 0.0) & (previous_signs != signs),
+ axis=1,
+ )
+ force_flat, sl, tp = _walk_group_stop_loss_take_profit(
+ gross_exposure,
+ group_return,
+ stop_loss_pct,
+ take_profit_pct,
+ reversed_without_flat,
+ )
+ if force_flat.any():
+ gated.loc[force_flat, columns] = 0.0
+ stop_loss_triggered.loc[sl, columns] = True
+ take_profit_triggered.loc[tp, columns] = True
+ if gated_weight_changes is not None and before_state is not None:
+ force_flat_series = pd.Series(force_flat, index=executed.index)
+ transition = force_flat_series & ~force_flat_series.shift(
+ 1, fill_value=False
+ )
+ # No organic drift-turnover is credited while flat, only the
+ # real closing trade on the first forced-flat row.
+ gated_weight_changes.loc[force_flat_series, columns] = 0.0
+ gated_weight_changes.loc[transition, columns] = (
+ 0.0 - before_state.loc[transition, columns]
+ )
+ return gated, stop_loss_triggered, take_profit_triggered, gated_weight_changes
+
+
+@dataclass(frozen=True)
+class DriftProvenance:
+ """Real, cell-level provenance from :func:`apply_weight_drift`.
+
+ ``drift_compliance_forced`` is True on the row a queued compliance
+ correction actually LANDED -- a fresh anchor, exactly like any other
+ real trade (turnover/costs/the trade log already pick this up
+ generically, with no special-casing, since it is just another row-to-
+ row change in the frame ``run_accounting`` is given).
+ ``drift_compliance_pending`` is True on every row a breach is known
+ and not yet (fully) resolved -- including the very row it was first
+ detected on, and every later row it is retried while blocked by
+ tradability. Mutually exclusive with ``drift_compliance_forced`` for
+ every (date, symbol) cell -- enforced in ``__post_init__`` below, since
+ the two masks are written by two independent, same-row `_try_restore`
+ calls in :func:`apply_weight_drift` with no shared memory of each
+ other's own verdict.
+ ``drift_turnover_actively_limited``/``drift_turnover_touched`` are the
+ exact analogue of :class:`~quantlab.portfolio.rebalancing.
+ TurnoverProvenance`'s own identically-named fields, but for ordinary
+ rebalance debt capped HERE (the only place ``maximum_turnover`` is
+ enforced once weight drift is active -- see ``engine.py``'s own
+ decision-level call).
+ """
+
+ drift_compliance_forced: pd.DataFrame
+ drift_compliance_pending: pd.DataFrame
+ drift_turnover_actively_limited: pd.DataFrame
+ drift_turnover_touched: pd.DataFrame
+
+ def __post_init__(self) -> None:
+ """Enforce that forced/pending are never both True for the same cell."""
+ overlap = self.drift_compliance_forced & self.drift_compliance_pending
+ if overlap.to_numpy().any():
+ raise BacktestError(
+ "drift_compliance_forced and drift_compliance_pending must "
+ "be mutually exclusive per cell -- this indicates a bug in "
+ "apply_weight_drift's same-row compliance handling."
+ )
+
+
+def _validate_drift_and_risk_options(
+ *,
+ long_only: bool,
+ maximum_weight: float | None,
+ maximum_gross_exposure: float | None,
+ maximum_net_exposure: float | None,
+ model_weight_drift: bool | None = None,
+ stop_loss_pct: float | None = None,
+ take_profit_pct: float | None = None,
+ maximum_turnover: float | None = None,
+) -> None:
+ """Validate the same invariants ``PortfolioConfig`` already enforces.
+
+ A strategy-driven caller (``engine.py``) only ever gets here with
+ already-validated values (``PortfolioConfig``'s own field constraints,
+ ``quantlab.strategies.base.validate_risk_control_parameters``) -- but
+ ``run_accounting``/``apply_weight_drift`` are BOTH public, directly
+ callable functions (tests, scripts, a future programmatic caller)
+ that bypass both, so this module must not silently accept a truthy
+ non-bool ``model_weight_drift``, a negative ``stop_loss_pct``, or a
+ negative ``maximum_weight`` reaching the drift-compliance LP as a
+ genuinely infeasible constraint and raising a confusing "bug in the
+ algorithm" error instead of a clear, immediate input-validation one.
+ """
+ flags: list[tuple[str, object]] = [("long_only", long_only)]
+ if model_weight_drift is not None:
+ flags.append(("model_weight_drift", model_weight_drift))
+ for flag_name, flag_value in flags:
+ if not isinstance(flag_value, (bool, np.bool_)):
+ raise BacktestError(f"{flag_name} must be a boolean, got {flag_value!r}.")
+ for pct_name, pct_value in (
+ ("stop_loss_pct", stop_loss_pct),
+ ("take_profit_pct", take_profit_pct),
+ ):
+ if pct_value is None:
+ continue
+ if isinstance(pct_value, (bool, np.bool_)) or not isinstance(pct_value, Real):
+ raise BacktestError(
+ f"{pct_name} must be a finite number, got {pct_value!r}."
+ )
+ if not np.isfinite(float(pct_value)) or float(pct_value) <= 0.0:
+ raise BacktestError(
+ f"{pct_name} must be strictly positive, got {pct_value!r}."
+ )
+ for cap_name, cap_value, strict, upper in (
+ ("maximum_weight", maximum_weight, True, 1.0),
+ ("maximum_gross_exposure", maximum_gross_exposure, True, None),
+ ("maximum_net_exposure", maximum_net_exposure, False, None),
+ ("maximum_turnover", maximum_turnover, True, None),
+ ):
+ if cap_value is None:
+ continue
+ if isinstance(cap_value, (bool, np.bool_)) or not isinstance(cap_value, Real):
+ raise BacktestError(
+ f"{cap_name} must be a finite number, got {cap_value!r}."
+ )
+ value = float(cap_value)
+ out_of_range = not np.isfinite(value) or (
+ value <= 0.0 if strict else value < 0.0
+ )
+ if out_of_range:
+ bound = "> 0" if strict else ">= 0"
+ raise BacktestError(
+ f"{cap_name} must be a finite number {bound}, got {cap_value!r}."
+ )
+ if upper is not None and value > upper:
+ raise BacktestError(f"{cap_name} must not exceed {upper}.")
+
+
+def _validate_tradable_mask(
+ tradable: pd.DataFrame, reference: pd.DataFrame, *, reference_name: str
+) -> pd.DataFrame:
+ """Validate and axis-align a strictly boolean ``tradable`` mask.
+
+ Shared by :func:`run_accounting` and :func:`apply_weight_drift` -- both
+ are directly callable public functions, so neither may silently accept
+ a mask on different axes than the frame it is meant to gate, or a
+ non-boolean column (e.g. the string ``"False"``, which would otherwise
+ coerce to truthy on the plain ``.to_numpy(dtype=bool)`` cast every
+ caller of this mask ultimately performs).
+ """
+ if not isinstance(tradable, pd.DataFrame):
+ raise BacktestError("tradable must be a pandas DataFrame.")
+ if not tradable.index.is_unique:
+ raise BacktestError("tradable index must not contain duplicate labels.")
+ if set(tradable.index) != set(reference.index) or set(tradable.columns) != set(
+ reference.columns
+ ):
+ raise BacktestError(
+ f"tradable must have the same dates and symbols as {reference_name}."
+ )
+ if tradable.isna().to_numpy().any():
+ raise BacktestError("tradable must not contain missing values.")
+ non_bool_columns = [
+ column for column, dtype in tradable.dtypes.items() if not is_bool_dtype(dtype)
+ ]
+ if non_bool_columns:
+ raise BacktestError(
+ f"tradable must contain only boolean values; column(s) "
+ f"{non_bool_columns} are not boolean dtype (e.g. a string "
+ "'False' would otherwise silently coerce to True)."
+ )
+ return tradable.reindex(index=reference.index, columns=reference.columns)
+
+
+def apply_weight_drift(
+ executed: pd.DataFrame,
+ asset_returns: pd.DataFrame,
+ tradable: pd.DataFrame | None,
+ position_groups: Sequence[tuple[str, ...]] | None,
+ *,
+ maximum_weight: float | None,
+ maximum_gross_exposure: float | None,
+ maximum_net_exposure: float | None,
+ long_only: bool,
+ rebalance_date: pd.DataFrame | None = None,
+ maximum_turnover: float | None = None,
+) -> tuple[pd.DataFrame, pd.DataFrame, DriftProvenance]:
+ """Evolve ``executed`` forward by organic price drift between real trades.
+
+ Walks ``executed`` -- the ALREADY shift-respecting-tradability, look-
+ ahead-barrier-applied real executed book (see :func:`quantlab.
+ execution.orders.executed_weights`), never the pre-shift decision
+ timeline :mod:`quantlab.portfolio.rebalancing` produces -- forward
+ between genuine trades via a per-column ``dollar[i]`` exposure and a
+ single shared relative equity ``E`` (``weight[i] = dollar[i] / E``).
+ Full mechanism -- the two kinds of per-column debt (hard-risk-limit
+ compliance debt via :func:`~quantlab.portfolio.drift_compliance.
+ restore_drift_compliance`'s LP, and turnover-capped ordinary rebalance
+ debt), their priority order, anchor detection, same-row combination
+ checks, and the bankruptcy guard -- is documented in
+ docs/backtesting.md#weight-drift, not repeated here.
+
+ Returns ``(pre_period_weights, trade_changes, provenance)``.
+ ``pre_period_weights`` is the weight HELD GOING INTO each row, BEFORE
+ that row's own return is applied (consistent with ``executed_weights
+ = held.shift(1)`` elsewhere in this module -- returning the
+ post-return value would double-count it). ``trade_changes`` is the
+ real per-row trade delta -- zero on a pure-drift row, the actual size
+ on a row that lands a rebalance or a compliance correction -- required
+ so a naive diff of ``pre_period_weights`` never sees organic drift
+ itself as a "trade" (see ``_run_accounting_steps``'s own
+ ``weight_changes_override``). Never raises or produces ``inf``/
+ ``NaN`` from this recursion itself -- a bankrupt anchor-episode
+ (relative ``E <= EPSILON``) is force-flattened and logged instead,
+ mirroring ``ruined``'s own handling. (A believed-fully-restored row
+ that still violates a constraint DOES raise -- see the compliance
+ re-check right before each row is finalized -- since that specific
+ case is a genuine bug in the LP, not a legitimate runtime outcome.)
+
+ ``tradable``/``position_groups`` are the same frames ``run_accounting``
+ already threads through elsewhere (tradability-aware shifting, stop-
+ loss/take-profit position groups) -- ``tradable is None`` treats every
+ column as always tradable (single-calendar short-circuit).
+ ``rebalance_date`` is a boolean, ``dates x symbols`` :class:`pandas.
+ DataFrame` matching ``executed``'s own index and columns exactly,
+ already shifted onto the executed timeline exactly like ``executed``
+ itself (see ``run_accounting``'s own docstring and ``engine.py``'s
+ construction of it) -- a column must never be marked ``True`` on a
+ date it is not itself genuinely tradable. ``None`` falls back to
+ value-diff-only anchor detection.
+ """
+ _validate_drift_and_risk_options(
+ long_only=long_only,
+ maximum_weight=maximum_weight,
+ maximum_gross_exposure=maximum_gross_exposure,
+ maximum_net_exposure=maximum_net_exposure,
+ maximum_turnover=maximum_turnover,
+ )
+ # A directly-callable public function (see the module docstring's own
+ # "run_accounting/apply_weight_drift are BOTH public" note) must not
+ # silently accept a malformed `executed`/`asset_returns`/`tradable` --
+ # unlike `run_accounting`, which is reached only through its own
+ # up-front validation, a caller can invoke this function directly with
+ # entirely unvalidated data.
+ executed = validate_execution_frame(executed, name="executed")
+ if not isinstance(asset_returns, pd.DataFrame):
+ raise BacktestError("asset_returns must be a pandas DataFrame.")
+ if not asset_returns.index.is_unique:
+ raise BacktestError("asset_returns index must not contain duplicate labels.")
+ if not asset_returns.columns.is_unique:
+ raise BacktestError("asset_returns columns must not contain duplicate labels.")
+ missing_dates = executed.index.difference(asset_returns.index)
+ missing_symbols = executed.columns.difference(asset_returns.columns)
+ if len(missing_dates) or len(missing_symbols):
+ raise BacktestError(
+ "asset_returns must cover every executed date and symbol "
+ f"(missing dates: {list(missing_dates)[:5]}, missing symbols: "
+ f"{list(missing_symbols)[:5]})."
+ )
+ asset_returns = asset_returns.reindex_like(executed)
+ try:
+ returns_values = asset_returns.to_numpy(dtype=float)
+ except (TypeError, ValueError) as exc:
+ raise BacktestError("asset_returns must contain only numeric values.") from exc
+ if np.isinf(returns_values).any():
+ raise BacktestError("asset_returns must not contain Infinity.")
+ finite_returns = returns_values[np.isfinite(returns_values)]
+ if (finite_returns < -1.0).any():
+ raise BacktestError(
+ "asset_returns must not contain simple returns below -1.0 (-100%)."
+ )
+ # Row 0 is exempt: it is always this series' own anchor (`previous_row
+ # is None`), with no prior row to have earned a return during -- a
+ # caller may legitimately start `executed` already non-zero (assuming
+ # a pre-existing position) with no return recorded for how it got
+ # there, mirroring `run_accounting`'s own `executed = held.shift(1)`
+ # convention, which always makes the production pipeline's row 0
+ # exactly 0 regardless of `held`. A genuinely MISSING return on an
+ # ALREADY-established held position from row 1 onward is still real,
+ # unambiguous missing data and must still raise.
+ missing_held_returns = np.isnan(returns_values) & (
+ np.abs(executed.to_numpy(dtype=float)) > EPSILON
+ )
+ if missing_held_returns.shape[0] > 0:
+ missing_held_returns[0, :] = False
+ if missing_held_returns.any():
+ bad = np.argwhere(missing_held_returns)[0]
+ raise BacktestError(
+ "asset_returns is missing a return for a held position: "
+ f"{executed.index[bad[0]]!r}/{executed.columns[bad[1]]!r}."
+ )
+ if tradable is not None:
+ tradable = _validate_tradable_mask(
+ tradable, executed, reference_name="executed"
+ )
+ columns = list(executed.columns)
+ n_rows, n_cols = executed.shape
+ groups = _resolve_position_groups(executed.columns, position_groups)
+ if rebalance_date is not None:
+ if not isinstance(rebalance_date, pd.DataFrame):
+ raise BacktestError("rebalance_date must be a pandas DataFrame.")
+ if not rebalance_date.index.is_unique:
+ raise BacktestError(
+ "rebalance_date index must not contain duplicate labels."
+ )
+ if set(rebalance_date.index) != set(executed.index) or set(
+ rebalance_date.columns
+ ) != set(executed.columns):
+ raise BacktestError(
+ "rebalance_date must have the same dates and symbols as executed."
+ )
+ if rebalance_date.isna().to_numpy().any():
+ raise BacktestError("rebalance_date must not contain missing values.")
+ non_bool_columns = [
+ column
+ for column, dtype in rebalance_date.dtypes.items()
+ if not is_bool_dtype(dtype)
+ ]
+ if non_bool_columns:
+ raise BacktestError(
+ f"rebalance_date must contain only boolean values; column(s) "
+ f"{non_bool_columns} are not boolean dtype (e.g. a string "
+ "'False' would otherwise silently coerce to True)."
+ )
+ rebalance_date_np = rebalance_date.reindex(
+ index=executed.index, columns=executed.columns
+ ).to_numpy(dtype=bool)
+ else:
+ rebalance_date_np = None
+
+ executed_np = executed.to_numpy(dtype=float)
+ returns_np = returns_values
+ if tradable is not None:
+ tradable_np = tradable.reindex(
+ index=executed.index, columns=executed.columns
+ ).to_numpy(dtype=bool)
+ else:
+ tradable_np = np.ones((n_rows, n_cols), dtype=bool)
+
+ out = np.zeros((n_rows, n_cols))
+ trade_changes = np.zeros((n_rows, n_cols))
+ drift_compliance_forced = np.zeros((n_rows, n_cols), dtype=bool)
+ drift_compliance_pending = np.zeros((n_rows, n_cols), dtype=bool)
+ drift_turnover_actively_limited = np.zeros((n_rows, n_cols), dtype=bool)
+ drift_turnover_touched = np.zeros((n_rows, n_cols), dtype=bool)
+
+ def _violations(row: np.ndarray) -> list[str]:
+ return _compliance_violations(
+ row,
+ maximum_weight=maximum_weight,
+ maximum_gross_exposure=maximum_gross_exposure,
+ maximum_net_exposure=maximum_net_exposure,
+ long_only=long_only,
+ )
+
+ def _try_restore(
+ row: np.ndarray, row_tradable: np.ndarray
+ ) -> tuple[np.ndarray, np.ndarray, bool] | None:
+ """``None`` if already compliant; else (corrected, relevant_mask, pending).
+
+ ``relevant_mask`` is the set of columns the caller must track for
+ this correction: every column the LP actually moved, plus --
+ ONLY when full compliance was not achievable (``pending=True``)
+ -- every currently-untradable column still holding a non-zero
+ position. The latter matters because a single untradable column
+ that IS the entire breach (nothing free exists to move at all)
+ would otherwise report an EMPTY moved-set despite a real,
+ unresolved violation -- silently losing both the provenance
+ marking and the "wait for this column to reopen" eligibility
+ check the pending state depends on.
+ """
+ if not _violations(row):
+ return None
+ result = restore_drift_compliance(
+ row,
+ columns,
+ row_tradable,
+ groups,
+ maximum_weight=maximum_weight,
+ maximum_gross_exposure=maximum_gross_exposure,
+ maximum_net_exposure=maximum_net_exposure,
+ long_only=long_only,
+ )
+ moved = np.abs(result.corrected - row) > EPSILON
+ if result.pending:
+ still_held_and_closed = (~row_tradable) & (np.abs(row) > EPSILON)
+ moved = moved | still_held_and_closed
+ return result.corrected, moved, result.pending
+
+ dollar = np.zeros(n_cols)
+ equity = 1.0
+ previous_row: np.ndarray | None = None
+ # Per-column ordinary rebalance debt: `ordinary_target[i]` is the
+ # decided value column `i` is being walked toward, meaningful only
+ # where `ordinary_mask[i]` is True. A fresh per-column decision (a
+ # value-diff or scheduled-rebalance anchor) overwrites BOTH for that
+ # column alone -- it never touches any OTHER column's own outstanding
+ # debt.
+ ordinary_mask = np.zeros(n_cols, dtype=bool)
+ ordinary_target = np.zeros(n_cols)
+ # Whether a hard-risk-limit breach is currently outstanding anywhere
+ # in the portfolio -- see the docstring's "compliance debt" section.
+ compliance_pending = False
+
+ for t in range(n_rows):
+ row = executed_np[t]
+ row_tradable = tradable_np[t]
+ if previous_row is None:
+ fresh = np.ones(n_cols, dtype=bool)
+ else:
+ fresh = np.abs(row - previous_row) > EPSILON
+ if rebalance_date_np is not None:
+ fresh = fresh | rebalance_date_np[t]
+ previous_row = row
+
+ # `dollar/equity` currently hold the state finalized at the END of
+ # row (t-1)'s own advance -- the weight HELD GOING INTO row t,
+ # before row t's own return. Every subsequent step below measures
+ # a real trade against this PRE-transaction value.
+ weight_prev = dollar / equity if equity > EPSILON else np.zeros(n_cols)
+
+ # Debt already outstanding BEFORE this row's own fresh decisions are
+ # folded in -- used below only to distinguish "this row's move is
+ # continuing a catch-up that was already running" from "this is a
+ # brand-new decision" for turnover-touched provenance; it plays no
+ # role in what actually executes.
+ carried_in_mask = ordinary_mask.copy()
+ ordinary_target = np.where(fresh, row, ordinary_target)
+ ordinary_mask = ordinary_mask | fresh
+
+ landed = weight_prev
+ landed_compliance_mask = np.zeros(n_cols, dtype=bool)
+
+ # --- Compliance debt: highest priority, exempt from
+ # `maximum_turnover`, re-solved fresh from THIS row's own weights
+ # (never a stale stored target -- see the docstring). A hard-limit
+ # correction is ORTHOGONAL to ordinary rebalance debt -- it never
+ # clears it, even for a column it moves: `ordinary_target` is
+ # always itself a validated-compliant value (upstream constraint
+ # enforcement, or an earlier compliance-restored point), so once
+ # this correction lands, resuming the walk toward that target is
+ # always safe, and a still-more-conservative target must not be
+ # silently abandoned just because compliance intervened first.
+ if compliance_pending:
+ restored = _try_restore(landed, row_tradable)
+ if restored is None:
+ compliance_pending = False
+ else:
+ corrected, moved, still_pending = restored
+ landed = np.where(moved, corrected, landed)
+ landed_compliance_mask = moved
+ if still_pending:
+ drift_compliance_pending[t, moved] = True
+ else:
+ drift_compliance_forced[t, moved] = True
+ compliance_pending = still_pending
+
+ # --- Ordinary rebalance debt: turnover-capped, for whatever
+ # columns are both owed a decision AND actually tradable this
+ # row -- a closed column's own debt simply waits, untouched, for
+ # a later row it reopens on, whether it is a fresh anchor or a
+ # multi-row catch-up.
+ eligible = ordinary_mask & row_tradable & ~landed_compliance_mask
+ desired = np.where(ordinary_mask, ordinary_target, landed)
+ change = np.where(eligible, desired - landed, 0.0)
+ requested = float(np.abs(change).sum())
+ if maximum_turnover is None or requested <= maximum_turnover + EPSILON:
+ fraction = 1.0
+ else:
+ fraction = maximum_turnover / requested
+ landed = landed + fraction * change
+ # Real, cell-level turnover-limiting provenance -- the exact
+ # analogue of `quantlab.portfolio.rebalancing.cap_turnover`'s own
+ # `turnover_actively_limited`/`turnover_touched` frames, since
+ # this is now the ONLY place `maximum_turnover` is actually
+ # enforced when weight drift is active (see `engine.py`'s own
+ # decision-level call, which passes `maximum_turnover=None` in
+ # that case for exactly this reason).
+ actively_limited = eligible & (fraction < 1.0 - EPSILON)
+ drift_turnover_actively_limited[t] = actively_limited
+ drift_turnover_touched[t] = eligible & (actively_limited | carried_in_mask)
+ if fraction >= 1.0 - EPSILON:
+ ordinary_mask = ordinary_mask & ~eligible
+
+ # --- A genuinely NEW violation can emerge purely from COMBINING
+ # this row's just-decided/corrected columns with another column's
+ # frozen or still-drifting value -- unlike organic drift (an
+ # exogenous price move needing a one-row lag to react to), every
+ # input to this combination is already known before finalizing
+ # this row's own output, so there is no look-ahead concern in
+ # resolving it immediately. Only runs when something was actually
+ # decided this row; a pure, undisturbed drift row instead keeps
+ # the ordinary one-row-lag detect-then-queue behavior below.
+ decided_this_row = bool(landed_compliance_mask.any() or eligible.any())
+ if decided_this_row and not compliance_pending:
+ restored = _try_restore(landed, row_tradable)
+ if restored is not None:
+ corrected, moved, still_pending = restored
+ landed = np.where(moved, corrected, landed)
+ # This is a SECOND, independent `_try_restore` call (Step 1
+ # above may have already run its own on this same row) --
+ # its own write to `landed` for `moved` cells supersedes
+ # whatever Step 1 already recorded for the SAME cell
+ # earlier this row, since Step 1's `landed` value for
+ # those cells has just been overwritten above. Clear
+ # first so `forced`/`pending` can never both be True for
+ # the same cell in the same row -- this call's own
+ # verdict is authoritative for any cell it touches.
+ drift_compliance_forced[t, moved] = False
+ drift_compliance_pending[t, moved] = False
+ if still_pending:
+ drift_compliance_pending[t, moved] = True
+ compliance_pending = True
+ else:
+ drift_compliance_forced[t, moved] = True
+ elif not decided_this_row and not compliance_pending:
+ # Pure organic drift newly breaching a limit -- queued, never
+ # applied to this row's own output (the portfolio genuinely
+ # held the breaching value for one row; correcting it
+ # retroactively would be look-ahead). Lands starting the next
+ # row via the compliance-debt branch above.
+ restored = _try_restore(landed, row_tradable)
+ if restored is not None:
+ _corrected, moved, _still_pending = restored
+ drift_compliance_pending[t, moved] = True
+ compliance_pending = True
+
+ if not compliance_pending:
+ # Defensive check, mirroring `rebalancing._assert_holdings_
+ # compliant`'s own "never trust the invariant blindly"
+ # philosophy: whenever the row-walk believes no compliance
+ # debt remains outstanding for THIS row, `landed` must
+ # actually be compliant -- checked on the row's final,
+ # fully-assembled value (not inside `_try_restore` itself,
+ # which can legitimately return `pending=False` for an
+ # INTERMEDIATE state that a later, independent `_try_restore`
+ # call in this same row -- see the "combining this row's
+ # just-decided columns" branch above -- is specifically
+ # responsible for re-checking against a value it hadn't seen
+ # yet). A violation here would mean a bug in the LP
+ # formulation (or in how this loop combines its calls), not a
+ # bad input -- but a silent violation would be an expensive,
+ # hard-to-diagnose out-of-mandate position, so this fails
+ # loudly rather than reporting a clean "compliance restored"
+ # trade-log event over a row that still breaches a limit.
+ remaining = _violations(landed)
+ if remaining:
+ raise BacktestError(
+ "apply_weight_drift produced a row believed fully "
+ f"compliant (no pending debt) that still violates: "
+ f"{', '.join(remaining)} -- this indicates a bug in "
+ "the drift-compliance restoration, not a legitimate "
+ "runtime condition."
+ )
+ out[t] = landed
+ trade_changes[t] = landed - weight_prev
+ touched = np.abs(landed - weight_prev) > EPSILON
+ dollar = np.where(touched, landed * equity, dollar)
+ if not ordinary_mask.any() and not compliance_pending:
+ # Pure numerical hygiene, never a behavior change: `weight =
+ # dollar / equity` is invariant under uniformly rescaling
+ # both, so renormalizing to `E = 1.0` is always safe whenever
+ # no debt of any kind remains outstanding.
+ dollar = landed.copy()
+ equity = 1.0
+
+ # === Advance state using row t's OWN return, with out[t] (this
+ # row's just-finalized output) as the base weight. `dollar/equity`
+ # already equal `out[t]` here. The result becomes `weight_prev` --
+ # and so, by default, `out[t+1]` -- for the next row. ===
+ r = returns_np[t]
+ r = np.where(np.isfinite(r), r, 0.0)
+ gross_return_t = float(np.sum(out[t] * r))
+ dollar = dollar * (1.0 + r)
+ equity = equity * (1.0 + gross_return_t)
+
+ if equity <= EPSILON:
+ dollar = np.zeros(n_cols)
+ equity = 1.0
+ ordinary_mask = np.zeros(n_cols, dtype=bool)
+ compliance_pending = False
+ logger.warning(
+ "Weight-drift equity (relative to its own last anchor, "
+ "gross/pre-cost) reached zero or below at %s -- "
+ "flattening this episode's drifted positions. Check "
+ "leverage and cost configuration.",
+ executed.index[t],
+ )
+
+ pre_period_weights = pd.DataFrame(
+ out, index=executed.index, columns=executed.columns
+ )
+ trade_changes_frame = pd.DataFrame(
+ trade_changes, index=executed.index, columns=executed.columns
+ )
+ provenance = DriftProvenance(
+ drift_compliance_forced=pd.DataFrame(
+ drift_compliance_forced, index=executed.index, columns=executed.columns
+ ),
+ drift_compliance_pending=pd.DataFrame(
+ drift_compliance_pending, index=executed.index, columns=executed.columns
+ ),
+ drift_turnover_actively_limited=pd.DataFrame(
+ drift_turnover_actively_limited,
+ index=executed.index,
+ columns=executed.columns,
+ ),
+ drift_turnover_touched=pd.DataFrame(
+ drift_turnover_touched, index=executed.index, columns=executed.columns
+ ),
+ )
+ return pre_period_weights, trade_changes_frame, provenance
+
+
def run_accounting(
held_weights: pd.DataFrame,
asset_returns: pd.DataFrame,
@@ -228,6 +1150,16 @@ def run_accounting(
initial_capital: float,
*,
tradable: pd.DataFrame | None = None,
+ stop_loss_pct: float | None = None,
+ take_profit_pct: float | None = None,
+ position_groups: Sequence[tuple[str, ...]] | None = None,
+ model_weight_drift: bool = False,
+ maximum_weight: float | None = None,
+ maximum_gross_exposure: float | None = None,
+ maximum_net_exposure: float | None = None,
+ long_only: bool = False,
+ rebalance_date: pd.DataFrame | None = None,
+ maximum_turnover: float | None = None,
) -> AccountingResult:
"""Run the vectorised accounting loop.
@@ -244,7 +1176,58 @@ def run_accounting(
tradable row, not the raw next row, so it is never misattributed
as trading during the closure itself (e.g. a weekend row that
only exists because another, always-open instrument shares the
- same combined index).
+ same combined index). Also the tradability `apply_weight_drift`
+ (when enabled) uses for its own compliance-restoration LP.
+ stop_loss_pct: Fractional (e.g. 0.10 = 10%) gross-return threshold
+ that force-flattens a position/group -- see
+ :func:`_detect_stop_loss_take_profit` for the exact formula
+ and why it operates on the real executed position rather than
+ a raw strategy signal. ``None`` (default) disables it, with
+ strictly no change to accounting's own numbers.
+ take_profit_pct: Same, on the favorable side.
+ position_groups: Column groups (e.g. pairs_trading's two legs)
+ whose combined P&L, not each column's own, drives the stop-
+ loss/take-profit check -- see :func:`_resolve_position_groups`.
+ A column absent from every group is its own independent group.
+ Also the groups `apply_weight_drift`'s compliance-restoration
+ LP moves coherently via one shared scalar.
+ model_weight_drift: When ``True``, evolve ``executed`` forward by
+ organic price drift between real trades (see
+ :func:`apply_weight_drift`) instead of holding it constant
+ until the next scheduled rebalance -- the constant-weight
+ step function is a documented approximation this flag
+ replaces with a materially more accurate one. ``False``
+ (default here) is strictly a no-op: ``executed`` passes
+ through unchanged.
+ maximum_weight: Per-asset hard cap, re-enforced on every drift row
+ when `model_weight_drift` is enabled (see
+ :func:`quantlab.portfolio.drift_compliance.
+ restore_drift_compliance`). Ignored when `model_weight_drift`
+ is ``False``.
+ maximum_gross_exposure: Portfolio-level gross cap, same treatment.
+ maximum_net_exposure: Portfolio-level net cap, same treatment.
+ long_only: Same treatment.
+ rebalance_date: Ignored unless `model_weight_drift` is `True`.
+ Boolean, ``dates x symbols`` DataFrame matching `held_weights`'
+ own dates and symbols, aligned to the EXECUTED timeline
+ exactly like `tradable` -- `True` on a (row, column) whose
+ underlying decision for THAT symbol was made on a genuine
+ scheduled rebalance date; a column must never be marked
+ `True` on a date it is not itself tradable. `None` falls back
+ to anchor detection from `executed`'s own row-to-row diff
+ alone. See :func:`apply_weight_drift`'s own docstring and
+ docs/backtesting.md#weight-drift for why this matters (a
+ constant-target schedule is otherwise invisible to value-
+ diffing) and the full anchor-detection mechanics.
+ maximum_turnover: Forwarded to :func:`apply_weight_drift` when
+ `model_weight_drift` is enabled, bounding the L1 size of an
+ ordinary anchor's catch-up trade (exempting a hard-risk-limit
+ drift-compliance correction -- see docs/backtesting.md#weight-
+ drift for the exact mechanics). Ignored when `model_weight_
+ drift` is `False` (the decision-level `rebalance_and_cap_
+ turnover` cap already applies there, unaffected by this
+ parameter either way). `None` (default) leaves anchor catch-
+ ups uncapped.
Returns:
A populated :class:`AccountingResult`.
@@ -265,6 +1248,21 @@ def run_accounting(
) from exc
if not np.isfinite(capital) or capital <= 0.0:
raise BacktestError("initial_capital must be a finite number greater than 0.")
+ if not isinstance(execution_model, ExecutionModel):
+ raise BacktestError(
+ f"execution_model must be an ExecutionModel instance, got "
+ f"{execution_model!r}."
+ )
+ _validate_drift_and_risk_options(
+ long_only=long_only,
+ maximum_weight=maximum_weight,
+ maximum_gross_exposure=maximum_gross_exposure,
+ maximum_net_exposure=maximum_net_exposure,
+ model_weight_drift=model_weight_drift,
+ stop_loss_pct=stop_loss_pct,
+ take_profit_pct=take_profit_pct,
+ maximum_turnover=maximum_turnover,
+ )
for name, frame in (
("held_weights", held_weights),
@@ -290,24 +1288,19 @@ def run_accounting(
)
if tradable is not None:
- # Exact same *set* of dates and symbols, no missing values -- unlike
- # asset_returns (which may legitimately come from a wider price
- # matrix), tradable is only ever built internally from held_weights'
- # own (date, symbol) grid, never user input. A mismatched set always
- # means an upstream wiring bug, so it must raise loudly rather than
+ # tradable is only ever built internally from held_weights' own
+ # (date, symbol) grid, never user input, so a mismatched axis set
+ # always means an upstream wiring bug -- raise loudly rather than
# silently default an unrecognized cell to "tradable" and risk
# trading a symbol that should have stayed closed. Axis *order*
# alone is not a mismatch: a caller may build tradable from a
# declared symbol list while held_weights comes from an
- # alphabetically-pivoted price matrix.
- if set(tradable.index) != set(held_weights.index) or set(
- tradable.columns
- ) != set(held_weights.columns):
- raise BacktestError(
- "tradable must have the same dates and symbols as held_weights."
- )
- if tradable.isna().to_numpy().any():
- raise BacktestError("tradable must not contain missing values.")
+ # alphabetically-pivoted price matrix. Strictly boolean dtype
+ # (never e.g. the string "False", which would otherwise coerce to
+ # truthy) is enforced by the shared helper.
+ tradable = _validate_tradable_mask(
+ tradable, held_weights, reference_name="held_weights"
+ )
held = held_weights.sort_index()
if tradable is not None:
@@ -330,7 +1323,65 @@ def run_accounting(
# sorted alongside it, so it needs no further alignment here.
executed = compute_executed_weights(held, tradable=tradable)
- result = _solve_accounting(executed, asset_returns, execution_model, capital)
+ # Weight drift operates on this ALREADY shift-respecting-tradability,
+ # look-ahead-barrier-applied real executed book -- see
+ # apply_weight_drift's own docstring for why this must be the post-
+ # shift executed timeline, never the pre-shift decision timeline
+ # rebalancing.py produces. `False` (default) is a provable no-op.
+ drift_provenance: DriftProvenance | None = None
+ # Real-trade-only turnover/cost input (see apply_weight_drift's own
+ # docstring): `None` outside drift, where `executed` is already a
+ # plain step function and every row-to-row diff genuinely IS a trade.
+ weight_changes_override: pd.DataFrame | None = None
+ if model_weight_drift:
+ executed, weight_changes_override, drift_provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ tradable,
+ position_groups,
+ maximum_weight=maximum_weight,
+ maximum_gross_exposure=maximum_gross_exposure,
+ maximum_net_exposure=maximum_net_exposure,
+ long_only=long_only,
+ rebalance_date=rebalance_date,
+ maximum_turnover=maximum_turnover,
+ )
+
+ result = _solve_accounting(
+ executed,
+ asset_returns,
+ execution_model,
+ capital,
+ weight_changes_override=weight_changes_override,
+ )
+
+ # Detect stop-loss/take-profit breaches on the REAL executed position
+ # from this initial pass, then -- exactly like the ruin handling below
+ # -- gate the affected cells and re-solve so turnover, costs, returns
+ # and equity are all self-consistent with the forced flatten. Checked
+ # BEFORE ruin: a stop-loss is a strategy-level risk control, not the
+ # portfolio-wide catastrophe ruin represents (which still overrides
+ # it below if both occur).
+ gated_executed, stop_loss_triggered, take_profit_triggered, gated_weight_changes = (
+ _detect_stop_loss_take_profit(
+ result.executed_weights,
+ asset_returns,
+ position_groups,
+ stop_loss_pct,
+ take_profit_pct,
+ weight_changes=weight_changes_override,
+ )
+ )
+ if stop_loss_triggered.to_numpy().any() or take_profit_triggered.to_numpy().any():
+ executed = gated_executed
+ weight_changes_override = gated_weight_changes
+ result = _solve_accounting(
+ executed,
+ asset_returns,
+ execution_model,
+ capital,
+ weight_changes_override=weight_changes_override,
+ )
# After equity reaches zero, flatten later positions and recompute so
# returns, turnover, costs and the trade log contain no post-ruin trades.
@@ -352,7 +1403,41 @@ def run_accounting(
execution_model,
capital,
force_flat=ruined,
+ weight_changes_override=weight_changes_override,
+ )
+ result.ruined = ruined
+ # `_solve_accounting` above (both the stop-loss/take-profit re-run and
+ # this ruin re-run) returns a fresh AccountingResult whose stop_loss_
+ # triggered/take_profit_triggered are all-False placeholders -- restore
+ # the real provenance detected earlier. `forced_liquidation` still wins
+ # over these in the trade log's own reason-priority ordering when both
+ # coincide on the same cell, so no need to clear them on ruined dates.
+ result.stop_loss_triggered = stop_loss_triggered
+ result.take_profit_triggered = take_profit_triggered
+ if drift_provenance is not None:
+ result.drift_compliance_forced = drift_provenance.drift_compliance_forced
+ result.drift_compliance_pending = drift_provenance.drift_compliance_pending
+ result.drift_turnover_actively_limited = (
+ drift_provenance.drift_turnover_actively_limited
)
+ result.drift_turnover_touched = drift_provenance.drift_turnover_touched
+ # `DriftProvenance.__post_init__` already enforced forced/pending
+ # mutual exclusion at construction time, but `AccountingResult` is
+ # a plain mutable dataclass with no invariant of its own -- that
+ # guarantee does not survive being flattened onto its separately-
+ # mutable fields above. Cheap re-check here too, so a future
+ # change to this flattening step (the only place that does it)
+ # can't silently reintroduce an overlap nothing downstream would
+ # otherwise catch.
+ overlap = result.drift_compliance_forced & result.drift_compliance_pending
+ if overlap.to_numpy().any():
+ raise BacktestError(
+ "AccountingResult.drift_compliance_forced and "
+ "drift_compliance_pending are not mutually exclusive after "
+ "being attached in run_accounting -- this indicates a bug "
+ "in that flattening step, not in DriftProvenance's own "
+ "construction."
+ )
logger.info(
"Accounting: %d periods, final equity %.2f (gross %.2f), avg turnover %.4f",
diff --git a/src/quantlab/backtesting/benchmark.py b/src/quantlab/backtesting/benchmark.py
index 3301e22..d05ecf2 100644
--- a/src/quantlab/backtesting/benchmark.py
+++ b/src/quantlab/backtesting/benchmark.py
@@ -118,6 +118,15 @@ def _align_returns(
~is_session_day(calendar, pd.DatetimeIndex(combined_index)),
index=combined_index,
)
+ # `combined_index[0]` can precede the benchmark's own first date
+ # (e.g. a 24/7 portfolio instrument trading on a date the
+ # benchmark's calendar marks as a holiday closure): that leading
+ # slot has nothing to forward-fill from, so seed it exactly like a
+ # closure -- but *only* when it genuinely is one. A leading date
+ # that is missing for any other reason (a real gap) must still
+ # raise below, unmasked by this.
+ if pd.isna(equity_on_combined.iloc[0]) and bool(closure.iloc[0]):
+ equity_on_combined.iloc[0] = 1.0
fillable = equity_on_combined.isna() & closure
equity_on_combined = equity_on_combined.mask(
fillable, equity_on_combined.ffill()
diff --git a/src/quantlab/backtesting/engine.py b/src/quantlab/backtesting/engine.py
index 147e886..a1445fe 100644
--- a/src/quantlab/backtesting/engine.py
+++ b/src/quantlab/backtesting/engine.py
@@ -34,6 +34,7 @@
from numbers import Integral
from pathlib import Path
+import numpy as np
import pandas as pd
from quantlab.backtesting.accounting import (
@@ -48,6 +49,7 @@
from quantlab.constants import (
CALENDAR_DAYS_PER_YEAR,
CRYPTO_FREQUENCY_TO_PERIODS_PER_YEAR,
+ EPSILON,
FREQUENCY_TO_PERIODS_PER_YEAR,
SYMBOL,
TIMESTAMP,
@@ -60,6 +62,7 @@
from quantlab.exceptions import BacktestError, QuantLabError
from quantlab.execution.execution_model import ExecutionModel
from quantlab.execution.orders import (
+ executed_weights,
shift_respecting_tradability,
validate_execution_frame,
)
@@ -70,12 +73,17 @@
)
from quantlab.logging_config import get_logger
from quantlab.portfolio.allocator import PortfolioAllocator, build_allocator
-from quantlab.portfolio.constraints import constraints_from_config
-from quantlab.portfolio.rebalancing import rebalance_and_cap_turnover
+from quantlab.portfolio.constraints import ConstraintTouch, constraints_from_config
+from quantlab.portfolio.rebalancing import (
+ apply_rebalancing,
+ rebalance_and_cap_turnover,
+ rebalance_dates,
+)
from quantlab.portfolio.volatility_targeting import apply_volatility_target
from quantlab.risk.metrics import compute_metrics
from quantlab.strategies.base import (
BaseStrategy,
+ SignalReasons,
build_strategy,
strategy_parameter_names,
)
@@ -548,10 +556,52 @@ def run(
)
asset_returns = compute_asset_returns(prices)
+ # Engine-injected context (never a user-configured constructor
+ # hyperparameter, see BaseStrategy.symbol_calendars's own
+ # docstring), set before any strategy method that might compute a
+ # rolling-window feature is called, so every native-calendar call
+ # site (quantlab.features.native_calendar.compute_native_then_
+ # align) can compute on each symbol's own calendar rather than a
+ # closure-padded combined timeline.
+ symbol_calendars = {
+ instrument.symbol: instrument.calendar
+ for instrument in config.data.instruments
+ }
+ strategy.symbol_calendars = symbol_calendars
+
# Require the strategy to cover the exact tradable panel.
signals = strategy.generate_signals(tradable_data)
signals = strategy._validate_signals(signals, prices)
+ # Optional, strategy-specific explanation of `signals` -- a pure,
+ # deterministic recomputation from the SAME tradable_data (see
+ # BaseStrategy.explain_signals's own docstring), never a cache of
+ # the call above. None for strategies that don't implement it
+ # (the default): the generic strategy_signal reason still works,
+ # just without a strategy-specific sub-code.
+ raw_signal_reasons = strategy.explain_signals(tradable_data)
+ signal_reasons: SignalReasons | None = (
+ None
+ if raw_signal_reasons is None
+ else BaseStrategy._validate_signal_reasons(
+ raw_signal_reasons.detail_code, raw_signal_reasons.details, prices
+ )
+ )
+
+ # Diagnostic decision proxy (optional, see BaseStrategy.decision_
+ # signal's own docstring): defaults to the raw signal itself, which
+ # is already a faithful decision proxy for every built-in strategy
+ # except pairs_trading (whose raw signal mixes a discrete decision
+ # with mechanical price/beta rescaling). Used ONLY for trigger
+ # detection and position_strategy_origin tracking below -- never
+ # substituted for `signals` in allocation, constraints or execution.
+ raw_decision_proxy = strategy.decision_signal(tradable_data)
+ decision_proxy = (
+ signals
+ if raw_decision_proxy is None
+ else BaseStrategy._validate_decision_signal(raw_decision_proxy, prices)
+ )
+
# Convert signals to target weights.
allocated = validate_execution_frame(
allocator.allocate(signals, tradable_data), name="allocator output"
@@ -583,9 +633,23 @@ def run(
periods_per_year=config.periods_per_year,
)
- # Enforce portfolio constraints.
+ # Fully-desired weights, post-allocation and post-volatility-target
+ # but pre-constraint -- captured under its own stable name before
+ # `constrained` (below) overwrites what `target_weights` means, so
+ # the trade log can later tell "didn't reach its desired size" (a
+ # constraint) apart from "the desired size itself changed" (signal/
+ # rebalance/vol-target). See build_trade_log's own docstring.
+ desired_target = target_weights
+
+ # Enforce portfolio constraints. apply_with_provenance runs the
+ # exact same computation as apply() (see its own docstring) and
+ # additionally records, per constraint, which cells it actually
+ # changed -- real provenance from the real computation, not a
+ # parallel reconstruction.
constraints = constraints_from_config(config.portfolio)
- constrained = constraints.apply(target_weights)
+ constrained, constraint_touches = constraints.apply_with_provenance(
+ target_weights
+ )
# Apply the shared stateful rebalancing/turnover pipeline once over
# the full index so its state remains continuous. A closed symbol
@@ -598,40 +662,477 @@ def run(
# real holiday set need not match every quirk of whatever data
# happens to be loaded for a lone calendar.
tradable = None
- symbol_calendars = {
- instrument.symbol: instrument.calendar
- for instrument in config.data.instruments
- }
shared_calendar = uniform_calendar(symbol_calendars.values())
if config.data.frequency == DAILY_FREQUENCY and shared_calendar is None:
+ # `prices.columns` (not `config.symbols`) is the order every
+ # other frame in this method is built and validated against
+ # (`allocated.columns.equals(prices.columns)` above, and
+ # transitively `constrained`/`held_weights`/the diagnostic
+ # frames below) -- `pivot_field` sorts symbols alphabetically,
+ # which need not match the instrument declaration order in
+ # `config.symbols`. `executed_weights(..., tradable=tradable)`
+ # (used throughout this method) requires its `tradable` frame
+ # to have EXACTLY matching columns, in the same order, so
+ # `tradable` must be built against `prices.columns` here too.
tradable = tradable_mask_for(
- pd.DatetimeIndex(prices.index), config.symbols, symbol_calendars
+ pd.DatetimeIndex(prices.index), list(prices.columns), symbol_calendars
+ )
+ # Position-group-coherent tradability (e.g. pairs_trading's two
+ # legs): a per-column tradable mask alone only guarantees each
+ # LEG's own eligibility independently -- a declared group must
+ # be eligible to move as ONE unit, on a date every member is
+ # tradable, never a date only some of its legs are (no legging
+ # risk is modeled). `_rebalance_tradability_aware`'s per-column
+ # "pending debt, retried every day" scheduling then operates on
+ # this group-collapsed mask directly. A symbol never in any
+ # declared group keeps its own independent tradability.
+ groups = strategy.position_groups()
+ if groups:
+ for group in groups:
+ members = [symbol for symbol in group if symbol in tradable.columns]
+ if len(members) > 1:
+ group_tradable = tradable[members].all(axis=1)
+ for symbol in members:
+ tradable[symbol] = group_tradable
+
+ # Diagnostic (not real-execution) frames used only to attribute a
+ # trade's reason -- signals/allocated/desired_target/constrained are
+ # recomputed fresh every row (never forward-filled the way
+ # held_weights is), so comparing them "yesterday vs today" would
+ # measure normal day-to-day drift, not "since the last rebalance
+ # decision". Resampling them the same way rebalance_and_cap_turnover
+ # itself samples `constrained` (via apply_rebalancing, using the
+ # same frequency/calendar) gives them the same rebalance-date
+ # cadence as held_weights, so a plain shift(1) inside
+ # build_trade_log correctly reads "value as of the previous
+ # rebalance decision". Never used to recompute an executed weight, cost or
+ # PnL figure -- only build_trade_log's reason classifier reads
+ # these. Centralised in one local helper so these frames can never
+ # drift out of sync with each other or with held_weights' own shift.
+ def _rebalance_diagnostic_frame(frame: pd.DataFrame) -> pd.DataFrame:
+ return apply_rebalancing(
+ frame, config.portfolio.rebalance_frequency, calendar=shared_calendar
+ )
+
+ # `rebalanced_signal` is built from `decision_proxy`, not `signals`
+ # directly: this is what prevents `strategy_signal` from firing on
+ # a strategy's purely mechanical rescaling (e.g. pairs_trading's
+ # price/beta drift at constant discrete state) that isn't a real
+ # new decision. `rebalanced_constrained` (new) is used only to
+ # build `target_episode_id` below.
+ rebalanced_signal = _rebalance_diagnostic_frame(decision_proxy)
+ rebalanced_allocated = _rebalance_diagnostic_frame(allocated)
+ rebalanced_desired = _rebalance_diagnostic_frame(desired_target)
+ rebalanced_constrained = _rebalance_diagnostic_frame(constrained)
+
+ # `target_episode_id`: a cell-level, monotonically increasing
+ # integer identifying which upstream decision produced the target
+ # currently being chased at each rebalance date -- the real
+ # identity a turnover-capped/tradability-deferred debt is scoped
+ # to (see rebalancing.py's `episode_id`), never a bare calendar
+ # counter (row-level) nor the target's own numeric value (two
+ # distinct decisions can coincidentally produce the same number).
+ # Increments a cell's counter only when a REAL upstream event
+ # concerns that cell: the exact same three diagnostic comparisons
+ # `_classify_reason` uses for trigger detection (signal/allocator/
+ # vol-target changed since the last rebalance), or -- when none of
+ # those fired -- the pre-turnover target itself still drifting
+ # (the same condition that drives the position_rescaling fallback
+ # below), which is how a continuously-rescaling target (pairs_
+ # trading) still gets a fresh episode per genuine change. A plain
+ # periodic re-sampling of the SAME still-pursued target increments
+ # nothing, so a turnover/tradability debt against it survives
+ # across rebalance dates as required.
+ def _changed_since_last_rebalance(frame: pd.DataFrame) -> pd.DataFrame:
+ values = frame.to_numpy()
+ previous = np.vstack([np.zeros((1, values.shape[1])), values[:-1]])
+ return pd.DataFrame(
+ np.abs(values - previous) > EPSILON,
+ index=frame.index,
+ columns=frame.columns,
)
- held_weights = rebalance_and_cap_turnover(
- constrained, config.portfolio, tradable=tradable, calendar=shared_calendar
+
+ signal_changed = _changed_since_last_rebalance(rebalanced_signal)
+ allocator_changed = _changed_since_last_rebalance(rebalanced_allocated)
+ vol_target_changed = _changed_since_last_rebalance(rebalanced_desired)
+ constrained_changed = _changed_since_last_rebalance(rebalanced_constrained)
+ no_trigger = ~(signal_changed | allocator_changed | vol_target_changed)
+ new_episode_this_row = (
+ signal_changed
+ | allocator_changed
+ | vol_target_changed
+ | (no_trigger & constrained_changed)
+ )
+ target_episode_id = new_episode_this_row.astype(int).cumsum()
+
+ # When weight drift is enabled, `apply_weight_drift` (below, via
+ # run_accounting) is the SOLE place `maximum_turnover` is applied --
+ # capping it here too would make this decision-level step produce
+ # an INTERMEDIATE, not-yet-fully-walked target (e.g. 0.2 while the
+ # true schedule target is 1.0), which the drift layer would then
+ # treat as "the" target, capable of trading the portfolio BACKWARD
+ # toward that stale intermediate value even while organic price
+ # drift has already carried it past it. Scheduling and
+ # tradability-aware pending-debt-carrying are UNCHANGED (`None`
+ # is already this module's own "uncapped" convention, not a
+ # separate code path) -- only the cap itself is turned off here.
+ decision_portfolio_config = (
+ config.portfolio.revalidated_copy(update={"maximum_turnover": None})
+ if config.portfolio.model_weight_drift
+ else config.portfolio
)
- if delay > 0:
+ held_weights, turnover_provenance = rebalance_and_cap_turnover(
+ constrained,
+ decision_portfolio_config,
+ tradable=tradable,
+ calendar=shared_calendar,
+ episode_id=target_episode_id,
+ return_provenance=True,
+ )
+
+ def _apply_extra_delay(frame: pd.DataFrame) -> pd.DataFrame:
+ if delay <= 0:
+ return frame
if tradable is not None:
# A raw row-count shift would delay execution onto a date a
# symbol can't actually trade on (see
- # shift_respecting_tradability's docstring) -- exactly the
- # same bug the mandatory look-ahead-barrier shift below
- # avoids, so the extra configured delay must avoid it too.
- held_weights = shift_respecting_tradability(
- held_weights, delay, tradable
- ).fillna(0.0)
+ # shift_respecting_tradability's docstring) -- the mandatory
+ # look-ahead-barrier shift below avoids exactly this, and the
+ # extra configured delay must avoid it too.
+ # Applied identically to held_weights and every reason
+ # frame, so they all stay aligned to the same decision date.
+ return shift_respecting_tradability(frame, delay, tradable).fillna(0.0)
+ return frame.shift(delay).fillna(0.0)
+
+ held_weights = _apply_extra_delay(held_weights)
+ desired_target_aligned = _apply_extra_delay(desired_target)
+ constrained_aligned = _apply_extra_delay(constrained)
+ rebalanced_signal = _apply_extra_delay(rebalanced_signal)
+ rebalanced_allocated = _apply_extra_delay(rebalanced_allocated)
+ rebalanced_desired = _apply_extra_delay(rebalanced_desired)
+
+ # constraint_touches (from apply_with_provenance above) is already
+ # at the same raw daily cadence as `constrained` itself -- not a
+ # rebalance-sampled diagnostic frame -- so it only needs the same
+ # delay+executed_weights alignment as executed_constrained, never
+ # _rebalance_diagnostic_frame. Boolean frames are round-tripped
+ # through float so they can reuse the exact same real numeric
+ # functions, then thresholded back to bool (exact, since the only
+ # values ever produced are 0.0/1.0).
+ def _align_bool(frame: pd.DataFrame) -> pd.DataFrame:
+ # `executed_weights` is built for *weights*, where a closed row
+ # correctly repeats the last tradable row's value (frozen, no
+ # reallocation while closed). Applied to a boolean flag, that
+ # same repetition would keep the flag True for every row a
+ # column stays closed after it lands True once -- wrong for a
+ # flag, which must describe THIS row's own event, never a
+ # carried-forward one. AND with `tradable` (when given) so a
+ # closed row's flag is always False, matching
+ # apply_weight_drift's own documented precondition; with no
+ # tradable mask at all, every row is implicitly tradable and
+ # this repetition concern cannot arise.
+ flag = (
+ executed_weights(
+ _apply_extra_delay(frame.astype(float)), tradable=tradable
+ )
+ > 0.5
+ )
+ return flag & tradable if tradable is not None else flag
+
+ # Genuine scheduled rebalance dates, aligned onto the executed
+ # timeline the same way held_weights becomes executed (extra
+ # delay, then the mandatory look-ahead-barrier shift) --
+ # apply_weight_drift's own anchor detection needs this, not just
+ # value-diffing `executed` against its own previous row: a
+ # rebalance whose freshly-decided target happens to numerically
+ # match the immediately preceding one (a constant-target
+ # schedule, or an unchanged signal) must still be treated as a
+ # real trade back to target, never silently absorbed into
+ # ongoing drift. See apply_weight_drift's own docstring.
+ schedule_dates = rebalance_dates(
+ pd.DatetimeIndex(constrained.index),
+ config.portfolio.rebalance_frequency,
+ calendar=shared_calendar,
+ )
+ is_rebalance_date_frame = pd.DataFrame(
+ np.broadcast_to(
+ constrained.index.isin(schedule_dates)[:, None], constrained.shape
+ ),
+ index=constrained.index,
+ columns=constrained.columns,
+ )
+ # Per-column, NOT collapsed with `.any(axis=1)`: a closed
+ # instrument must never be forced to anchor (and therefore trade)
+ # just because some OTHER instrument's own schedule/value-change
+ # fires the same row -- see apply_weight_drift's own docstring.
+ # `_align_bool` already routes this per-column through the exact
+ # same tradability-aware shift real weight values get, so a
+ # closed column's own flag correctly stays tied to ITS OWN next
+ # genuinely tradable session, never another column's.
+ rebalance_date = _align_bool(is_rebalance_date_frame)
+
+ aligned_constraint_touches: dict[str, ConstraintTouch] = {
+ name: ConstraintTouch(
+ touched=_align_bool(touch.touched),
+ before=executed_weights(
+ _apply_extra_delay(touch.before), tradable=tradable
+ ),
+ after=executed_weights(
+ _apply_extra_delay(touch.after), tradable=tradable
+ ),
+ direct=_align_bool(touch.direct),
+ )
+ for name, touch in constraint_touches.items()
+ }
+ # Split each redistribution-capable constraint's provenance into
+ # two entries -- the base name keeps only the directly-clipped
+ # cells, "*_redistribution" the touched-but-not-direct ones -- so
+ # build_trade_log can attribute each honestly, never a single
+ # winning constraint. Both entries share the SAME before/after
+ # (the real value immediately around this constraint's own
+ # effect); only which cells count as "touched" differs. Every
+ # other constraint (no redistribution concept, `direct == touched`
+ # by construction) passes through as a single entry unchanged.
+ redistribution_capable = frozenset(
+ {"maximum_weight", "minimum_weight", "maximum_positions"}
+ )
+ executed_constraint_touches: dict[str, ConstraintTouch] = {}
+ for name, touch in aligned_constraint_touches.items():
+ if name in redistribution_capable:
+ executed_constraint_touches[name] = ConstraintTouch(
+ touched=touch.direct,
+ before=touch.before,
+ after=touch.after,
+ direct=touch.direct,
+ )
+ redistribution_mask = touch.touched & ~touch.direct
+ executed_constraint_touches[f"{name}_redistribution"] = ConstraintTouch(
+ touched=redistribution_mask,
+ before=touch.before,
+ after=touch.after,
+ direct=pd.DataFrame(
+ False, index=touch.touched.index, columns=touch.touched.columns
+ ),
+ )
else:
- held_weights = held_weights.shift(delay).fillna(0.0)
+ executed_constraint_touches[name] = touch
+
+ # Real, cell-level turnover-cap/tradability provenance from
+ # rebalancing.py, aligned to executed_weights' index the same way
+ # as everything else above.
+ executed_turnover_actively_limited = _align_bool(
+ turnover_provenance.turnover_actively_limited
+ )
+ executed_turnover_touched = _align_bool(turnover_provenance.turnover_touched)
+ executed_tradability_touched = _align_bool(
+ turnover_provenance.tradability_touched
+ )
+ executed_tradability_compliance_limited = _align_bool(
+ turnover_provenance.tradability_compliance_limited
+ )
- # Accounting contains the one-period look-ahead barrier.
+ # Two seeds built from `decision_proxy` (never `signal_reasons.
+ # detail_code.notna()`, which would wrongly gate detection on
+ # whether a strategy-specific detail happens to exist -- a real
+ # transition without one must still be detected):
+ # - `trigger_detail_seed`: a plain, unbounded row-index pointer to
+ # the MOST RECENT transition (any magnitude change > EPSILON),
+ # never cleared -- consulted only when `strategy_signal` is
+ # itself the trigger, so a stale pointer on non-firing rows is
+ # harmless; reading the strategy's own detail_code/details AT
+ # that exact row (rather than forward-filling the text itself)
+ # means a detail-less transition correctly clears any earlier
+ # detail rather than letting it leak forward.
+ # - `position_origin_seed`: a REGIME-based (flat/long/short via
+ # sign) pointer for `position_strategy_origin` -- a continuous
+ # signal's own magnitude drift never creates a new origin, only
+ # a flat<->non-flat regime change does; a transition into flat
+ # clears it, a transition out of flat (fresh entry or reversal)
+ # replaces it. Independent of `trigger_detail_seed`: a
+ # downstream layer holding the executed weight flat does not,
+ # by itself, move this seed, since it only ever looks at
+ # `decision_proxy`.
+ decision_values = decision_proxy.to_numpy()
+ row_index_1based = np.arange(1, len(signals.index) + 1, dtype=float)
+ decision_prev_values = np.vstack(
+ [np.zeros((1, decision_values.shape[1])), decision_values[:-1]]
+ )
+ has_transition_magnitude = (
+ np.abs(decision_values - decision_prev_values) > EPSILON
+ )
+ trigger_detail_seed = (
+ pd.DataFrame(
+ np.where(has_transition_magnitude, row_index_1based[:, None], np.nan),
+ index=signals.index,
+ columns=signals.columns,
+ )
+ .ffill()
+ .fillna(0.0)
+ )
+
+ def _regime(values: np.ndarray) -> np.ndarray:
+ return np.where(values > EPSILON, 1, np.where(values < -EPSILON, -1, 0))
+
+ regime_now = _regime(decision_values)
+ regime_prev = np.vstack(
+ [np.zeros((1, regime_now.shape[1]), dtype=int), regime_now[:-1]]
+ )
+ has_regime_transition = regime_now != regime_prev
+ position_origin_candidate = np.where(
+ has_regime_transition,
+ np.where(regime_now == 0, 0.0, row_index_1based[:, None]),
+ np.nan,
+ )
+ position_origin_seed = (
+ pd.DataFrame(
+ position_origin_candidate, index=signals.index, columns=signals.columns
+ )
+ .ffill()
+ .fillna(0.0)
+ )
+
+ def _source_row(seed: pd.DataFrame) -> np.ndarray:
+ executed_positions = executed_weights(
+ _apply_extra_delay(_rebalance_diagnostic_frame(seed)),
+ tradable=tradable,
+ )
+ return executed_positions.round().to_numpy().astype(int) - 1
+
+ trigger_source_row = _source_row(trigger_detail_seed)
+ position_origin_source_row = _source_row(position_origin_seed)
+
+ def _gather(raw: pd.DataFrame, source_row: np.ndarray) -> pd.DataFrame:
+ raw_values = raw.to_numpy(dtype=object)
+ gathered = np.full(source_row.shape, None, dtype=object)
+ for column_index in range(source_row.shape[1]):
+ valid = source_row[:, column_index] >= 0
+ gathered[valid, column_index] = raw_values[
+ source_row[valid, column_index], column_index
+ ]
+ return pd.DataFrame(
+ gathered, index=raw.index, columns=raw.columns, dtype=object
+ )
+
+ executed_strategy_reason_code: pd.DataFrame | None = None
+ executed_strategy_reason_details: pd.DataFrame | None = None
+ if signal_reasons is not None:
+ executed_strategy_reason_code = _gather(
+ signal_reasons.detail_code, trigger_source_row
+ )
+ executed_strategy_reason_details = _gather(
+ signal_reasons.details, trigger_source_row
+ )
+ executed_position_strategy_origin_code = _gather(
+ signal_reasons.detail_code, position_origin_source_row
+ )
+ executed_position_strategy_origin_details = _gather(
+ signal_reasons.details, position_origin_source_row
+ )
+ else:
+ _empty_object = pd.DataFrame(
+ None, index=signals.index, columns=signals.columns, dtype=object
+ )
+ executed_position_strategy_origin_code = _empty_object
+ executed_position_strategy_origin_details = _empty_object.copy()
+
+ # Origin timestamp: independent of whether explain_signals() exists
+ # at all -- a strategy without strategy-specific detail codes still
+ # gets a temporally correct origin (point 2: the temporal tracking
+ # of position_strategy_origin is not gated on detail_code
+ # existing).
+ _dates = signals.index.to_numpy()
+ _origin_gathered = np.full(
+ position_origin_source_row.shape, pd.NaT, dtype=object
+ )
+ for _column_index in range(position_origin_source_row.shape[1]):
+ _valid = position_origin_source_row[:, _column_index] >= 0
+ _origin_gathered[_valid, _column_index] = _dates[
+ position_origin_source_row[_valid, _column_index]
+ ]
+ executed_position_strategy_origin_timestamp = pd.DataFrame(
+ _origin_gathered, index=signals.index, columns=signals.columns, dtype=object
+ )
+
+ # Accounting contains the one-period look-ahead barrier. A
+ # strategy's stop_loss_pct/take_profit_pct/position_groups() are
+ # read generically here (default None/None/None disables the
+ # check entirely) -- the mechanism itself lives in accounting.py,
+ # operating on the REAL executed position, never this strategy's
+ # raw `signals` above.
accounting = run_accounting(
held_weights,
asset_returns,
execution_model,
config.initial_capital,
tradable=tradable,
+ stop_loss_pct=strategy.stop_loss_pct,
+ take_profit_pct=strategy.take_profit_pct,
+ position_groups=strategy.position_groups(),
+ model_weight_drift=config.portfolio.model_weight_drift,
+ maximum_weight=config.portfolio.maximum_weight,
+ # Same combined-cap convention as rebalancing.py's own
+ # gross_cap = min(gross_caps): maximum_leverage always has a
+ # real (non-None) value, so it must be folded in here too, or
+ # the drift-compliance LP would silently miss it whenever
+ # maximum_gross_exposure itself is left unset.
+ maximum_gross_exposure=(
+ min(
+ config.portfolio.maximum_gross_exposure,
+ config.portfolio.maximum_leverage,
+ )
+ if config.portfolio.maximum_gross_exposure is not None
+ else config.portfolio.maximum_leverage
+ ),
+ maximum_net_exposure=config.portfolio.maximum_net_exposure,
+ long_only=config.portfolio.long_only,
+ rebalance_date=rebalance_date,
+ maximum_turnover=config.portfolio.maximum_turnover,
+ )
+
+ # `maximum_turnover` is enforced at the decision level above ONLY
+ # when weight drift is disabled -- when it's enabled,
+ # `apply_weight_drift` is the sole place it's actually applied (see
+ # `decision_portfolio_config` above), so the decision-level
+ # `turnover_provenance` computed from it is empty in that case.
+ # OR-merging in `accounting`'s own turnover provenance keeps the
+ # trade log's `turnover_cap` attribution accurate either way -- a
+ # no-op when drift is disabled (accounting's own frames are then
+ # all-``False`` placeholders), the real signal when it's enabled.
+ # Already at `accounting.executed_weights`' own index -- no further
+ # alignment needed.
+ executed_turnover_actively_limited = (
+ executed_turnover_actively_limited
+ | accounting.drift_turnover_actively_limited
+ )
+ executed_turnover_touched = (
+ executed_turnover_touched | accounting.drift_turnover_touched
)
+ # Real, row-broadcast provenance: forced liquidation affects every
+ # column simultaneously and unconditionally once the portfolio is
+ # ruined -- a legitimate broadcast, not an approximation (see
+ # AccountingResult.ruined's own docstring). Already at
+ # accounting.executed_weights' own final index -- no further
+ # alignment needed.
+ executed_forced_liquidation = pd.DataFrame(
+ dict.fromkeys(accounting.executed_weights.columns, accounting.ruined),
+ index=accounting.executed_weights.index,
+ )
+
+ # Align every reason-attribution frame to accounting.executed_weights'
+ # own index using the exact same shift function run_accounting uses
+ # internally, so they can never misalign with the trade log's own
+ # date index. executed_desired/executed_constrained are real
+ # pipeline frames (just re-aligned, not resampled); the *_diag ones
+ # are the rebalance-sampled diagnostic frames from above.
+ executed_desired = executed_weights(desired_target_aligned, tradable=tradable)
+ executed_constrained = executed_weights(constrained_aligned, tradable=tradable)
+ executed_signal_diag = executed_weights(rebalanced_signal, tradable=tradable)
+ executed_allocated_diag = executed_weights(
+ rebalanced_allocated, tradable=tradable
+ )
+ executed_desired_diag = executed_weights(rebalanced_desired, tradable=tradable)
+
# Align the benchmark to the simulated portfolio dates.
benchmark_data = (
data if config.benchmark_kind is BenchmarkKind.SYMBOL else tradable_data
@@ -664,6 +1165,35 @@ def run(
spread_bps=execution_model.spread.spread_bps,
slippage_model=execution_model.slippage,
slippage_equity=accounting.equity_for_costs,
+ executed_desired=executed_desired,
+ executed_constrained=executed_constrained,
+ executed_signal_diag=executed_signal_diag,
+ executed_allocated_diag=executed_allocated_diag,
+ executed_desired_diag=executed_desired_diag,
+ tradable=tradable,
+ executed_strategy_reason_code=executed_strategy_reason_code,
+ executed_strategy_reason_details=executed_strategy_reason_details,
+ constraint_provenance=executed_constraint_touches,
+ executed_turnover_actively_limited=executed_turnover_actively_limited,
+ executed_turnover_touched=executed_turnover_touched,
+ executed_tradability_touched=executed_tradability_touched,
+ executed_tradability_compliance_limited=(
+ executed_tradability_compliance_limited
+ ),
+ executed_forced_liquidation=executed_forced_liquidation,
+ executed_stop_loss_triggered=accounting.stop_loss_triggered,
+ executed_take_profit_triggered=accounting.take_profit_triggered,
+ executed_drift_compliance_forced=accounting.drift_compliance_forced,
+ executed_drift_compliance_pending=accounting.drift_compliance_pending,
+ executed_position_strategy_origin_timestamp=(
+ executed_position_strategy_origin_timestamp
+ ),
+ executed_position_strategy_origin_code=(
+ executed_position_strategy_origin_code
+ ),
+ executed_position_strategy_origin_details=(
+ executed_position_strategy_origin_details
+ ),
)
# Compute performance, risk and portfolio metrics.
diff --git a/src/quantlab/backtesting/result.py b/src/quantlab/backtesting/result.py
index 4652117..5d928ed 100644
--- a/src/quantlab/backtesting/result.py
+++ b/src/quantlab/backtesting/result.py
@@ -83,6 +83,58 @@
"sensitivity": "sensitivity_run_params",
}
+#: Maps each `_ROBUSTNESS_ARTIFACT_FILES` key to its own section name inside
+#: the consolidated `resolved_config` metadata (see `cli._update_resolved_
+#: config`) -- only "stress_tests" differs from its resolved_config section
+#: name ("stress_test", matching `robustness.stress_test` in YAML).
+_ROBUSTNESS_RESOLVED_CONFIG_KEYS: dict[str, str] = {
+ "stress_tests": "stress_test",
+ "bootstrap": "bootstrap",
+ "permutation_test": "permutation_test",
+ "sensitivity": "sensitivity",
+}
+
+
+def _restore_resolved_config_section(
+ result: BacktestResult, old_metadata: Mapping[str, Any], section: str
+) -> None:
+ """Recover one `resolved_config` section from a prior save's metadata.
+
+ Never overwrites a section this run already computed fresh -- mirrors
+ the same "recovered value only fills a gap" precedence
+ `load_previous_robustness_artifacts` already applies to run-params
+ keys, so a technique actually recomputed this run always keeps its own
+ freshly resolved values.
+ """
+ old_resolved_config = old_metadata.get("resolved_config")
+ if not isinstance(old_resolved_config, Mapping):
+ return
+ value = old_resolved_config.get(section)
+ if value is None:
+ return
+ resolved_config = result.metadata.setdefault("resolved_config", {})
+ resolved_config.setdefault(section, value)
+
+
+def resolve_experiment_directory(
+ config: ExperimentConfig, *, default_root: Path | None = None
+) -> Path:
+ """Return where a run's bundle belongs absent an explicit call-time override.
+
+ ``config.output.directory`` when set, else ``default_root`` (or the
+ documented default ``reports/generated/``) joined with
+ ``experiment_name``. Shared by :meth:`BacktestResult.save` and every
+ caller (CLI commands, the dashboard) that needs to know this same
+ location before a result even exists yet -- e.g. to place a checkpoint
+ file alongside where the eventual save will land. Callers that expose
+ their own ``GENERATED_REPORTS_DIR`` binding (so it can be redirected in
+ tests) should pass it as ``default_root``.
+ """
+ if config.output.directory is not None:
+ return Path(config.output.directory)
+ root = GENERATED_REPORTS_DIR if default_root is None else default_root
+ return root / config.experiment_name
+
def _bundle_lock_path(output_directory: Path) -> Path:
"""Return the persistent sibling lock used to serialize bundle saves."""
@@ -197,7 +249,7 @@ def summary(self) -> str:
f"Max drawdown : {m.get('max_drawdown', 0):>10.2%}",
f"Hit rate (non-zero periods): {m.get('hit_rate', 0):>5.2%}",
f"Total costs (currency units): {self.total_costs():>7.2f}",
- f"Number of trades : {self.number_of_trades():>10d}",
+ f"Number of fills : {self.number_of_trades():>10d}",
]
if self.benchmark_returns is not None and "beta" in m:
lines += [
@@ -351,11 +403,14 @@ def save(
"""Persist the result's managed outputs to a reproducible directory.
Writes ``config.yaml``, ``metadata.json``, ``metrics.json`` and CSVs for
- the equity curve, benchmark, trades, positions and costs, plus a
- ``figures/`` folder and an HTML report.
+ the equity curve, benchmark, trades, positions and costs
+ unconditionally. The ``figures/`` folder and the HTML report are
+ each written only when ``config.output.save_figures`` /
+ ``.save_html_report`` is true (both default true).
Args:
output_directory: Destination. Defaults to
+ ``config.output.directory`` when set, else
``reports/generated//``.
robustness: Extra sections (e.g. walk-forward / stress tables) to
fold into the saved report's Robustness section.
@@ -384,7 +439,7 @@ def save(
out = Path(
output_directory
if output_directory is not None
- else GENERATED_REPORTS_DIR / self.config.experiment_name
+ else resolve_experiment_directory(self.config)
)
artifacts = dict(validation_artifacts or {})
unsupported = set(artifacts) - _VALIDATION_ARTIFACTS
@@ -497,41 +552,51 @@ def _save_locked(
}
# Render once, then reuse the same images on disk and in the HTML.
+ # `save_html_report`/`save_figures` (config.output) skip only this
+ # presentation layer when disabled -- every artefact written above
+ # is unconditional, so `quantlab report` can always regenerate the
+ # HTML afterwards even from a run that skipped it here.
self.save_warnings = []
rendered_figures: dict[str, str] = {}
- try:
- from quantlab.reporting.charts import report_figures
-
- rendered_figures = report_figures(self, self.save_warnings)
- except Exception as exc: # pragma: no cover - rendering is optional
- msg = f"Could not render figures: {exc}"
- logger.warning(msg)
- self.save_warnings.append(msg)
- try:
- from quantlab.reporting.charts import save_figures
-
- save_figures(
- self,
- out / "figures",
- self.save_warnings,
- rendered=rendered_figures,
- )
- except Exception as exc: # pragma: no cover - rendering is optional
- msg = f"Could not save figures: {exc}"
- logger.warning(msg)
- self.save_warnings.append(msg)
- try:
- # Embedded chart failures append to the same warning collector.
- self.to_html(
- out / "report.html",
- robustness=robustness,
- warnings=self.save_warnings,
- figures=rendered_figures,
- )
- except Exception as exc: # pragma: no cover - rendering is optional
- msg = f"Could not render HTML report: {exc}"
- logger.warning(msg)
- self.save_warnings.append(msg)
+ render_figures = (
+ self.config.output.save_html_report or self.config.output.save_figures
+ )
+ if render_figures:
+ try:
+ from quantlab.reporting.charts import report_figures
+
+ rendered_figures = report_figures(self, self.save_warnings)
+ except Exception as exc: # pragma: no cover - rendering is optional
+ msg = f"Could not render figures: {exc}"
+ logger.warning(msg)
+ self.save_warnings.append(msg)
+ if self.config.output.save_figures:
+ try:
+ from quantlab.reporting.charts import save_figures
+
+ save_figures(
+ self,
+ out / "figures",
+ self.save_warnings,
+ rendered=rendered_figures,
+ )
+ except Exception as exc: # pragma: no cover - rendering is optional
+ msg = f"Could not save figures: {exc}"
+ logger.warning(msg)
+ self.save_warnings.append(msg)
+ if self.config.output.save_html_report:
+ try:
+ # Embedded chart failures append to the same warning collector.
+ self.to_html(
+ out / "report.html",
+ robustness=robustness,
+ warnings=self.save_warnings,
+ figures=rendered_figures,
+ )
+ except Exception as exc: # pragma: no cover - rendering is optional
+ msg = f"Could not render HTML report: {exc}"
+ logger.warning(msg)
+ self.save_warnings.append(msg)
# Explicit, persisted methodology marker: different CLI commands in
# walk-forward mode save fundamentally different `self` objects to
@@ -544,10 +609,18 @@ def _save_locked(
# last. Recording which one `self.metrics` actually is removes the
# ambiguity for anyone reading the bundle later, without needing to
# know the CLI's own save conventions.
+ from quantlab.backtesting.trade_log import TRADE_LOG_SCHEMA_VERSION
from quantlab.reporting.research_summary import out_of_sample_scope
self.metadata["result_scope"] = out_of_sample_scope(self) or "full_sample"
self.metadata["save_warnings"] = self.save_warnings
+ self.metadata["trade_log_schema_version"] = TRADE_LOG_SCHEMA_VERSION
+ # Always known (unlike the walk-forward/robustness fields other
+ # callers add to this same dict) -- a consolidated, always-current
+ # record of concrete values actually used, for full reproducibility.
+ self.metadata.setdefault("resolved_config", {})["signal_price_type"] = (
+ self.config.strategy.signal_price_type
+ )
_write_text_atomic(
out / "metrics.json",
json.dumps(
@@ -755,15 +828,22 @@ def load_previous_walk_forward_robustness(
if old_checksums:
result.metadata["walk_forward_csv_checksums"] = old_checksums
+ _restore_resolved_config_section(result, old_metadata, "walk_forward")
robustness: dict[str, Any] = {
"walk_forward": pd.read_csv(exp_dir / "walk_forward_results.csv")
}
if stress_path.is_file():
robustness["stress_tests"] = pd.read_csv(stress_path)
+ _restore_resolved_config_section(result, old_metadata, "stress_test")
return robustness
-def save_with_walk_forward_reuse(result: BacktestResult, exp_dir: str | Path) -> Path:
+def save_with_walk_forward_reuse(
+ result: BacktestResult,
+ exp_dir: str | Path,
+ *,
+ robustness_extra: Mapping[str, Any] | None = None,
+) -> Path:
"""Save a result while preserving compatible walk-forward artefacts.
Also preserves compatible bootstrap/permutation-test/sensitivity
@@ -772,6 +852,16 @@ def save_with_walk_forward_reuse(result: BacktestResult, exp_dir: str | Path) ->
not that) would delete still-valid evidence a `bootstrap`/
`permutation-test`/`sensitivity` run had just saved, since neither of
those techniques is itself part of "walk-forward" evidence.
+
+ Args:
+ result: The freshly run backtest to save.
+ exp_dir: Destination directory for the saved bundle.
+ robustness_extra: Freshly computed sections (e.g. a Strategy
+ Explorer results diagnostic) to fold in on top of any reused
+ walk-forward/stress-test/bootstrap/permutation-test/sensitivity
+ evidence -- always wins on key overlap, matching how a
+ recomputed technique already takes precedence over a merely
+ recovered one below.
"""
exp_dir = Path(exp_dir)
exp_dir.mkdir(parents=True, exist_ok=True)
@@ -800,7 +890,13 @@ def save_with_walk_forward_reuse(result: BacktestResult, exp_dir: str | Path) ->
# `robustness`'s own keys (from walk-forward reuse above) win on any
# overlap -- same precedence `save_with_robustness_reuse` gives a
# freshly-computed technique over a merely-recovered one.
- merged_robustness = {**previous_robustness_artifacts, **(robustness or {})}
+ # `robustness_extra` (e.g. a Strategy Explorer diagnostic) is
+ # freshest of all, so it wins over both.
+ merged_robustness = {
+ **previous_robustness_artifacts,
+ **(robustness or {}),
+ **(robustness_extra or {}),
+ }
validation_artifacts: dict[str, pd.Series | pd.DataFrame] = {}
for key, frame in previous_robustness_artifacts.items():
filename = _ROBUSTNESS_ARTIFACT_FILES[key]
@@ -960,6 +1056,14 @@ def load_previous_robustness_artifacts(
and run_params_key not in result.metadata
):
result.metadata[run_params_key] = old_metadata[run_params_key]
+ # Recover this technique's resolved_config section the same way --
+ # otherwise the surviving CSV's actual settings (stress-test
+ # magnitudes, sensitivity axes, ...) disappear from the
+ # consolidated view even though the raw CSV and its checksum are
+ # still right here.
+ _restore_resolved_config_section(
+ result, old_metadata, _ROBUSTNESS_RESOLVED_CONFIG_KEYS[key]
+ )
return recovered
diff --git a/src/quantlab/backtesting/runner.py b/src/quantlab/backtesting/runner.py
index aa9c8db..29fe301 100644
--- a/src/quantlab/backtesting/runner.py
+++ b/src/quantlab/backtesting/runner.py
@@ -23,6 +23,7 @@
from quantlab.data.calendar import is_247, uniform_calendar
from quantlab.data.validator import DataQualityReport
from quantlab.execution.execution_model import ExecutionModel
+from quantlab.features.native_calendar import compute_native_then_align
from quantlab.portfolio.allocator import PortfolioAllocator, build_allocator
from quantlab.strategies.base import (
BaseStrategy,
@@ -58,12 +59,16 @@ def build_strategy_from_config(config: ExperimentConfig) -> BaseStrategy:
"""Instantiate the configured strategy with its parameter dict.
Injects the experiment's annualisation factor when the strategy accepts
- ``periods_per_year`` and the YAML does not override it.
+ ``periods_per_year`` and the YAML does not override it, and likewise
+ injects ``strategy.signal_price_type`` under the ``price_type`` keyword
+ every built-in strategy accepts.
"""
parameters = dict(config.strategy_parameters)
accepted = strategy_parameter_names(config.strategy_name)
if "periods_per_year" in accepted and "periods_per_year" not in parameters:
parameters["periods_per_year"] = config.periods_per_year
+ if "price_type" in accepted and "price_type" not in parameters:
+ parameters["price_type"] = config.strategy.signal_price_type
return build_strategy(config.strategy_name, parameters)
@@ -77,8 +82,16 @@ def build_execution_from_config(
frequency and market calendar determine the bars per day; an explicit
metrics annualisation override does not alter this physical conversion.
- When instruments trade on different calendars, this window falls back to
- the equity (252-day, non-24/7) convention — a documented, accepted
+ The window's CONTENT is computed on each symbol's own native calendar
+ (see :func:`quantlab.features.native_calendar.compute_native_then_
+ align`): a closure-padded row (e.g. a session-bound equity's weekend
+ sharing the timeline with an always-open crypto instrument) never
+ dilutes that symbol's own trailing-volume estimate. The window's SIZE
+ in bars is a separate, smaller approximation that remains: when
+ instruments trade on different calendars, there is no single "bars per
+ day" that is simultaneously correct for every symbol sharing one
+ ``.rolling(window)`` call, so the bar count itself still falls back to
+ the equity (252-day, non-24/7) convention -- a documented, accepted
approximation, since it only sizes a nominal liquidity window for
volume-based slippage rather than multiplying directly into reported
metrics the way ``periods_per_year`` does.
@@ -102,8 +115,17 @@ def build_execution_from_config(
)
bars_per_day = frequency_table[str(config.frequency)] / days_per_year
window = max(1, round(21 * bars_per_day))
+ symbol_calendars = {
+ instrument.symbol: instrument.calendar
+ for instrument in config.data.instruments
+ }
adv = (
- bar_dollar_volume.rolling(window, min_periods=1).mean().shift(1)
+ compute_native_then_align(
+ lambda v: v.rolling(window, min_periods=1).mean(),
+ bar_dollar_volume,
+ symbol_calendars,
+ pd.DatetimeIndex(bar_dollar_volume.index),
+ ).shift(1)
* bars_per_day
)
return ExecutionModel.from_config(config.execution, average_daily_volume=adv)
diff --git a/src/quantlab/backtesting/trade_log.py b/src/quantlab/backtesting/trade_log.py
index 2834681..183a155 100644
--- a/src/quantlab/backtesting/trade_log.py
+++ b/src/quantlab/backtesting/trade_log.py
@@ -2,10 +2,39 @@
Each row records a non-zero change, its estimated notional and modelled costs.
It does not simulate share quantities or partial order execution.
+
+Reason attribution uses four independent concepts, which may legitimately
+differ on the same row without contradiction:
+
+- ``action`` -- what actually happened to the executed weight
+ (``_classify_action``).
+- ``trigger`` -- the single most-upstream event CURRENTLY
+ consumed that initiated the target change (``strategy_signal`` >
+ ``portfolio_rebalance`` > ``volatility_target_adjustment`` > none). Not
+ an exhaustive list of every transformed layer -- a downstream
+ recomputation triggered by the same upstream event is not a separate
+ cause.
+- ``adjustment(s)`` -- the downstream execution/portfolio layer(s) that
+ materially modified, delayed, redistributed, constrained or forced the
+ path from the upstream target to the currently executed position,
+ including a known execution debt whose catch-up this row represents
+ (turnover cap, a prior symbol closure) -- computed INDEPENDENTLY of
+ ``trigger`` and always from each layer's own real provenance, never
+ deduced from ``new != desired``. ``position_rescaling``/
+ ``deferred_catchup`` are a strict last-resort fallback, used only when
+ no real adjustment layer and no trigger explain the row.
+- ``position_strategy_origin`` -- the origin of the currently active
+ strategic regime/stance relevant to this row (this symbol/leg), driven
+ purely by the strategy's own decision state (see ``engine.py``'s
+ ``decision_proxy``) -- NOT necessarily the origin of an executed-weight
+ episode (a downstream layer can hold the executed weight flat while the
+ strategic stance stays active), and NOT an execution timestamp.
"""
from __future__ import annotations
+from collections.abc import Iterable, Mapping, Sequence
+from dataclasses import dataclass
from numbers import Real
import numpy as np
@@ -22,6 +51,14 @@
SlippageModel,
validate_slippage_cost_frame,
)
+from quantlab.portfolio.constraints import ConstraintTouch
+
+#: Schema version of :data:`TRADE_LOG_COLUMNS`. A bare 12-column layout with
+#: a single ``reason_code`` and none of the trigger/adjustment/position_
+#: strategy_origin columns is version 1. Recorded in report/export metadata
+#: (never as a column -- a bare CSV stays self-describing via its column
+#: names instead, see docs on ``render_trade_table``/report generation).
+TRADE_LOG_SCHEMA_VERSION = 2
#: Column order of the trade log.
TRADE_LOG_COLUMNS = [
@@ -31,6 +68,15 @@
"new_weight",
"weight_change",
"side",
+ "action",
+ "trigger_reason_code",
+ "trigger_reason_detail_code",
+ "trigger_reason_details",
+ "adjustment_reason_codes",
+ "adjustment_reason_details",
+ "position_strategy_origin_timestamp",
+ "position_strategy_origin_code",
+ "position_strategy_origin_details",
"reference_price",
"traded_notional",
"commission",
@@ -38,6 +84,413 @@
"slippage_cost",
"total_cost",
]
+assert len(TRADE_LOG_COLUMNS) == 21
+
+#: `reason_code`/`reason_detail_code` frames -- all-or-nothing kwargs of
+#: `build_trade_log` that enable reason attribution. Keyed by the kwarg
+#: name so validation and array-extraction can iterate them uniformly.
+_REASON_FRAME_KWARGS = (
+ "executed_desired",
+ "executed_constrained",
+ "executed_signal_diag",
+ "executed_allocated_diag",
+ "executed_desired_diag",
+)
+
+#: Canonical, exhaustive order of every value `adjustment_reason_codes`
+#: can ever contain -- constraint names (with their `_redistribution`
+#: variant right after the base name, for the 3 constraints that have a
+#: redistribution concept), the execution-layer causes, and the two
+#: last-resort fallbacks. The single source of truth for how multiple
+#: codes are combined into one `adjustment_reason_codes` string -- nothing
+#: else in the codebase (including the dashboard) should build or parse
+#: that convention on its own; use serialize_adjustment_codes/
+#: parse_adjustment_codes instead. Every code `_classify_reason` can ever
+#: emit MUST appear here, or `serialize_adjustment_codes` raises.
+ADJUSTMENT_ORDER = (
+ "long_only",
+ "maximum_positions",
+ "maximum_positions_redistribution",
+ "minimum_weight",
+ "minimum_weight_redistribution",
+ "maximum_weight",
+ "maximum_weight_redistribution",
+ "maximum_gross_exposure",
+ "maximum_leverage",
+ "maximum_net_exposure",
+ "tradability",
+ "turnover_cap",
+ "drift_compliance",
+ "drift_compliance_pending",
+ "stop_loss",
+ "take_profit",
+ "forced_liquidation",
+ "position_rescaling",
+ "deferred_catchup",
+)
+
+#: Redistribution-specific detail text, keyed by the constraint's BASE
+#: name (not the `_redistribution`-suffixed code) -- a single generic
+#: "another position was capped" sentence is wrong for minimum_weight
+#: (dust removal) and maximum_positions (cardinality drop), so each gets
+#: its own honest wording.
+_REDISTRIBUTION_DETAIL_TEXT = {
+ "maximum_weight": "redistribution after another position was capped",
+ "minimum_weight": "redistribution after dust/small positions were removed",
+ "maximum_positions": (
+ "redistribution after positions were dropped to satisfy maximum_positions"
+ ),
+}
+
+
+def serialize_adjustment_codes(codes: Iterable[str]) -> str:
+ """Join adjustment names into adjustment_reason_codes' stable format.
+
+ Names are deduplicated and ordered per ADJUSTMENT_ORDER (the real
+ pipeline's own order), not by input/call order, so the result is
+ deterministic and causally meaningful (e.g.
+ "maximum_weight+turnover_cap"). Raises ``BacktestError`` on a code
+ absent from ADJUSTMENT_ORDER -- a wiring bug between `_classify_
+ reason` and this canonical list, never dropped silently.
+ """
+ present = set(codes)
+ unknown = present - set(ADJUSTMENT_ORDER)
+ if unknown:
+ raise BacktestError(
+ f"Unknown adjustment code(s) {sorted(unknown)}; not present in "
+ "ADJUSTMENT_ORDER."
+ )
+ return "+".join(name for name in ADJUSTMENT_ORDER if name in present)
+
+
+def parse_adjustment_codes(value: str, *, strict: bool = True) -> list[str]:
+ """Inverse of serialize_adjustment_codes.
+
+ ``strict=True`` (the default, used everywhere internally and in
+ tests) raises ``BacktestError`` on a code absent from
+ ADJUSTMENT_ORDER. ``strict=False`` preserves an unrecognized code
+ as-is instead of raising -- reserved for displaying an artifact
+ potentially produced by a future schema version; a code is never
+ silently dropped in either mode.
+ """
+ codes = value.split("+")
+ if strict:
+ unknown = set(codes) - set(ADJUSTMENT_ORDER)
+ if unknown:
+ raise BacktestError(
+ f"Unknown adjustment code(s) {sorted(unknown)}; not present in "
+ "ADJUSTMENT_ORDER."
+ )
+ return codes
+
+
+def stop_loss_take_profit_trigger_counts(trade_log: pd.DataFrame) -> dict[str, int]:
+ """Count trade-log rows carrying the ``stop_loss``/``take_profit`` code.
+
+ Generic across every strategy: whenever ``stop_loss_pct``/
+ ``take_profit_pct`` are configured (any of the 5 directional
+ strategies), a triggered force-flatten shows up here via the SAME
+ ``adjustment_reason_codes`` column ``build_trade_log`` already
+ populates -- never a second, strategy-specific recomputation. Returns
+ ``{"stop_loss": 0, "take_profit": 0}`` on an empty log or one with no
+ ``adjustment_reason_codes`` column (e.g. neither threshold was
+ configured).
+ """
+ if trade_log.empty or "adjustment_reason_codes" not in trade_log.columns:
+ return {"stop_loss": 0, "take_profit": 0}
+ parsed = (
+ trade_log["adjustment_reason_codes"]
+ .dropna()
+ .apply(lambda value: parse_adjustment_codes(value, strict=False))
+ )
+ return {
+ "stop_loss": int(parsed.apply(lambda codes: "stop_loss" in codes).sum()),
+ "take_profit": int(parsed.apply(lambda codes: "take_profit" in codes).sum()),
+ }
+
+
+def _classify_action(previous: float, new: float) -> str:
+ """Classify what happened to a position from its weight before/after.
+
+ Long/flat/short thresholds reuse the project-wide EPSILON, matching
+ every other "is this effectively zero" check in the codebase. Total
+ over every reachable input -- including the sub-epsilon corner case
+ where ``previous`` and ``new`` individually read as flat yet differ by
+ more than EPSILON (e.g. +0.6e-12 / -0.6e-12): noise-level floating
+ point residue, not a real position, named explicitly as
+ ``"flat_to_flat"`` rather than folded into an entry/exit label that
+ would misrepresent a pair of weights indistinguishable from zero.
+ """
+ was_flat = abs(previous) <= EPSILON
+ was_long = previous > EPSILON
+ was_short = previous < -EPSILON
+ now_flat = abs(new) <= EPSILON
+ now_long = new > EPSILON
+ now_short = new < -EPSILON
+
+ if was_flat and now_long:
+ return "entry_long"
+ if was_flat and now_short:
+ return "entry_short"
+ if was_long and now_flat:
+ return "exit_long"
+ if was_short and now_flat:
+ return "exit_short"
+ if was_long and now_short:
+ return "reverse_long_to_short"
+ if was_short and now_long:
+ return "reverse_short_to_long"
+ if was_long and now_long:
+ return "increase_long" if new > previous else "reduce_long"
+ if was_short and now_short:
+ return "increase_short" if new < previous else "reduce_short"
+ return "flat_to_flat"
+
+
+def _compose_details(generic: str, specific: str | None) -> str:
+ """Enrich the generic pipeline-level explanation with a business-specific one.
+
+ The specific detail never REPLACES the generic text, it only adds to
+ it (explicit product rule: a more precise piece of information must
+ never remove a correct one already available).
+ """
+ if specific is None:
+ return generic
+ return f"{generic}; {specific}"
+
+
+@dataclass(frozen=True)
+class TradeReason:
+ """Trigger + adjustment attribution for one fill (see module docstring)."""
+
+ trigger_code: str | None
+ trigger_detail_code: str | None
+ trigger_details: str | None
+ adjustment_codes: str | None
+ adjustment_details: str | None
+
+
+def _classify_reason(
+ *,
+ new: float,
+ previous: float,
+ executed_desired: float,
+ executed_desired_prev: float,
+ executed_constrained: float,
+ signal_now: float,
+ signal_prev: float,
+ allocated_now: float,
+ allocated_prev: float,
+ desired_diag_now: float,
+ desired_diag_prev: float,
+ strategy_detail_code: str | None = None,
+ strategy_details: str | None = None,
+ contributing_constraints: Sequence[str] = (),
+ constraint_before: Mapping[str, float] | None = None,
+ constraint_after: Mapping[str, float] | None = None,
+ tradability_touched: bool = False,
+ tradability_compliance_limited: bool = False,
+ turnover_touched: bool = False,
+ turnover_actively_limited: bool = False,
+ stop_loss_triggered: bool = False,
+ take_profit_triggered: bool = False,
+ forced_liquidation: bool = False,
+ drift_compliance_forced: bool = False,
+ drift_compliance_pending: bool = False,
+) -> TradeReason:
+ """Return the trigger + adjustment attribution for one fill.
+
+ Both are assigned from real, per-layer provenance signals only, never
+ deduced from `new != desired`. Full trigger/adjustment priority order:
+ see docs/backtesting.md#trade-log-reason-attribution. ``signal_now``/
+ ``signal_prev`` must already come from the strategy's own diagnostic
+ decision proxy (``decision_signal()`` when provided, else the raw
+ signal) -- see ``engine.py`` -- never the raw signal directly for a
+ strategy whose raw signal mixes decision state with mechanical
+ rescaling.
+ """
+ # TRIGGER
+ if abs(signal_now - signal_prev) > EPSILON:
+ generic = f"signal {signal_prev:.4f} -> {signal_now:.4f} since last rebalance"
+ trigger_code: str | None = "strategy_signal"
+ trigger_detail_code = strategy_detail_code
+ trigger_details: str | None = _compose_details(generic, strategy_details)
+ elif abs(allocated_now - allocated_prev) > EPSILON:
+ trigger_code = "portfolio_rebalance"
+ trigger_detail_code = None
+ trigger_details = (
+ f"allocator output {allocated_prev:.4f} -> {allocated_now:.4f} "
+ "since last rebalance"
+ )
+ elif abs(desired_diag_now - desired_diag_prev) > EPSILON:
+ trigger_code = "volatility_target_adjustment"
+ trigger_detail_code = None
+ trigger_details = (
+ f"target {desired_diag_prev:.4f} -> {desired_diag_now:.4f} "
+ "since last rebalance"
+ )
+ else:
+ trigger_code, trigger_detail_code, trigger_details = None, None, None
+
+ # ADJUSTMENT(S)
+ adjustment_codes_list: list[str] = []
+ adjustment_clauses: list[str] = []
+ if forced_liquidation:
+ # Overrides every other adjustment: once ruined, no other layer's
+ # specific clip value still explains the executed weight.
+ adjustment_codes_list = ["forced_liquidation"]
+ adjustment_clauses = [
+ "portfolio equity reached zero -- position forcibly flattened, "
+ "no margin call modeled"
+ ]
+ elif stop_loss_triggered or take_profit_triggered:
+ # Overrides every ordinary constraint adjustment (a real
+ # stop-loss/take-profit force-flatten fully explains the executed
+ # weight regardless of what a constraint would otherwise have
+ # clipped it to), but is itself overridden above by
+ # forced_liquidation -- portfolio ruin is more severe than a
+ # single position's own risk control.
+ adjustment_codes_list = []
+ adjustment_clauses = []
+ if stop_loss_triggered:
+ adjustment_codes_list.append("stop_loss")
+ adjustment_clauses.append(
+ "cumulative return since entry breached the configured "
+ "stop_loss_pct -- position forcibly closed"
+ )
+ if take_profit_triggered:
+ adjustment_codes_list.append("take_profit")
+ adjustment_clauses.append(
+ "cumulative return since entry reached the configured "
+ "take_profit_pct -- position forcibly closed"
+ )
+ elif drift_compliance_forced or drift_compliance_pending:
+ # Overrides every ordinary constraint/tradability/turnover_cap
+ # adjustment: this row's magnitude comes from the drift-compliance
+ # LP restoring a hard risk limit organic price drift breached
+ # off-schedule, not from the ordinary decision pipeline at all --
+ # but is itself overridden above by forced_liquidation/stop_loss/
+ # take_profit, each a still more specific or more severe cause.
+ adjustment_codes_list = []
+ adjustment_clauses = []
+ if drift_compliance_forced:
+ adjustment_codes_list.append("drift_compliance")
+ adjustment_clauses.append(
+ "organic price drift breached a hard portfolio risk limit "
+ "between rebalances -- position corrected back toward "
+ "compliance by the drift-compliance linear program"
+ )
+ if drift_compliance_pending:
+ adjustment_codes_list.append("drift_compliance_pending")
+ adjustment_clauses.append(
+ "drift-caused breach not yet fully resolved -- the "
+ "responsible symbol/group is still untradable, best "
+ "achievable correction applied, retried each day it "
+ "remains blocked"
+ )
+ else:
+ for name in contributing_constraints:
+ adjustment_codes_list.append(name)
+ assert constraint_before is not None
+ assert constraint_after is not None
+ before_value = constraint_before[name]
+ after_value = constraint_after[name]
+ if name.endswith("_redistribution"):
+ base_name = name.removesuffix("_redistribution")
+ cause_text = _REDISTRIBUTION_DETAIL_TEXT.get(
+ base_name, "redistribution after another position was adjusted"
+ )
+ adjustment_clauses.append(
+ f"{name}: {before_value:.4f} -> {after_value:.4f} ({cause_text})"
+ )
+ else:
+ adjustment_clauses.append(
+ f"{name}: {before_value:.4f} -> {after_value:.4f}"
+ )
+
+ # tradability/turnover_cap may share the same executed magnitude
+ # (they can both apply to the same cell in the same pass, without
+ # a clean per-mechanism split) -- only the FIRST cause to fire
+ # (ADJUSTMENT_ORDER: tradability before turnover_cap) carries the
+ # magnitude, the other stays purely causal rather than repeating a
+ # possibly-misleading shared number.
+ magnitude_already_shown = False
+ if tradability_touched:
+ adjustment_codes_list.append("tradability")
+ cause_text = (
+ "rebalancing feasibility limit reached while another "
+ "symbol remained closed"
+ if tradability_compliance_limited
+ else "catching up a delta previously blocked while the "
+ "symbol was closed"
+ )
+ if not magnitude_already_shown:
+ adjustment_clauses.append(
+ f"tradability: desired {executed_constrained:.4f}, "
+ f"executed {new:.4f} ({cause_text})"
+ )
+ magnitude_already_shown = True
+ else:
+ adjustment_clauses.append(f"tradability: {cause_text}")
+ if turnover_touched:
+ adjustment_codes_list.append("turnover_cap")
+ cause_text = (
+ "turnover-capped this period"
+ if turnover_actively_limited
+ else "catching up a target previously deferred by turnover cap"
+ )
+ if not magnitude_already_shown:
+ adjustment_clauses.append(
+ f"turnover_cap: desired {executed_constrained:.4f}, "
+ f"executed {new:.4f} ({cause_text})"
+ )
+ magnitude_already_shown = True
+ else:
+ adjustment_clauses.append(
+ f"turnover_cap: {cause_text} "
+ "(see above for the shared executed magnitude)"
+ )
+
+ # Strict fallback: reached only when NOTHING real above explains
+ # this row's movement, and no trigger explains it either.
+ if (
+ not adjustment_codes_list
+ and trigger_code is None
+ and abs(new - previous) > EPSILON
+ ):
+ if abs(executed_desired - executed_desired_prev) > EPSILON:
+ adjustment_codes_list = ["position_rescaling"]
+ adjustment_clauses = [
+ f"target continued drifting {executed_desired_prev:.4f} -> "
+ f"{executed_desired:.4f} with no new upstream decision"
+ ]
+ else:
+ adjustment_codes_list = ["deferred_catchup"]
+ adjustment_clauses = [
+ f"{previous:.4f} -> {new:.4f} with no new upstream driver"
+ ]
+
+ if adjustment_codes_list:
+ adjustment_codes: str | None = serialize_adjustment_codes(adjustment_codes_list)
+ adjustment_details: str | None = "; ".join(adjustment_clauses)
+ else:
+ adjustment_codes, adjustment_details = None, None
+
+ if trigger_code is None and adjustment_codes is None:
+ # Safety net, should not normally happen.
+ trigger_code, trigger_detail_code, trigger_details = (
+ "unknown",
+ None,
+ "no upstream driver identified",
+ )
+
+ return TradeReason(
+ trigger_code=trigger_code,
+ trigger_detail_code=trigger_detail_code,
+ trigger_details=trigger_details,
+ adjustment_codes=adjustment_codes,
+ adjustment_details=adjustment_details,
+ )
def _non_negative_rate(value: object, name: str) -> float:
@@ -60,6 +513,54 @@ def _validate_unique_axes(frame: pd.DataFrame, name: str) -> None:
raise BacktestError(f"{name} columns must not contain duplicate labels.")
+def _validate_matching_frame(
+ frame: object, name: str, reference: pd.DataFrame
+) -> pd.DataFrame:
+ """Validate a frame is a DataFrame sharing ``reference``'s exact axes."""
+ if not isinstance(frame, pd.DataFrame):
+ raise BacktestError(f"{name} must be a pandas DataFrame.")
+ _validate_unique_axes(frame, name)
+ if not frame.index.equals(reference.index) or not frame.columns.equals(
+ reference.columns
+ ):
+ raise BacktestError(
+ f"{name} must have exactly the same index and columns as executed_weights."
+ )
+ return frame
+
+
+def _validate_optional_group(
+ names: tuple[str, ...],
+ frames: Mapping[str, pd.DataFrame | None],
+ *,
+ reference: pd.DataFrame,
+ requires: bool,
+ requires_label: str,
+) -> bool:
+ """Validate an all-or-nothing optional kwarg group; return whether supplied.
+
+ Every frame in ``names`` must be supplied together or not at all. When
+ supplied, ``requires`` (typically ``attribute_reasons``) must already
+ be true, or the group is rejected as meaningless on its own.
+ """
+ supplied = [name for name in names if frames[name] is not None]
+ if not supplied:
+ return False
+ if len(supplied) != len(names):
+ missing = sorted(set(names) - set(supplied))
+ raise BacktestError(
+ f"{'/'.join(names)} must be supplied all together or not at "
+ f"all; missing: {missing}."
+ )
+ if not requires:
+ raise BacktestError(f"{'/'.join(names)} requires {requires_label}.")
+ for name in names:
+ frame = frames[name]
+ assert frame is not None
+ _validate_matching_frame(frame, name, reference)
+ return True
+
+
def build_trade_log(
executed_weights: pd.DataFrame,
weight_changes: pd.DataFrame,
@@ -70,6 +571,27 @@ def build_trade_log(
spread_bps: float,
slippage_model: SlippageModel,
slippage_equity: pd.Series | None = None,
+ executed_desired: pd.DataFrame | None = None,
+ executed_constrained: pd.DataFrame | None = None,
+ executed_signal_diag: pd.DataFrame | None = None,
+ executed_allocated_diag: pd.DataFrame | None = None,
+ executed_desired_diag: pd.DataFrame | None = None,
+ tradable: pd.DataFrame | None = None,
+ executed_strategy_reason_code: pd.DataFrame | None = None,
+ executed_strategy_reason_details: pd.DataFrame | None = None,
+ constraint_provenance: dict[str, ConstraintTouch] | None = None,
+ executed_turnover_actively_limited: pd.DataFrame | None = None,
+ executed_turnover_touched: pd.DataFrame | None = None,
+ executed_tradability_touched: pd.DataFrame | None = None,
+ executed_tradability_compliance_limited: pd.DataFrame | None = None,
+ executed_forced_liquidation: pd.DataFrame | None = None,
+ executed_stop_loss_triggered: pd.DataFrame | None = None,
+ executed_take_profit_triggered: pd.DataFrame | None = None,
+ executed_drift_compliance_forced: pd.DataFrame | None = None,
+ executed_drift_compliance_pending: pd.DataFrame | None = None,
+ executed_position_strategy_origin_timestamp: pd.DataFrame | None = None,
+ executed_position_strategy_origin_code: pd.DataFrame | None = None,
+ executed_position_strategy_origin_details: pd.DataFrame | None = None,
) -> pd.DataFrame:
"""Build the trade log from executed weight changes.
@@ -84,6 +606,125 @@ def build_trade_log(
slippage_equity: Per-date equity passed to the slippage model's
``equity`` argument. Pass ``AccountingResult.equity_for_costs``
to reproduce volume-based accounting costs.
+ executed_desired: The fully-desired, pre-constraint target weights
+ (post-allocator, post-volatility-targeting), aligned to
+ ``executed_weights``' own index via the same
+ ``executed_weights()`` shift -- a *real* pipeline frame, not a
+ reconstruction. Required (with the 4 arguments below) to
+ populate the trigger/adjustment columns; when omitted, those
+ columns are ``None``/``NaT`` for every row (e.g. walk-forward's
+ own call site, which rebuilds trades from a stitched
+ out-of-sample weight series with no per-fold diagnostic frames
+ surviving the stitch -- attribution is genuinely unavailable
+ there, not merely unwired) while ``action`` is still always
+ computed.
+ executed_constrained: The post-``ConstraintSet``, pre-turnover-cap
+ target weights, aligned the same way as ``executed_desired``.
+ executed_signal_diag: The strategy's decision proxy (``decision_
+ signal()`` when the strategy provides one, else the raw
+ signal), resampled to rebalance dates and aligned to
+ ``executed_weights``' index -- a *diagnostic* frame (see
+ ``engine.py``'s ``_rebalance_diagnostic_frame``), used only to
+ detect "did the strategy's decision change since the last
+ rebalance", never to recompute an executed weight.
+ executed_allocated_diag: The allocator's raw output, diagnostic-
+ sampled the same way as ``executed_signal_diag``.
+ executed_desired_diag: ``executed_desired``, diagnostic-sampled the
+ same way, used to detect a volatility-targeting-driven change
+ since the last rebalance (as opposed to ``executed_desired``
+ itself, which is used unsampled here to detect a still-drifting
+ target for the position_rescaling/deferred_catchup fallback).
+ tradable: Per-symbol tradability mask, real and already computed
+ by the engine. ``None`` means every symbol was always tradable
+ (no per-symbol calendar closures modeled) -- distinct from a
+ per-cell ``False``.
+ executed_strategy_reason_code: The strategy's own per-cell
+ ``trigger_reason_detail_code`` (``str | None``), aligned to
+ ``executed_weights``' index the same way as the other reason
+ frames. Requires the 5 arguments above; both this and
+ ``executed_strategy_reason_details`` must be supplied
+ together or not at all. When present and a row resolves to
+ trigger ``"strategy_signal"``, overrides that branch's
+ generic text with the strategy's own attribution.
+ executed_strategy_reason_details: The strategy's own per-cell
+ free-text explanation, paired with
+ ``executed_strategy_reason_code``.
+ constraint_provenance: Per-constraint provenance from
+ :meth:`ConstraintSet.apply_with_provenance`, keyed by
+ constraint name (including a ``"*_redistribution"`` entry for
+ a constraint split into direct/redistribution -- both keys
+ carry the SAME ``before``/``after`` as the base constraint,
+ only ``touched`` differs), with each ``ConstraintTouch``'s
+ frames already aligned to ``executed_weights``' index the same
+ way as the other reason frames. Requires the 5 arguments
+ above. Every constraint whose ``touched`` mask is set for a
+ given cell contributes its own entry (with its own
+ before/after text) to ``adjustment_reason_codes`` -- never a
+ single winning constraint.
+ executed_turnover_actively_limited: Real, cell-level provenance
+ from ``rebalancing.py``'s turnover-cap tracking (see
+ ``cap_turnover``/``rebalance_and_cap_turnover``'s
+ ``return_provenance``): True where the turnover budget itself
+ bound this row's move for that cell. Requires the 5 arguments
+ above; must be supplied together with
+ ``executed_turnover_touched`` or not at all.
+ executed_turnover_touched: The broader, episode-scoped turnover
+ provenance from the same source -- also True on a later row
+ still catching up a debt from an earlier turnover-limited move
+ toward the SAME still-unresolved upstream decision.
+ executed_tradability_touched: Real, cell-level provenance from
+ ``rebalancing.py``'s closure-catchup tracking: True where this
+ row's move is (at least partly) catching up a delta previously
+ blocked by a closure, or a feasibility limit reached only
+ because another symbol stayed closed. Must be supplied
+ together with ``executed_tradability_compliance_limited`` or
+ not at all; requires the 5 arguments above.
+ executed_tradability_compliance_limited: Sub-case of the above,
+ distinguishing the feasibility-limit case for detail text.
+ executed_forced_liquidation: Real, row-broadcast provenance from
+ ``AccountingResult.ruined``: True on every date the portfolio
+ was ruined and positions were forcibly flattened. Requires the
+ 5 arguments above. Highest-priority adjustment -- see
+ docs/backtesting.md#trade-log-reason-attribution for the full
+ override order. Currently unreachable via the real pipeline
+ (``run_accounting`` zeroes ``weight_changes`` on every ruined
+ date), wired anyway so `_classify_reason` never falls back to
+ `unknown`/`deferred_catchup` should that ever change.
+ executed_stop_loss_triggered: Real, row-broadcast provenance from
+ ``AccountingResult.stop_loss_triggered``: True on every cell
+ whose position was force-flattened by a stop-loss breach on
+ the real executed position (see ``quantlab.backtesting.
+ accounting._detect_stop_loss_take_profit``). Unlike
+ ``executed_forced_liquidation``, this branch IS reachable in
+ practice -- a stop-loss-forced exit is a real, non-zero
+ weight change. Requires the 5 reason-attribution arguments
+ above.
+ executed_take_profit_triggered: Same as
+ ``executed_stop_loss_triggered``, for the favorable-side
+ threshold.
+ executed_drift_compliance_forced: Real, row-broadcast provenance
+ from ``AccountingResult.drift_compliance_forced``: True on
+ every cell whose executed weight was set by the drift-
+ compliance linear program landing a correction for a hard
+ risk limit organic price drift breached off-schedule (see
+ ``quantlab.backtesting.accounting.apply_weight_drift``).
+ Requires the 5 reason-attribution arguments above.
+ executed_drift_compliance_pending: Same as ``executed_drift_
+ compliance_forced``, for a still-unresolved breach (the
+ responsible symbol/group remains untradable) -- the best
+ achievable correction, retried every day it stays blocked.
+ executed_position_strategy_origin_timestamp: The timestamp of the
+ most recent strategic regime transition (per the strategy's
+ own decision proxy) still active for this cell -- ``NaT`` when
+ no strategic position is currently active (the decision proxy
+ is flat). Must be supplied together with the 2 arguments below
+ or not at all; requires the 5 arguments above.
+ executed_position_strategy_origin_code: The strategy's own
+ ``explain_signals()`` detail code at that origin transition,
+ or ``None`` when unavailable -- a temporally correct origin is
+ tracked independently of whether a specific code exists for it.
+ executed_position_strategy_origin_details: Free text paired with
+ the above.
Returns:
A DataFrame with :data:`TRADE_LOG_COLUMNS`, one row per non-zero fill.
@@ -101,6 +742,178 @@ def build_trade_log(
if not isinstance(slippage_model, SlippageModel):
raise BacktestError("slippage_model must implement SlippageModel.")
+ reason_frames = {
+ "executed_desired": executed_desired,
+ "executed_constrained": executed_constrained,
+ "executed_signal_diag": executed_signal_diag,
+ "executed_allocated_diag": executed_allocated_diag,
+ "executed_desired_diag": executed_desired_diag,
+ }
+ supplied_reason_frames = [
+ name for name in _REASON_FRAME_KWARGS if reason_frames[name] is not None
+ ]
+ if supplied_reason_frames and len(supplied_reason_frames) != len(
+ _REASON_FRAME_KWARGS
+ ):
+ missing = sorted(set(_REASON_FRAME_KWARGS) - set(supplied_reason_frames))
+ raise BacktestError(
+ "executed_desired/executed_constrained/executed_signal_diag/"
+ "executed_allocated_diag/executed_desired_diag must be supplied "
+ f"all together or not at all; missing: {missing}."
+ )
+ attribute_reasons = bool(supplied_reason_frames)
+ if attribute_reasons:
+ for name in _REASON_FRAME_KWARGS:
+ frame = reason_frames[name]
+ assert frame is not None # narrowed by attribute_reasons above
+ _validate_matching_frame(frame, name, executed_weights)
+ if tradable is not None:
+ _validate_matching_frame(tradable, "tradable", executed_weights)
+
+ attribute_strategy_reasons = _validate_optional_group(
+ ("executed_strategy_reason_code", "executed_strategy_reason_details"),
+ {
+ "executed_strategy_reason_code": executed_strategy_reason_code,
+ "executed_strategy_reason_details": executed_strategy_reason_details,
+ },
+ reference=executed_weights,
+ requires=attribute_reasons,
+ requires_label="the reason-attribution frames (executed_desired etc.)",
+ )
+
+ if constraint_provenance is not None:
+ if not attribute_reasons:
+ raise BacktestError(
+ "constraint_provenance requires the reason-attribution frames "
+ "(executed_desired etc.) to also be supplied."
+ )
+ if not isinstance(constraint_provenance, dict):
+ raise BacktestError("constraint_provenance must be a dict.")
+ for constraint_name, touch in constraint_provenance.items():
+ if not isinstance(touch, ConstraintTouch):
+ raise BacktestError(
+ f"constraint_provenance[{constraint_name!r}] must be a "
+ "ConstraintTouch."
+ )
+ for field_name, frame in (
+ ("touched", touch.touched),
+ ("before", touch.before),
+ ("after", touch.after),
+ ("direct", touch.direct),
+ ):
+ label = f"constraint_provenance[{constraint_name!r}].{field_name}"
+ _validate_matching_frame(frame, label, executed_weights)
+
+ attribute_turnover = _validate_optional_group(
+ ("executed_turnover_actively_limited", "executed_turnover_touched"),
+ {
+ "executed_turnover_actively_limited": executed_turnover_actively_limited,
+ "executed_turnover_touched": executed_turnover_touched,
+ },
+ reference=executed_weights,
+ requires=attribute_reasons,
+ requires_label="the reason-attribution frames (executed_desired etc.)",
+ )
+ attribute_tradability = _validate_optional_group(
+ (
+ "executed_tradability_touched",
+ "executed_tradability_compliance_limited",
+ ),
+ {
+ "executed_tradability_touched": executed_tradability_touched,
+ "executed_tradability_compliance_limited": (
+ executed_tradability_compliance_limited
+ ),
+ },
+ reference=executed_weights,
+ requires=attribute_reasons,
+ requires_label="the reason-attribution frames (executed_desired etc.)",
+ )
+ attribute_forced_liquidation = False
+ if executed_forced_liquidation is not None:
+ if not attribute_reasons:
+ raise BacktestError(
+ "executed_forced_liquidation requires the reason-attribution "
+ "frames (executed_desired etc.) to also be supplied."
+ )
+ _validate_matching_frame(
+ executed_forced_liquidation, "executed_forced_liquidation", executed_weights
+ )
+ attribute_forced_liquidation = True
+ attribute_stop_loss = False
+ if executed_stop_loss_triggered is not None:
+ if not attribute_reasons:
+ raise BacktestError(
+ "executed_stop_loss_triggered requires the reason-attribution "
+ "frames (executed_desired etc.) to also be supplied."
+ )
+ _validate_matching_frame(
+ executed_stop_loss_triggered,
+ "executed_stop_loss_triggered",
+ executed_weights,
+ )
+ attribute_stop_loss = True
+ attribute_take_profit = False
+ if executed_take_profit_triggered is not None:
+ if not attribute_reasons:
+ raise BacktestError(
+ "executed_take_profit_triggered requires the reason-attribution "
+ "frames (executed_desired etc.) to also be supplied."
+ )
+ _validate_matching_frame(
+ executed_take_profit_triggered,
+ "executed_take_profit_triggered",
+ executed_weights,
+ )
+ attribute_take_profit = True
+ attribute_drift_compliance_forced = False
+ if executed_drift_compliance_forced is not None:
+ if not attribute_reasons:
+ raise BacktestError(
+ "executed_drift_compliance_forced requires the reason-"
+ "attribution frames (executed_desired etc.) to also be supplied."
+ )
+ _validate_matching_frame(
+ executed_drift_compliance_forced,
+ "executed_drift_compliance_forced",
+ executed_weights,
+ )
+ attribute_drift_compliance_forced = True
+ attribute_drift_compliance_pending = False
+ if executed_drift_compliance_pending is not None:
+ if not attribute_reasons:
+ raise BacktestError(
+ "executed_drift_compliance_pending requires the reason-"
+ "attribution frames (executed_desired etc.) to also be supplied."
+ )
+ _validate_matching_frame(
+ executed_drift_compliance_pending,
+ "executed_drift_compliance_pending",
+ executed_weights,
+ )
+ attribute_drift_compliance_pending = True
+ attribute_position_origin = _validate_optional_group(
+ (
+ "executed_position_strategy_origin_timestamp",
+ "executed_position_strategy_origin_code",
+ "executed_position_strategy_origin_details",
+ ),
+ {
+ "executed_position_strategy_origin_timestamp": (
+ executed_position_strategy_origin_timestamp
+ ),
+ "executed_position_strategy_origin_code": (
+ executed_position_strategy_origin_code
+ ),
+ "executed_position_strategy_origin_details": (
+ executed_position_strategy_origin_details
+ ),
+ },
+ reference=executed_weights,
+ requires=attribute_reasons,
+ requires_label="the reason-attribution frames (executed_desired etc.)",
+ )
+
commission_rate = _non_negative_rate(commission_bps, "commission_bps")
spread_rate = _non_negative_rate(spread_bps, "spread_bps")
@@ -130,9 +943,164 @@ def build_trade_log(
changes = validate_execution_frame(weight_changes, name="weight_changes")
executed_values = executed.to_numpy()
change_values = changes.to_numpy()
- previous_values = np.vstack(
- [np.zeros((1, executed_values.shape[1])), executed_values[:-1]]
- )
+ # NOT the previous ROW's own executed value (`executed_values[:-1]`,
+ # shifted) -- that only coincides with the value actually held right
+ # before THIS row's trade when `weight_changes` is a plain row-to-row
+ # diff of `executed` (true when model_weight_drift=False). With drift
+ # active, `weight_changes` is `apply_weight_drift`'s own real per-row
+ # TRADE delta (zero on a pure-drift row, the landed trade's true size
+ # otherwise) -- the position can have drifted organically between the
+ # previous row's own reported value and this row's trade, so the two
+ # diverge. `executed - change` is correct in BOTH cases: it is, by
+ # construction, exactly the value this row's own trade delta was
+ # computed against (`new - previous == change` holds by definition,
+ # not just as a property to verify), and reduces to the identical
+ # previous-row-shifted value whenever `weight_changes` genuinely is a
+ # plain diff (the non-drift path, unchanged from before this fix).
+ previous_values = executed_values - change_values
+
+ # Placeholder defaults for every reason-attribution lookup array, always
+ # bound regardless of which `attribute_*` flags are set. Each one is
+ # only ever read far below, inside the per-row/per-column loop, under
+ # the exact same `attribute_*` flag that gates its real assignment here
+ # -- so a placeholder value is never actually consulted at runtime. This
+ # is for static analysis only (a linter cannot follow "guarded by the
+ # same unchanged boolean flag" across the loop boundary in between);
+ # it changes no behaviour.
+ empty_float = np.empty(executed_values.shape, dtype=float)
+ empty_object = np.empty(executed_values.shape, dtype=object)
+ empty_bool = np.empty(executed_values.shape, dtype=bool)
+ desired_values = empty_float
+ constrained_values = empty_float
+ signal_diag_values = empty_float
+ allocated_diag_values = empty_float
+ desired_diag_values = empty_float
+ desired_prev_values = empty_float
+ signal_diag_prev_values = empty_float
+ allocated_diag_prev_values = empty_float
+ desired_diag_prev_values = empty_float
+ strategy_detail_code_values = empty_object
+ strategy_details_values = empty_object
+ constraint_names: list[str] = []
+ constraint_touched_values: dict[str, np.ndarray] = {}
+ constraint_before_values: dict[str, np.ndarray] = {}
+ constraint_after_values: dict[str, np.ndarray] = {}
+ tradability_touched_values = empty_bool
+ tradability_compliance_limited_values = empty_bool
+ turnover_touched_values = empty_bool
+ turnover_actively_limited_values = empty_bool
+ forced_liquidation_values = empty_bool
+ stop_loss_triggered_values = empty_bool
+ take_profit_triggered_values = empty_bool
+ drift_compliance_forced_values = empty_bool
+ drift_compliance_pending_values = empty_bool
+ position_origin_timestamp_values = empty_object
+ position_origin_code_values = empty_object
+ position_origin_details_values = empty_object
+
+ if attribute_reasons:
+ assert executed_desired is not None
+ assert executed_constrained is not None
+ assert executed_signal_diag is not None
+ assert executed_allocated_diag is not None
+ assert executed_desired_diag is not None
+ # Axes were already validated exactly equal to executed_weights'
+ # (same order too) above -- validate_execution_frame doesn't reorder
+ # anything, so a plain .to_numpy() lines up with executed_values
+ # cell-for-cell, no reindex needed.
+ desired_values = executed_desired.to_numpy()
+ constrained_values = executed_constrained.to_numpy()
+ signal_diag_values = executed_signal_diag.to_numpy()
+ allocated_diag_values = executed_allocated_diag.to_numpy()
+ desired_diag_values = executed_desired_diag.to_numpy()
+ # Same "no prior rebalance -> flat" convention as previous_values:
+ # np.vstack, never .shift(1) (which would put NaN in row 0 and
+ # silently break the very first trade's comparison).
+ zeros_row = np.zeros((1, executed_values.shape[1]))
+ desired_prev_values = np.vstack([zeros_row, desired_values[:-1]])
+ signal_diag_prev_values = np.vstack([zeros_row, signal_diag_values[:-1]])
+ allocated_diag_prev_values = np.vstack([zeros_row, allocated_diag_values[:-1]])
+ desired_diag_prev_values = np.vstack([zeros_row, desired_diag_values[:-1]])
+
+ if attribute_strategy_reasons:
+ assert executed_strategy_reason_code is not None
+ assert executed_strategy_reason_details is not None
+ strategy_detail_code_values = executed_strategy_reason_code.to_numpy(
+ dtype=object
+ )
+ strategy_details_values = executed_strategy_reason_details.to_numpy(
+ dtype=object
+ )
+
+ if constraint_provenance is not None:
+ constraint_names = [
+ name for name in ADJUSTMENT_ORDER if name in constraint_provenance
+ ]
+ constraint_touched_values = {
+ name: constraint_provenance[name].touched.to_numpy(dtype=bool)
+ for name in constraint_names
+ }
+ constraint_before_values = {
+ name: constraint_provenance[name].before.to_numpy()
+ for name in constraint_names
+ }
+ constraint_after_values = {
+ name: constraint_provenance[name].after.to_numpy()
+ for name in constraint_names
+ }
+
+ if attribute_turnover:
+ assert executed_turnover_actively_limited is not None
+ assert executed_turnover_touched is not None
+ turnover_actively_limited_values = (
+ executed_turnover_actively_limited.to_numpy(dtype=bool)
+ )
+ turnover_touched_values = executed_turnover_touched.to_numpy(dtype=bool)
+ if attribute_tradability:
+ assert executed_tradability_touched is not None
+ assert executed_tradability_compliance_limited is not None
+ tradability_touched_values = executed_tradability_touched.to_numpy(
+ dtype=bool
+ )
+ tradability_compliance_limited_values = (
+ executed_tradability_compliance_limited.to_numpy(dtype=bool)
+ )
+ if attribute_forced_liquidation:
+ assert executed_forced_liquidation is not None
+ forced_liquidation_values = executed_forced_liquidation.to_numpy(dtype=bool)
+ if attribute_stop_loss:
+ assert executed_stop_loss_triggered is not None
+ stop_loss_triggered_values = executed_stop_loss_triggered.to_numpy(
+ dtype=bool
+ )
+ if attribute_take_profit:
+ assert executed_take_profit_triggered is not None
+ take_profit_triggered_values = executed_take_profit_triggered.to_numpy(
+ dtype=bool
+ )
+ if attribute_drift_compliance_forced:
+ assert executed_drift_compliance_forced is not None
+ drift_compliance_forced_values = executed_drift_compliance_forced.to_numpy(
+ dtype=bool
+ )
+ if attribute_drift_compliance_pending:
+ assert executed_drift_compliance_pending is not None
+ drift_compliance_pending_values = (
+ executed_drift_compliance_pending.to_numpy(dtype=bool)
+ )
+ if attribute_position_origin:
+ assert executed_position_strategy_origin_timestamp is not None
+ assert executed_position_strategy_origin_code is not None
+ assert executed_position_strategy_origin_details is not None
+ position_origin_timestamp_values = (
+ executed_position_strategy_origin_timestamp.to_numpy(dtype=object)
+ )
+ position_origin_code_values = (
+ executed_position_strategy_origin_code.to_numpy(dtype=object)
+ )
+ position_origin_details_values = (
+ executed_position_strategy_origin_details.to_numpy(dtype=object)
+ )
previous_equity_series = equity_before_period(equity, changes.index)
previous_equity = previous_equity_series.to_numpy()
@@ -185,14 +1153,161 @@ def build_trade_log(
"A positive finite prior-period reference price is required "
f"for {symbol!r} on {timestamp!r}."
)
+ previous = float(previous_values[row_number, column_index])
+ new = float(executed_values[row_number, column_index])
+ if attribute_reasons:
+ contributing_constraints = [
+ name
+ for name in constraint_names
+ if constraint_touched_values[name][row_number, column_index]
+ ]
+ constraint_before = {
+ name: float(
+ constraint_before_values[name][row_number, column_index]
+ )
+ for name in contributing_constraints
+ }
+ constraint_after = {
+ name: float(constraint_after_values[name][row_number, column_index])
+ for name in contributing_constraints
+ }
+ if attribute_strategy_reasons:
+ strategy_detail_code = strategy_detail_code_values[
+ row_number, column_index
+ ]
+ strategy_details = strategy_details_values[row_number, column_index]
+ else:
+ strategy_detail_code, strategy_details = None, None
+ reason = _classify_reason(
+ new=new,
+ previous=previous,
+ executed_desired=float(desired_values[row_number, column_index]),
+ executed_desired_prev=float(
+ desired_prev_values[row_number, column_index]
+ ),
+ executed_constrained=float(
+ constrained_values[row_number, column_index]
+ ),
+ signal_now=float(signal_diag_values[row_number, column_index]),
+ signal_prev=float(
+ signal_diag_prev_values[row_number, column_index]
+ ),
+ allocated_now=float(
+ allocated_diag_values[row_number, column_index]
+ ),
+ allocated_prev=float(
+ allocated_diag_prev_values[row_number, column_index]
+ ),
+ desired_diag_now=float(
+ desired_diag_values[row_number, column_index]
+ ),
+ desired_diag_prev=float(
+ desired_diag_prev_values[row_number, column_index]
+ ),
+ strategy_detail_code=strategy_detail_code,
+ strategy_details=strategy_details,
+ contributing_constraints=contributing_constraints,
+ constraint_before=constraint_before,
+ constraint_after=constraint_after,
+ tradability_touched=(
+ bool(tradability_touched_values[row_number, column_index])
+ if attribute_tradability
+ else False
+ ),
+ tradability_compliance_limited=(
+ bool(
+ tradability_compliance_limited_values[
+ row_number, column_index
+ ]
+ )
+ if attribute_tradability
+ else False
+ ),
+ turnover_touched=(
+ bool(turnover_touched_values[row_number, column_index])
+ if attribute_turnover
+ else False
+ ),
+ turnover_actively_limited=(
+ bool(turnover_actively_limited_values[row_number, column_index])
+ if attribute_turnover
+ else False
+ ),
+ stop_loss_triggered=(
+ bool(stop_loss_triggered_values[row_number, column_index])
+ if attribute_stop_loss
+ else False
+ ),
+ take_profit_triggered=(
+ bool(take_profit_triggered_values[row_number, column_index])
+ if attribute_take_profit
+ else False
+ ),
+ forced_liquidation=(
+ bool(forced_liquidation_values[row_number, column_index])
+ if attribute_forced_liquidation
+ else False
+ ),
+ drift_compliance_forced=(
+ bool(drift_compliance_forced_values[row_number, column_index])
+ if attribute_drift_compliance_forced
+ else False
+ ),
+ drift_compliance_pending=(
+ bool(drift_compliance_pending_values[row_number, column_index])
+ if attribute_drift_compliance_pending
+ else False
+ ),
+ )
+ trigger_reason_code = reason.trigger_code
+ trigger_reason_detail_code = reason.trigger_detail_code
+ trigger_reason_details = reason.trigger_details
+ adjustment_reason_codes = reason.adjustment_codes
+ adjustment_reason_details = reason.adjustment_details
+ if attribute_position_origin:
+ position_strategy_origin_timestamp = (
+ position_origin_timestamp_values[row_number, column_index]
+ )
+ position_strategy_origin_code = position_origin_code_values[
+ row_number, column_index
+ ]
+ position_strategy_origin_details = position_origin_details_values[
+ row_number, column_index
+ ]
+ else:
+ position_strategy_origin_timestamp = pd.NaT
+ position_strategy_origin_code = None
+ position_strategy_origin_details = None
+ else:
+ trigger_reason_code = None
+ trigger_reason_detail_code = None
+ trigger_reason_details = None
+ adjustment_reason_codes = None
+ adjustment_reason_details = None
+ position_strategy_origin_timestamp = pd.NaT
+ position_strategy_origin_code = None
+ position_strategy_origin_details = None
records.append(
{
"timestamp": timestamp,
"symbol": symbol,
- "previous_weight": float(previous_values[row_number, column_index]),
- "new_weight": float(executed_values[row_number, column_index]),
+ "previous_weight": previous,
+ "new_weight": new,
"weight_change": delta,
"side": "buy" if delta > 0 else "sell",
+ "action": _classify_action(previous, new),
+ "trigger_reason_code": trigger_reason_code,
+ "trigger_reason_detail_code": trigger_reason_detail_code,
+ "trigger_reason_details": trigger_reason_details,
+ "adjustment_reason_codes": adjustment_reason_codes,
+ "adjustment_reason_details": adjustment_reason_details,
+ "position_strategy_origin_timestamp": (
+ position_strategy_origin_timestamp
+ ),
+ "position_strategy_origin_code": position_strategy_origin_code,
+ "position_strategy_origin_details": (
+ position_strategy_origin_details
+ ),
"reference_price": price,
"traded_notional": notional,
"commission": commission,
@@ -203,4 +1318,21 @@ def build_trade_log(
)
if not records:
return pd.DataFrame(columns=TRADE_LOG_COLUMNS)
- return pd.DataFrame.from_records(records)[TRADE_LOG_COLUMNS]
+ result = pd.DataFrame.from_records(records)[TRADE_LOG_COLUMNS]
+ # Cast every str|None reason column to plain "object" dtype explicitly:
+ # pandas' default string-dtype inference can otherwise silently take
+ # over a column that mixes real strings with `None` across rows
+ # (missing values become NaN instead of None), which every `is None`
+ # check elsewhere in this pipeline relies on not happening.
+ for column in (
+ "trigger_reason_code",
+ "trigger_reason_detail_code",
+ "trigger_reason_details",
+ "adjustment_reason_codes",
+ "adjustment_reason_details",
+ "position_strategy_origin_code",
+ "position_strategy_origin_details",
+ ):
+ column_values = result[column].astype(object)
+ result[column] = column_values.where(column_values.notna(), None)
+ return result
diff --git a/src/quantlab/cli.py b/src/quantlab/cli.py
index b5c58f2..6da6799 100644
--- a/src/quantlab/cli.py
+++ b/src/quantlab/cli.py
@@ -176,6 +176,25 @@ def _load_config(config_path: Path) -> ExperimentConfig:
return ExperimentConfig.from_yaml(config_path)
+def _resolve_output_directory(
+ cfg: ExperimentConfig, cli_override: Path | None = None
+) -> Path:
+ """Resolve the bundle directory: CLI flag > ``output.directory`` > default.
+
+ Every command below except ``backtest`` has no CLI flag of its own
+ (``cli_override`` stays ``None``), so for them this is just
+ :func:`~quantlab.backtesting.result.resolve_experiment_directory` --
+ the same resolution :meth:`~quantlab.backtesting.result.BacktestResult.
+ save` itself falls back to for callers (like ``backtest``) that pass
+ ``output_directory`` straight through instead of pre-resolving it here.
+ """
+ if cli_override is not None:
+ return cli_override
+ from quantlab.backtesting.result import resolve_experiment_directory
+
+ return resolve_experiment_directory(cfg, default_root=GENERATED_REPORTS_DIR)
+
+
def _echo_data_warnings(report: DataQualityReport, *, limit: int = 10) -> None:
"""Print a limited number of data-quality warnings.
@@ -268,7 +287,9 @@ def _run_active_validation(
)
grid = _default_grid(cfg)
- train_window, validation_window, test_window = resolve_walk_forward_windows(cfg)
+ train_window, validation_window, test_window, step = (
+ resolve_walk_forward_windows(cfg)
+ )
_echo_step("Running walk-forward validation")
_echo_parameter_grid(cfg.strategy_name, grid)
wf = WalkForwardValidator(cfg).run(
@@ -278,6 +299,7 @@ def _run_active_validation(
validation_window=validation_window,
test_window=test_window,
expanding=cfg.validation.expanding,
+ step=step,
on_progress=_make_cli_progress_callback("Walk-forward"),
checkpoint_path=checkpoint_path,
)
@@ -313,6 +335,24 @@ def _walk_forward_validation_artifacts(
}
+def _update_resolved_config(result: BacktestResult, **fields: Any) -> None:
+ """Merge fields into ``result.metadata["resolved_config"]``.
+
+ A single consolidated, always-current record of the CONCRETE values a
+ run actually used after applying every default/fallback (e.g. a walk-
+ forward window left unset in YAML, or the default parameter grid) --
+ for full reproducibility even as this project's own defaults change
+ over time. Additive only: never replaces the whole dict, since a
+ single ``robustness`` command run can populate this from several
+ independent techniques (stress-test, bootstrap, permutation-test,
+ sensitivity) on the same result. Does not replace or rename any of the
+ existing, narrower keys (``bootstrap_run_params``, ``walk_forward_
+ parameter_grid``, etc.) that other code already reads by their exact
+ name (e.g. ``load_previous_walk_forward_robustness``).
+ """
+ result.metadata.setdefault("resolved_config", {}).update(fields)
+
+
def _attach_walk_forward_evidence(
result: BacktestResult,
wf: WalkForwardResult | None,
@@ -334,6 +374,41 @@ def _attach_walk_forward_evidence(
result.metadata["walk_forward_oos_metrics"] = dict(result.metrics)
+def _strategy_diagnostics_robustness(
+ data: pd.DataFrame, cfg: ExperimentConfig
+) -> dict[str, object]:
+ """Return the current strategy's report diagnostics, keyed by section.
+
+ Returns ``{}`` when its Strategy Explorer profile declares none.
+ Generic by profile lookup (``quantlab.dashboard.explorer.profile``) --
+ no strategy name appears here, mirroring the dashboard's own dispatch.
+ Cheap relative to bootstrap/stress-test techniques, so always
+ recomputed fresh rather than persisted/reused across runs. A failed
+ computation still contributes a status note (never silently omitted --
+ the report would otherwise look identical to a strategy with no
+ diagnostics declared at all).
+ """
+ import quantlab.dashboard.explorer.profiles # noqa: F401 (registration)
+ from quantlab.dashboard.explorer.profile import get_profile
+
+ profile = get_profile(cfg.strategy_name)
+ if profile is None or profile.results_diagnostics is None:
+ return {}
+ try:
+ diagnostics = profile.results_diagnostics.compute(data, cfg)
+ section = profile.results_diagnostics.report_section(diagnostics)
+ except Exception as exc:
+ logger.exception("Strategy Explorer results diagnostics failed")
+ import pandas as pd
+
+ from quantlab.reporting.sections import DiagnosticsSection
+
+ section = DiagnosticsSection(
+ table=pd.DataFrame({"Status": [f"Could not compute: {exc}"]}),
+ )
+ return {profile.results_diagnostics.key: section}
+
+
def _compute_stress_tests(
data: pd.DataFrame,
cfg: ExperimentConfig,
@@ -363,6 +438,18 @@ def _compute_stress_tests(
)
+def _stress_test_resolved_config(cfg: ExperimentConfig) -> dict[str, list[float | int]]:
+ """Return the stress-test magnitude lists this run actually used."""
+ settings = cfg.robustness.stress_test
+ return {
+ "commission_multipliers": list(settings.commission_multipliers),
+ "slippage_multipliers": list(settings.slippage_multipliers),
+ "execution_delays": list(settings.execution_delays),
+ "best_days_removed": list(settings.best_days_removed),
+ "reduce_universe_by": list(settings.reduce_universe_by),
+ }
+
+
def _compute_bootstrap(
cfg: ExperimentConfig,
result: BacktestResult,
@@ -403,7 +490,16 @@ def _compute_bootstrap(
"n_iterations": effective_n_iterations,
"block_size": effective_block_size,
}
- return boot.summary()
+ _update_resolved_config(
+ result,
+ bootstrap={
+ "n_iterations": effective_n_iterations,
+ "block_size": effective_block_size,
+ "confidence_level": cfg.robustness.bootstrap.confidence_level,
+ "seed": cfg.random_seed,
+ },
+ )
+ return boot.summary(confidence_level=cfg.robustness.bootstrap.confidence_level)
def _compute_permutation_test(
@@ -432,6 +528,13 @@ def _compute_permutation_test(
result.metadata["permutation_test_run_params"] = {
"n_iterations": effective_n_iterations,
}
+ _update_resolved_config(
+ result,
+ permutation_test={
+ "n_iterations": effective_n_iterations,
+ "seed": cfg.random_seed,
+ },
+ )
return outcome
@@ -625,7 +728,8 @@ def backtest(
result = run_backtest_from_config(data, cfg, data_quality_report=report)
_echo_step("Saving results")
- out_dir = result.save(output)
+ robustness_extra = _strategy_diagnostics_robustness(data, cfg)
+ out_dir = result.save(output, robustness=robustness_extra or None)
typer.echo("")
typer.echo(result.summary())
@@ -659,7 +763,7 @@ def walk_forward(
resolve_walk_forward_windows,
)
- out = GENERATED_REPORTS_DIR / cfg.experiment_name
+ out = _resolve_output_directory(cfg)
wf_checkpoint = out / ".checkpoint_walk_forward.pkl"
stress_checkpoint = out / ".checkpoint_stress_test.pkl"
if fresh:
@@ -675,18 +779,21 @@ def walk_forward(
grid = _default_grid(cfg)
_echo_parameter_grid(cfg.strategy_name, grid)
validator = WalkForwardValidator(cfg)
- train_window, validation_window, test_window = resolve_walk_forward_windows(cfg)
+ train_window, validation_window, test_window, step = (
+ resolve_walk_forward_windows(cfg)
+ )
if not (
cfg.validation.train_window
and cfg.validation.validation_window
and cfg.validation.test_window
+ and cfg.validation.step
):
# Apply the documented CLI defaults and make every fallback visible.
typer.secho(
" using default window(s) "
f"(train={train_window}, validation={validation_window}, "
- f"test={test_window}) — set validation.train_window / "
- "validation_window / test_window explicitly to override.",
+ f"test={test_window}, step={step}) — set validation.train_window / "
+ "validation_window / test_window / step explicitly to override.",
fg=typer.colors.YELLOW,
)
wf = validator.run(
@@ -696,6 +803,7 @@ def walk_forward(
validation_window=validation_window,
test_window=test_window,
expanding=cfg.validation.expanding,
+ step=step,
on_progress=_make_cli_progress_callback("Walk-forward"),
checkpoint_path=wf_checkpoint,
)
@@ -756,12 +864,20 @@ def walk_forward(
# Save the numerical bundle and its validation artefacts under one marker
# and cross-process lock. BacktestResult computes checksums only after each
# CSV has been atomically replaced.
+ # No strategy-diagnostics section here (unlike `backtest`/`report`):
+ # each walk-forward fold can select different parameters than
+ # `cfg`'s own, and covers only that fold's slice of history -- a
+ # diagnostic computed once, on the full history with the base
+ # config's parameters, would not actually describe what any
+ # individual fold traded (see the dashboard's equivalent choice in
+ # `_render_walk_forward_diagnostics_note`).
+ robustness_extra: dict[str, Any] = {
+ "walk_forward": wf.summary_table(),
+ "stress_tests": stress,
+ }
result.save(
out,
- robustness={
- "walk_forward": wf.summary_table(),
- "stress_tests": stress,
- },
+ robustness=robustness_extra,
validation_artifacts={
"walk_forward_results.csv": wf.summary_table(),
"walk_forward_oos_returns.csv": wf.oos_returns.rename("return"),
@@ -805,7 +921,7 @@ def stress_test(
from quantlab.validation.checkpoint import clear_checkpoint
from quantlab.validation.robustness import stress_test_checkpoint_paths
- out = GENERATED_REPORTS_DIR / cfg.experiment_name
+ out = _resolve_output_directory(cfg)
wf_checkpoint = out / ".checkpoint_walk_forward.pkl"
stress_checkpoint = out / ".checkpoint_stress_test.pkl"
if fresh:
@@ -834,6 +950,7 @@ def stress_test(
_attach_walk_forward_evidence(
result, wf, validation_artifacts, robustness_extra
)
+ _update_resolved_config(result, stress_test=_stress_test_resolved_config(cfg))
_echo_step("Saving results")
from quantlab.backtesting.result import save_with_robustness_reuse
@@ -882,9 +999,7 @@ def bootstrap(
from quantlab.data.loader import DataLoader
from quantlab.validation.checkpoint import clear_checkpoint
- wf_checkpoint = (
- GENERATED_REPORTS_DIR / cfg.experiment_name / ".checkpoint_walk_forward.pkl"
- )
+ wf_checkpoint = _resolve_output_directory(cfg) / ".checkpoint_walk_forward.pkl"
if fresh:
clear_checkpoint(wf_checkpoint)
@@ -911,12 +1026,14 @@ def bootstrap(
out_dir = save_with_robustness_reuse(
result,
- GENERATED_REPORTS_DIR / cfg.experiment_name,
+ _resolve_output_directory(cfg),
robustness=robustness_extra,
validation_artifacts=validation_artifacts,
)
+ from quantlab.reporting.tables import format_bootstrap_summary
+
typer.echo("")
- typer.echo(summary.to_string(index=False))
+ typer.echo(format_bootstrap_summary(summary).to_string(index=False))
typer.echo("")
_echo_save_outcome(result, f"Saved to {out_dir}")
except QuantLabError as exc:
@@ -947,9 +1064,7 @@ def permutation_test(
from quantlab.data.loader import DataLoader
from quantlab.validation.checkpoint import clear_checkpoint
- wf_checkpoint = (
- GENERATED_REPORTS_DIR / cfg.experiment_name / ".checkpoint_walk_forward.pkl"
- )
+ wf_checkpoint = _resolve_output_directory(cfg) / ".checkpoint_walk_forward.pkl"
if fresh:
clear_checkpoint(wf_checkpoint)
@@ -977,7 +1092,7 @@ def permutation_test(
out_dir = save_with_robustness_reuse(
result,
- GENERATED_REPORTS_DIR / cfg.experiment_name,
+ _resolve_output_directory(cfg),
robustness=robustness_extra,
validation_artifacts=validation_artifacts,
)
@@ -1029,7 +1144,7 @@ def sensitivity(
from quantlab.data.loader import DataLoader
from quantlab.validation.checkpoint import clear_checkpoint
- out = GENERATED_REPORTS_DIR / cfg.experiment_name
+ out = _resolve_output_directory(cfg)
wf_checkpoint = out / ".checkpoint_walk_forward.pkl"
sensitivity_checkpoint = out / ".checkpoint_sensitivity.pkl"
if fresh:
@@ -1068,6 +1183,12 @@ def sensitivity(
"parameter_y": y_name,
"values_y": y_values,
}
+ _update_resolved_config(
+ result,
+ sensitivity={
+ "parameters": {x_name: x_values, y_name: y_values},
+ },
+ )
validation_artifacts: dict[str, Any] = {"sensitivity.csv": sens}
robustness_extra: dict[str, Any] = {"sensitivity": sens}
@@ -1117,7 +1238,7 @@ def robustness(
from quantlab.validation.checkpoint import clear_checkpoint
from quantlab.validation.robustness import stress_test_checkpoint_paths
- out = GENERATED_REPORTS_DIR / cfg.experiment_name
+ out = _resolve_output_directory(cfg)
wf_checkpoint = out / ".checkpoint_walk_forward.pkl"
stress_checkpoint = out / ".checkpoint_stress_test.pkl"
sensitivity_checkpoint = out / ".checkpoint_sensitivity.pkl"
@@ -1139,6 +1260,13 @@ def robustness(
_attach_walk_forward_evidence(
result, wf, validation_artifacts, robustness_extra
)
+ if wf is None:
+ # Mirrors `backtest`/`report`'s own inclusion of this section --
+ # omitted only in walk-forward mode (each fold can select
+ # different parameters than this config's base values, so a
+ # single full-history diagnostic would misrepresent any
+ # individual fold; see `_render_walk_forward_diagnostics_note`).
+ robustness_extra.update(_strategy_diagnostics_robustness(data, cfg))
ran_any = False
if cfg.robustness.stress_test.enabled:
@@ -1153,6 +1281,9 @@ def robustness(
)
validation_artifacts["stress_tests.csv"] = stress
robustness_extra["stress_tests"] = stress
+ _update_resolved_config(
+ result, stress_test=_stress_test_resolved_config(cfg)
+ )
typer.echo("")
typer.echo(stress.to_string(index=False))
@@ -1162,8 +1293,10 @@ def robustness(
boot_summary = _compute_bootstrap(cfg, result)
validation_artifacts["bootstrap_summary.csv"] = boot_summary
robustness_extra["bootstrap"] = boot_summary
+ from quantlab.reporting.tables import format_bootstrap_summary
+
typer.echo("")
- typer.echo(boot_summary.to_string(index=False))
+ typer.echo(format_bootstrap_summary(boot_summary).to_string(index=False))
if cfg.robustness.permutation_test.enabled:
ran_any = True
@@ -1201,6 +1334,9 @@ def robustness(
)
validation_artifacts["sensitivity.csv"] = sens
robustness_extra["sensitivity"] = sens
+ _update_resolved_config(
+ result, sensitivity={"parameters": {x_name: x_values, y_name: y_values}}
+ )
typer.echo("")
typer.echo(sens.to_string(index=False))
@@ -1229,57 +1365,121 @@ def robustness(
@app.command()
def report(
- experiment: str = typer.Option(
- ...,
+ experiment: str | None = typer.Option(
+ None,
"--experiment",
"-e",
help=(
- "Regenerate a report from a previously saved experiment. If none exists, "
- "run a bundled QuantLab config with the same experiment name."
+ "Regenerate a report from an experiment saved under the default "
+ "reports/generated/ directory. If no matching saved config exists "
+ "there, falls back to a bundled QuantLab config of the same "
+ "experiment name. Mutually exclusive with --config/"
+ "--shipped-config, which instead point directly at a config file "
+ "-- use one of those when the experiment's own output.directory "
+ "is not reports/generated/, since this by-name lookup cannot find "
+ "it otherwise."
+ ),
+ ),
+ config: Path | None = typer.Option(
+ None,
+ "--config",
+ help=(
+ "Regenerate a report directly from this config file, bypassing "
+ "the --experiment by-name lookup under reports/generated/ -- the "
+ "way to target a config whose own output.directory points "
+ "elsewhere. Mutually exclusive with --experiment/--shipped-config."
+ ),
+ ),
+ shipped_config: str | None = typer.Option(
+ None,
+ "--shipped-config",
+ help=(
+ "Name of a config bundled with the installed package, as a "
+ "direct alternative to --experiment (see --config). Mutually "
+ "exclusive with --experiment/--config."
),
),
) -> None:
- """Regenerate a report from a saved experiment or matching bundled config."""
+ """Regenerate a report from a saved experiment, or directly from a config."""
configure_logging()
try:
- reports_root = GENERATED_REPORTS_DIR.resolve()
- exp_dir = (GENERATED_REPORTS_DIR / experiment).resolve()
- if not exp_dir.is_relative_to(reports_root):
+ direct_config_requested = config is not None or shipped_config is not None
+ if experiment is not None and direct_config_requested:
raise QuantLabError(
- f"Invalid --experiment {experiment!r}: must not escape the "
- f"generated-reports directory ({GENERATED_REPORTS_DIR})."
+ "--experiment is mutually exclusive with --config/--shipped-config."
)
- config_path = exp_dir / "config.yaml"
- if not config_path.is_file():
- # Fall back to a shipped config of the same name.
- from quantlab.constants import CONFIGS_DIR
-
- candidates = sorted(
- path
- for pattern in ("*.yaml", "*.yml")
- for path in CONFIGS_DIR.glob(pattern)
- )
- for candidate in candidates:
- from quantlab.config import ExperimentConfig
+ if direct_config_requested:
+ config_path = _resolve_config_path(config, shipped_config)
+ cfg = _load_config(config_path)
+ elif experiment is not None:
+ reports_root = GENERATED_REPORTS_DIR.resolve()
+ exp_dir = (GENERATED_REPORTS_DIR / experiment).resolve()
+ if not exp_dir.is_relative_to(reports_root):
+ raise QuantLabError(
+ f"Invalid --experiment {experiment!r}: must not escape the "
+ f"generated-reports directory ({GENERATED_REPORTS_DIR})."
+ )
+ config_path = exp_dir / "config.yaml"
+ if not config_path.is_file():
+ # Fall back to a shipped config of the same name.
+ from quantlab.constants import CONFIGS_DIR
- if ExperimentConfig.from_yaml(candidate).experiment_name == experiment:
- config_path = candidate
- break
- if not config_path.is_file():
- raise QuantLabError(
- f"No saved or bundled config found for experiment {experiment!r}. "
- "Run `quantlab backtest` first or check the experiment name."
- )
- cfg = _load_config(config_path)
- if cfg.experiment_name != experiment:
+ candidates = sorted(
+ path
+ for pattern in ("*.yaml", "*.yml")
+ for path in CONFIGS_DIR.glob(pattern)
+ )
+ for candidate in candidates:
+ from quantlab.config import ExperimentConfig
+
+ if (
+ ExperimentConfig.from_yaml(candidate).experiment_name
+ == experiment
+ ):
+ config_path = candidate
+ break
+ if not config_path.is_file():
+ raise QuantLabError(
+ f"No saved or bundled config found for experiment "
+ f"{experiment!r}. Run `quantlab backtest` first, check "
+ "the experiment name, or pass --config/--shipped-config "
+ "directly if it was saved outside reports/generated/."
+ )
+ cfg = _load_config(config_path)
+ if cfg.experiment_name != experiment:
+ raise QuantLabError(
+ f"Config {config_path} declares experiment_name="
+ f"{cfg.experiment_name!r}, but --experiment was "
+ f"{experiment!r}."
+ )
+ else:
raise QuantLabError(
- f"Config {config_path} declares experiment_name="
- f"{cfg.experiment_name!r}, but --experiment was {experiment!r}."
+ "One of --experiment, --config, or --shipped-config is required."
)
- from quantlab.backtesting.result import save_with_walk_forward_reuse
+ from quantlab.backtesting.result import (
+ resolve_experiment_directory,
+ save_with_walk_forward_reuse,
+ )
from quantlab.backtesting.runner import run_backtest_from_config
from quantlab.data.loader import DataLoader
+ # This command's entire purpose is producing an HTML report, so it
+ # always renders one -- regardless of output.save_html_report/
+ # save_figures, which only govern whether *other* commands' own
+ # runs render the presentation layer. The saved bundle's real
+ # location follows output.directory when the config sets one,
+ # which may differ from the by-name lookup above (that lookup only
+ # has to find *a* config describing this experiment, not the
+ # directory its own prior runs actually saved to).
+ cfg = cfg.revalidated_copy(
+ update={
+ "output": cfg.output.revalidated_copy(
+ update={"save_html_report": True, "save_figures": True}
+ )
+ }
+ )
+ exp_dir = resolve_experiment_directory(cfg, default_root=GENERATED_REPORTS_DIR)
+
_echo_step("Reloading data and re-running for the report")
data, report = DataLoader().load(cfg)
_echo_data_warnings(report)
@@ -1287,7 +1487,10 @@ def report(
# A report-only run does not recompute walk-forward validation.
# Reuse earlier OOS artefacts only when their provenance checks pass.
- out = save_with_walk_forward_reuse(result, exp_dir)
+ robustness_extra = _strategy_diagnostics_robustness(data, cfg)
+ out = save_with_walk_forward_reuse(
+ result, exp_dir, robustness_extra=robustness_extra
+ )
_echo_save_outcome(result, f"Report at {out / 'report.html'}")
except QuantLabError as exc:
typer.secho(f"[ERROR] {exc}", fg=typer.colors.RED, err=True)
diff --git a/src/quantlab/config.py b/src/quantlab/config.py
index d6d1294..99ea2c5 100644
--- a/src/quantlab/config.py
+++ b/src/quantlab/config.py
@@ -19,12 +19,20 @@
from datetime import date
from enum import StrEnum
from pathlib import Path
-from typing import Any, Self, Union, get_args, get_origin
+from typing import Any, Literal, Self, Union, get_args, get_origin
import numpy as np
import pandas as pd
import yaml
-from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ Field,
+ StrictFloat,
+ StrictInt,
+ field_validator,
+ model_validator,
+)
from yaml.constructor import ConstructorError
from yaml.nodes import MappingNode
@@ -535,35 +543,37 @@ def _check_intraday_frequency_requires_uniform_calendar(self) -> DataConfig:
return self
@model_validator(mode="after")
- def _warn_if_mixed_calendars_dilute_windowed_features(self) -> DataConfig:
- """Warn (never reject) that rolling-window features may be diluted.
-
- A verified closure's synthetic bar (:mod:`quantlab.data.closures`)
- is exactly flat -- zero return, zero volume -- so any trailing
- window counted in raw *periods* (a momentum lookback, a volatility
- window, an ADV window, a technical indicator's own window, ...)
- spans MORE real trading sessions than its configured length for a
- session-bound instrument sharing a combined timeline with an
- always-open one (e.g. equities alongside crypto): those flat bars
- silently dilute volatility/ADV estimates, and a "252-period"
- lookback stretches across more than 252 real equity sessions.
- Computing every instrument's own features on its native calendar
- before aligning signals would remove this entirely, but that is a
- substantially larger redesign than a single validator can express
- -- this only makes the existing, structural limitation visible at
- config load instead of a silent distortion (see
- docs/limitations.md).
+ def _note_mixed_calendars_use_native_calendar_features(self) -> DataConfig:
+ """Informational: a mixed-calendar universe uses native-calendar features.
+
+ Every built-in strategy's own rolling-window signal (momentum
+ lookback, technical indicator, mean-reversion indicator, a pairs
+ spread's own hedge fit) and the ADV window are computed on each
+ instrument's own native session dates before aligning back onto
+ the combined timeline (see :func:`quantlab.features.
+ native_calendar.compute_native_then_align`) -- a session-bound
+ instrument sharing a combined timeline with an always-open one
+ (e.g. equities alongside crypto) is not diluted by the always-open
+ instrument's own extra sessions in what actually gets traded. Not
+ yet covered: the inverse-volatility/volatility-targeting
+ allocators, and most Strategy Explorer diagnostics (see
+ docs/limitations.md for the precise, still-open list). Kept at
+ ``logger.warning`` (surfaced in the dashboard sidebar) purely so a
+ user configuring a mixed-calendar portfolio is still made aware of
+ both what is covered and what remains open.
"""
calendars = {instrument.calendar for instrument in self.instruments}
if len(calendars) > 1:
logger.warning(
- "Instruments span more than one calendar (%s): any "
- "rolling-window feature (momentum lookback, volatility "
- "window, ADV window, technical indicators, ...) counts raw "
- "periods, not real trading sessions per instrument -- a "
- "session-bound instrument's estimates are diluted by the "
- "flat, zero-return/zero-volume closure bars inserted to "
- "keep the combined timeline dense. See docs/limitations.md.",
+ "Instruments span more than one calendar (%s): every "
+ "built-in strategy's own rolling-window signal (momentum "
+ "lookback, technical indicator, mean-reversion indicator, "
+ "a pairs spread's own hedge fit) and the ADV window are "
+ "computed on each instrument's own native calendar before "
+ "aligning onto the combined timeline. Not yet covered: "
+ "the inverse-volatility/volatility-targeting allocators, "
+ "and most Strategy Explorer diagnostics. See "
+ "docs/limitations.md.",
sorted(calendars),
)
return self
@@ -599,6 +609,21 @@ class StrategyConfig(_StrictModel):
name: str
parameters: dict[str, Any] = Field(default_factory=dict)
+ #: Price series used to generate signals -- execution/costs always use
+ #: the raw close regardless of this setting (see docs/data_pipeline.md);
+ #: this only controls what a strategy's own generate_signals() sees.
+ signal_price_type: Literal["adjusted_close", "close"] = "adjusted_close"
+
+ @model_validator(mode="after")
+ def _reject_price_type_in_parameters(self) -> StrategyConfig:
+ if "price_type" in self.parameters:
+ raise ValueError(
+ "strategy.parameters must not set 'price_type' directly -- "
+ "use strategy.signal_price_type instead, so the value the "
+ "strategy actually uses always matches what is recorded in "
+ "resolved_config."
+ )
+ return self
class PortfolioConfig(_StrictModel):
@@ -618,8 +643,24 @@ class PortfolioConfig(_StrictModel):
volatility_window: int = Field(default=63, gt=1)
maximum_leverage: float = Field(default=1.0, gt=0.0)
rebalance_frequency: RebalanceFrequency = RebalanceFrequency.MONTHLY
- # Maximum L1 weight change allowed at each rebalance.
+ # Maximum L1 weight change allowed on any single row. A rebalance whose
+ # full target exceeds this in one step lands partially and keeps
+ # closing the remaining gap over subsequent rows, so a whole rebalance
+ # can take several rows to fully execute.
maximum_turnover: float | None = Field(default=None, gt=0.0)
+ # Evolve executed weights forward by organic price drift between real
+ # trades (see quantlab.backtesting.accounting.apply_weight_drift)
+ # instead of holding them constant until the next scheduled rebalance --
+ # a real portfolio's weights genuinely do drift with each asset's own
+ # price move between trades; holding them constant is only ever exactly
+ # correct when the schedule itself rebalances every single period. A
+ # genuinely scheduled rebalance still always trades toward its
+ # freshly-decided target regardless of drift -- landing there in one
+ # row unless maximum_turnover caps the move, in which case it lands
+ # partially and keeps closing the gap on subsequent rows. `False`
+ # remains available as an explicit legacy/reproducibility escape
+ # hatch, not the standard path.
+ model_weight_drift: bool = True
@model_validator(mode="after")
def _reject_unimplemented_custom_rebalancing(self) -> PortfolioConfig:
@@ -738,6 +779,11 @@ class ValidationConfig(_StrictModel):
train_window: int | None = Field(default=None, gt=0)
validation_window: int | None = Field(default=None, gt=0)
test_window: int | None = Field(default=None, gt=0)
+ # Advance between consecutive folds' train windows. None defaults to
+ # test_window (contiguous, non-overlapping test blocks). A smaller step
+ # overlaps test blocks for denser evaluation; step must not exceed
+ # test_window (see walk_forward_windows()'s docstring for why).
+ step: int | None = Field(default=None, gt=0)
expanding: bool = True
optimization_metric: OptimizationMetric = OptimizationMetric.SHARPE
# Optional strategy-parameter candidates for walk-forward selection. When
@@ -758,6 +804,18 @@ def _check_ratios(self) -> ValidationConfig:
"validation_ratio has no effect without a positive test_ratio "
"when validation.method is 'holdout'."
)
+ if self.method is ValidationMethod.WALK_FORWARD and self.step is not None:
+ # Mirrors resolve_walk_forward_windows()'s own default (126) --
+ # kept duplicated rather than imported since that function lives
+ # in quantlab.validation.walk_forward, which itself imports this
+ # config module.
+ effective_test_window = self.test_window or 126
+ if self.step > effective_test_window:
+ raise ValueError(
+ f"step ({self.step}) must not exceed test_window "
+ f"({effective_test_window}) -- a larger step would skip "
+ "dates between consecutive folds' test blocks entirely."
+ )
if self.method is ValidationMethod.WALK_FORWARD and (
self.validation_ratio is not None or self.test_ratio is not None
):
@@ -777,6 +835,30 @@ def _check_ratios(self) -> ValidationConfig:
raise ValueError(
f"parameter_grid.{name} must contain at least one candidate."
)
+ if name == "price_type":
+ raise ValueError(
+ "parameter_grid must not include 'price_type' -- it is a "
+ "structural choice, set strategy.signal_price_type instead."
+ )
+ # Caught here, at YAML load time, rather than deep inside
+ # walk_forward.py's own execution-time duplicate check --
+ # same "reject at the door" convention as StressTestSettings.
+ _no_duplicate_values(candidates, name=f"parameter_grid.{name}")
+ if self.method is not ValidationMethod.WALK_FORWARD:
+ window_fields = {
+ "train_window": self.train_window,
+ "validation_window": self.validation_window,
+ "test_window": self.test_window,
+ "step": self.step,
+ }
+ provided = sorted(k for k, v in window_fields.items() if v is not None)
+ if provided:
+ verb = "apply" if len(provided) > 1 else "applies"
+ pronoun = "them" if len(provided) > 1 else "it"
+ raise ValueError(
+ f"{', '.join(provided)} {verb} only to validation.method "
+ f"'walk_forward'; remove {pronoun} or set method: walk_forward."
+ )
return self
@@ -786,10 +868,56 @@ class ReproducibilityConfig(_StrictModel):
random_seed: int = Field(default=42, ge=0)
+def _no_duplicate_values(values: list[Any], *, name: str) -> list[Any]:
+ """Reject a candidate list containing the same value more than once."""
+ seen: list[Any] = []
+ for value in values:
+ if value in seen:
+ raise ValueError(f"{name} must not contain duplicate values.")
+ seen.append(value)
+ return values
+
+
class StressTestSettings(_StrictModel):
- """Toggle for the ``robustness stress-test`` / orchestrator run."""
+ """Scenario magnitudes for the ``robustness stress-test`` / orchestrator run.
+
+ Each list is a set of scenario magnitudes to evaluate independently
+ (one row per value) -- an empty list disables that scenario TYPE
+ entirely, the same "empty means nothing to run" convention as
+ ``validation.parameter_grid``. Defaults reproduce exactly the fixed
+ scenario set this project ran before these became configurable.
+ """
enabled: bool = False
+ commission_multipliers: list[StrictFloat] = Field(
+ default_factory=lambda: [2.0, 5.0]
+ )
+ slippage_multipliers: list[StrictFloat] = Field(default_factory=lambda: [2.0])
+ execution_delays: list[StrictInt] = Field(default_factory=lambda: [1])
+ best_days_removed: list[StrictInt] = Field(default_factory=lambda: [10])
+ #: Number of symbols dropped from the tail of the universe, per
+ #: scenario. A scenario whose universe is too small for that count is
+ #: recorded with status="failed", never silently omitted.
+ reduce_universe_by: list[StrictInt] = Field(default_factory=lambda: [1])
+
+ @model_validator(mode="after")
+ def _check_magnitudes(self) -> StressTestSettings:
+ # Multipliers below are genuine stress scenarios (elevated costs),
+ # not arbitrary perturbations -- 1.0 or below would silently test
+ # cheaper-than-baseline execution instead. Delays/day-counts/removed
+ # symbols are plain positive counts.
+ for name, values, gt in (
+ ("commission_multipliers", self.commission_multipliers, 1.0),
+ ("slippage_multipliers", self.slippage_multipliers, 1.0),
+ ("execution_delays", self.execution_delays, 0),
+ ("best_days_removed", self.best_days_removed, 0),
+ ("reduce_universe_by", self.reduce_universe_by, 0),
+ ):
+ for value in values:
+ if value <= gt:
+ raise ValueError(f"{name} entries must be greater than {gt}.")
+ _no_duplicate_values(values, name=name)
+ return self
class BootstrapSettings(_StrictModel):
@@ -798,6 +926,9 @@ class BootstrapSettings(_StrictModel):
enabled: bool = False
n_iterations: int = Field(default=1000, gt=0)
block_size: int = Field(default=1, gt=0)
+ #: Central percentile interval width reported by BootstrapResult.summary()
+ #: (0.90 -> the 5th/95th percentiles).
+ confidence_level: float = Field(default=0.90, gt=0.0, lt=1.0)
class PermutationTestSettings(_StrictModel):
@@ -845,6 +976,19 @@ def _check_parameters_shape(self) -> SensitivitySettings:
f"robustness.sensitivity.parameters.{name} must "
"contain at least one candidate value."
)
+ if name == "price_type":
+ raise ValueError(
+ "robustness.sensitivity.parameters must not include "
+ "'price_type' -- it is a structural choice, set "
+ "strategy.signal_price_type instead."
+ )
+ # Caught here, at YAML load time, rather than deep inside
+ # parameter_sensitivity.py's own execution-time duplicate
+ # check -- same "reject at the door" convention as
+ # StressTestSettings/ValidationConfig.parameter_grid.
+ _no_duplicate_values(
+ candidates, name=f"robustness.sensitivity.parameters.{name}"
+ )
return self
@@ -863,6 +1007,37 @@ class RobustnessConfig(_StrictModel):
sensitivity: SensitivitySettings = Field(default_factory=SensitivitySettings)
+class OutputConfig(_StrictModel):
+ """Where and what a run saves (``output:``).
+
+ ``directory`` overrides the default ``reports/generated/`` location used by every CLI command (``backtest``'s own
+ ``--output`` flag still takes priority when given). The two artefact
+ toggles skip only the presentation layer -- metrics.json/trades.csv/
+ equity_curve.csv and every other numeric artefact are always written
+ regardless. ``quantlab report`` does NOT depend on those saved
+ artefacts to regenerate the HTML report later: it reloads the data and
+ re-runs the backtest from this same config, and only reuses previously
+ saved walk-forward/stress/bootstrap/permutation-test/sensitivity
+ EVIDENCE when its own provenance check against the fresh run still
+ passes.
+ """
+
+ directory: str | None = None
+ save_html_report: bool = True
+ save_figures: bool = True
+
+ @field_validator("directory")
+ @classmethod
+ def _reject_blank_directory(cls, value: str | None) -> str | None:
+ if value is not None and not value.strip():
+ raise ValueError(
+ "output.directory must not be empty or whitespace-only -- omit "
+ "the field entirely to use the default location instead."
+ )
+ return value
+
+
class ExperimentConfig(_StrictModel):
"""Top-level, reproducible description of a single experiment.
@@ -881,6 +1056,25 @@ class ExperimentConfig(_StrictModel):
default_factory=ReproducibilityConfig
)
robustness: RobustnessConfig = Field(default_factory=RobustnessConfig)
+ output: OutputConfig = Field(default_factory=OutputConfig)
+ #: Optional overrides for the HTML report's auto-generated research
+ #: question/hypothesis text (quantlab.reporting.research_summary). When
+ #: unset, that text is synthesized from the strategy name/config as
+ #: before -- unchanged behaviour for every experiment that doesn't set
+ #: these.
+ research_question: str | None = None
+ hypothesis: str | None = None
+
+ @field_validator("research_question", "hypothesis")
+ @classmethod
+ def _reject_blank_research_text(cls, value: str | None) -> str | None:
+ if value is not None and not value.strip():
+ raise ValueError(
+ "research_question/hypothesis must not be empty or "
+ "whitespace-only -- omit the field entirely to use the "
+ "auto-generated text instead."
+ )
+ return value
@field_validator("experiment_name")
@classmethod
@@ -982,7 +1176,8 @@ def _check_component_names(self) -> ExperimentConfig:
if self.portfolio.allocator != "signal_proportional":
raise ValueError(
"pairs_trading requires portfolio.allocator "
- "'signal_proportional' so its dollar hedge ratio is preserved."
+ "'signal_proportional' so the relative dollar-notional "
+ "hedge ratio implied by beta and current prices is preserved."
)
if (
self.portfolio.maximum_weight is not None
@@ -1027,6 +1222,13 @@ def _check_component_names(self) -> ExperimentConfig:
f"its top/bottom selections ({top_count} + {bottom_count} "
f"requested, {available} configured)."
)
+ scaling = parameters.get("signal_scaling", "binary")
+ if scaling != "binary" and self.portfolio.allocator == "equal_weight":
+ raise ValueError(
+ "Non-binary cross_sectional_momentum signals require an "
+ "allocator that preserves signal magnitude; equal_weight "
+ "keeps only signs."
+ )
if self.strategy.name == "time_series_momentum":
scaling = self.strategy.parameters.get("signal_scaling", "binary")
if scaling != "binary" and self.portfolio.allocator == "equal_weight":
diff --git a/src/quantlab/dashboard/app.py b/src/quantlab/dashboard/app.py
index 0ebfe39..e67b8a2 100644
--- a/src/quantlab/dashboard/app.py
+++ b/src/quantlab/dashboard/app.py
@@ -20,23 +20,33 @@
import pandas as pd
import streamlit as st
+import quantlab.dashboard.explorer.profiles # noqa: F401 (registration side effect)
from quantlab.config import DataSourceName, compatible_frequencies_for_sources
from quantlab.dashboard.components import (
+ binance_universe_labels,
+ entry_threshold_bounds,
+ exit_threshold_bounds,
+ mean_reversion_slider_bounds,
+ parse_symbols,
render_charts,
render_exposure_and_cost_charts,
render_gross_net_comparison,
render_metric_cards,
render_sensitivity_heatmap,
render_trade_table,
+ symbols_picker,
+ yahoo_universe_labels,
)
+from quantlab.dashboard.explorer import detail as explorer_detail
+from quantlab.dashboard.explorer import gallery as explorer_gallery
+from quantlab.dashboard.explorer.profile import get_profile
from quantlab.dashboard.state import (
- binance_trading_symbols,
build_config_from_inputs,
default_end_date,
detect_calendar,
detect_source,
estimate_walk_forward_backtest_count,
- run_dashboard_backtest,
+ run_dashboard_backtest_with_data,
run_dashboard_bootstrap,
run_dashboard_permutation_test,
run_dashboard_sensitivity,
@@ -44,15 +54,19 @@
run_dashboard_walk_forward,
run_dashboard_walk_forward_sensitivity,
run_dashboard_walk_forward_stress_tests,
- yahoo_common_symbols,
)
from quantlab.logging_config import configure_logging, get_logger
from quantlab.progress import ProgressReporter
+from quantlab.reporting.sections import DiagnosticsSection
+from quantlab.reporting.tables import format_bootstrap_summary
from quantlab.strategies.base import (
available_strategies,
strategy_parameter_names,
strategy_sweepable_parameter_names,
)
+from quantlab.strategies.mean_reversion import (
+ UI_INDICATORS as MEAN_REVERSION_UI_INDICATORS,
+)
from quantlab.validation.parameter_grid import parse_parameter_grid_values
from quantlab.validation.parameter_sensitivity import (
infer_sensitivity_parameter_columns,
@@ -60,7 +74,7 @@
if TYPE_CHECKING:
from quantlab.backtesting.result import BacktestResult
- from quantlab.data.base import SymbolSuggestion
+ from quantlab.config import ExperimentConfig
from quantlab.validation.walk_forward import WalkForwardResult
# Streamlit runs this file in its own process (`quantlab dashboard` launches
@@ -75,118 +89,61 @@
st.set_page_config(page_title="QuantLab", page_icon="📈", layout="wide")
-def _parse_symbols(raw: str) -> list[str]:
- """Normalise symbols and remove duplicates while preserving their order."""
- return list(dict.fromkeys(s.strip().upper() for s in raw.split(",") if s.strip()))
-
-
-@st.cache_data(ttl=3600, show_spinner="Loading Binance's symbol list…")
-def _cached_binance_universe() -> list[SymbolSuggestion]:
- """Binance's full active spot-symbol universe, refreshed hourly.
-
- Fetched once per hour (per Streamlit cache entry, no arguments) rather
- than per keystroke: once loaded, picking symbols from it is instant,
- client-side dropdown filtering — no server round trip per character.
- """
- return binance_trading_symbols()
-
-
-def _label_for(suggestion: SymbolSuggestion) -> str:
- if suggestion.description:
- return f"{suggestion.symbol} — {suggestion.description}"
- return suggestion.symbol
-
-
-def _binance_universe_labels() -> dict[str, str]:
- """Binance's cached universe as ``{symbol: display label}``."""
- return {s.symbol: _label_for(s) for s in _cached_binance_universe()}
-
-
-def _yahoo_universe_labels() -> dict[str, str]:
- """The bundled S&P 500 + major-ETF reference list as ``{symbol: label}``.
-
- Yahoo has no downloadable "every symbol" endpoint the way Binance does,
- so this static, bundled list stands in as an instant, offline universe —
- covering what most dashboard users will look for, not every symbol Yahoo
- can actually serve.
- """
- return {s.symbol: _label_for(s) for s in yahoo_common_symbols()}
-
-
-#: Shown in the widget's help tooltip whenever a symbol picker's preloaded
-#: list isn't a complete universe — currently only Yahoo's, since Binance's
-#: list genuinely is complete.
-_INCOMPLETE_LIST_NOTE = (
- "Not every symbol is suggested — if yours is missing, type its exact "
- "ticker and it'll still be accepted."
+_PAIRS_TRADING_SYMBOLS_HELP = (
+ "pairs_trading needs at least two symbols; its two legs are then picked below."
)
-def _symbols_picker(
- label_by_symbol: dict[str, str],
- key: str,
- default_symbols: tuple[str, ...],
- *,
- accept_new_options: bool = False,
-) -> list[str]:
- """A single instant, client-side-filtered dropdown over a preloaded universe.
-
- Typing filters the already-loaded option list in the browser (like a
- search-engine dropdown) — no server round trip per character. When
- ``accept_new_options`` is set, a symbol absent from the preloaded list
- (Yahoo's bundled universe is large but not exhaustive — Yahoo has no
- downloadable "every symbol" list to preload the way Binance does) can
- still be typed and added directly.
- """
- help_text = "Tradable universe — start typing to filter"
- help_text += (
- ", or enter an exact symbol not in the list. " if accept_new_options else ". "
- )
- help_text += (
- "pairs_trading needs at least two symbols; its two legs are then picked below."
- )
- if accept_new_options:
- help_text += " " + _INCOMPLETE_LIST_NOTE
-
- if key not in st.session_state:
- st.session_state[key] = [
- label_by_symbol[s] for s in default_symbols if s in label_by_symbol
- ]
- # No `label_visibility="collapsed"` here: Streamlit hides the help
- # tooltip icon along with a collapsed label, and that icon is the only
- # place the market-calendar/incomplete-list notes above are surfaced.
- with st.container(border=True):
- picked_labels = st.multiselect(
- "Symbols",
- options=list(label_by_symbol.values()),
- key=key,
- placeholder=(
- "Type to find or add any symbol…"
- if accept_new_options
- else "Type to find a symbol…"
- ),
- accept_new_options=accept_new_options,
- help=help_text,
- )
- symbol_by_label = {label: symbol for symbol, label in label_by_symbol.items()}
- return [
- symbol_by_label.get(label, label.strip().upper()) for label in picked_labels
- ]
-
-
def _binance_symbols_picker() -> list[str]:
+ # A plain `st.expander` still runs its body every rerun even while
+ # collapsed, so this cannot simply live inside one -- that would
+ # silently call Binance's API on every dashboard load, regardless of
+ # whether the user ever opens this section (`cached_binance_universe`
+ # is itself cached, but the FIRST network round trip per hour still
+ # happens unconditionally). Gating the fetch behind an explicit button
+ # avoids that, and (unlike a stateful/lazy expander) needs no
+ # label<->symbol remapping to preserve an already-made selection across
+ # reruns -- the underlying multiselect's own widget state persists
+ # normally once it exists.
+ if not st.session_state.get("binance_universe_load_requested", False):
+ st.caption(
+ "Loading the tradable symbol list calls Binance's public API. "
+ "Click below to fetch it (cached for an hour after that)."
+ )
+ if st.button("Load Binance symbols", key="binance_universe_load_button"):
+ # No `st.rerun()` here: the click already triggers Streamlit's
+ # own full top-to-bottom rerun. An extra manual `st.rerun()`
+ # would abort THIS run right here, before the CSV picker (and
+ # any other widget further down the sidebar) ever executes --
+ # and a widget that doesn't render on a given run loses its
+ # keyed session-state value, silently resetting it back to
+ # its default (this is exactly how the CSV symbols field used
+ # to reset itself whenever this button was clicked). Setting
+ # the flag and falling through to the picker below instead
+ # renders it immediately, in this same run, with no abort.
+ st.session_state["binance_universe_load_requested"] = True
+ else:
+ return []
# Empty by default: all three pickers are visible simultaneously now, and
# a non-empty default here would immediately conflict with CSV's bundled
# demo default below (see `_combine_instrument_picks`).
- return _symbols_picker(_binance_universe_labels(), "binance_symbols", ())
+ return symbols_picker(
+ st,
+ binance_universe_labels(st),
+ "binance_symbols",
+ (),
+ help_suffix=_PAIRS_TRADING_SYMBOLS_HELP,
+ )
def _yahoo_symbols_picker() -> list[str]:
- return _symbols_picker(
- _yahoo_universe_labels(),
+ return symbols_picker(
+ st,
+ yahoo_universe_labels(),
"yahoo_symbols",
(),
accept_new_options=True,
+ help_suffix=_PAIRS_TRADING_SYMBOLS_HELP,
)
@@ -194,6 +151,7 @@ def _csv_symbols_picker() -> list[str]:
raw = st.text_input(
"CSV symbols (comma-separated)",
"SPY, QQQ, TLT, GLD",
+ key="csv_symbols_input",
help=(
"Local files under data/raw, one CSV per symbol. When 'Allow "
"bundled synthetic demo data' below is enabled, QuantLab falls "
@@ -201,7 +159,7 @@ def _csv_symbols_picker() -> list[str]:
"requested local file is absent."
),
)
- return _parse_symbols(raw)
+ return parse_symbols(raw)
def _combine_instrument_picks(
@@ -293,6 +251,75 @@ def _instrument_table(
return records
+def _risk_control_pct_inputs(key_prefix: str) -> tuple[float | None, float | None]:
+ """Render the shared stop_loss_pct/take_profit_pct widget pair.
+
+ Operates on the REAL executed position (post-allocator/constraints/
+ rebalancing/execution) -- see `quantlab.backtesting.accounting.
+ _detect_stop_loss_take_profit`. Shared across every strategy that
+ accepts these two parameters so the widget behavior/help text can
+ never drift between strategies.
+ """
+ col_stop, col_take = st.columns(2)
+ stop_loss_pct: float | None = None
+ take_profit_pct: float | None = None
+ with col_stop:
+ if st.checkbox(
+ "Enable stop-loss",
+ value=False,
+ key=f"{key_prefix}_enable_stop_loss_pct",
+ help=(
+ "Force-flatten the REAL executed position (or, for a "
+ "strategy that declares a multi-asset group such as "
+ "pairs_trading's two legs, the group's COMBINED position) "
+ "once its gross (pre-cost) return since entry drops below "
+ "-this value -- independent of any indicator-based stop "
+ "the strategy itself may already have."
+ ),
+ ):
+ stop_loss_pct = (
+ st.slider(
+ "Stop-loss (%)",
+ 1.0,
+ 50.0,
+ 10.0,
+ 1.0,
+ key=f"{key_prefix}_stop_loss_pct",
+ help="Gross-return loss, since entry, that force-flattens "
+ "the position.",
+ )
+ / 100.0
+ )
+ with col_take:
+ if st.checkbox(
+ "Enable take-profit",
+ value=False,
+ key=f"{key_prefix}_enable_take_profit_pct",
+ help=(
+ "Force-flatten the REAL executed position (or, for a "
+ "strategy that declares a multi-asset group such as "
+ "pairs_trading's two legs, the group's COMBINED position) "
+ "once its gross (pre-cost) return since entry rises above "
+ "this value -- locks in a gain instead of waiting for the "
+ "strategy's own ordinary exit condition."
+ ),
+ ):
+ take_profit_pct = (
+ st.slider(
+ "Take-profit (%)",
+ 1.0,
+ 50.0,
+ 10.0,
+ 1.0,
+ key=f"{key_prefix}_take_profit_pct",
+ help="Gross-return gain, since entry, that force-flattens "
+ "the position.",
+ )
+ / 100.0
+ )
+ return stop_loss_pct, take_profit_pct
+
+
def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict:
"""Render strategy-specific parameter widgets and return their values.
@@ -365,6 +392,23 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict:
"the top fraction above."
),
)
+ params["signal_scaling"] = st.selectbox(
+ "Signal scaling",
+ ["binary", "continuous"],
+ index=0,
+ help=(
+ "binary weights every selected asset identically. continuous "
+ "weights each selected asset by its RANK within its own "
+ "selected leg, divided by that leg's own selected count "
+ "(e.g. the weakest of 4 selected longs gets 0.25, the "
+ "strongest gets 1.0) — which assets are selected is "
+ "unchanged, only their relative size."
+ ),
+ )
+ with st.expander("Advanced parameters"):
+ params["stop_loss_pct"], params["take_profit_pct"] = (
+ _risk_control_pct_inputs("csm")
+ )
elif strategy_name == "time_series_momentum":
lookback_period = st.slider(
"Lookback (periods)",
@@ -425,6 +469,10 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict:
"section below."
),
)
+ with st.expander("Advanced parameters"):
+ params["stop_loss_pct"], params["take_profit_pct"] = (
+ _risk_control_pct_inputs("tsm")
+ )
elif strategy_name == "mean_reversion":
params["lookback_period"] = st.slider(
"Lookback (periods)",
@@ -433,67 +481,129 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict:
20,
1,
help=(
- "Trailing window used to compute the rolling mean and "
- "standard deviation that the price z-score is measured "
+ "Trailing window used to compute the rolling mean/std (or "
+ "RSI/percentile window) the chosen indicator is measured "
"against."
),
)
- entry_zscore = st.slider(
- "Entry z-score",
- 1.0,
- 3.0,
- 2.0,
- 0.1,
- help=(
- "Open a position once the price z-score moves beyond ± this "
- "threshold, betting on reversion back toward the trailing mean."
- ),
- )
- params["entry_zscore"] = entry_zscore
- params["exit_zscore"] = st.slider(
- "Exit z-score",
- 0.0,
- min(1.5, round(entry_zscore - 0.1, 1)),
- 0.5,
- 0.1,
+ indicator = st.selectbox(
+ "Indicator",
+ list(MEAN_REVERSION_UI_INDICATORS),
+ key="mr_indicator",
help=(
- "Close the position once the z-score reverts back inside "
- "± this threshold."
+ "Which zero-centered indicator drives entries/exits/stop: "
+ "a rolling z-score, RSI, or a trailing percentile rank. "
+ "Switching indicator resets the thresholds below to that "
+ "indicator's own defaults — a threshold tuned for one "
+ "indicator's scale is not meaningful on another's."
),
)
- params["long_only"] = st.checkbox(
- "Long only",
+ params["indicator"] = indicator
+ (
+ entry_min,
+ entry_max,
+ entry_default,
+ exit_default,
+ stop_max,
+ stop_default,
+ step,
+ ) = mean_reversion_slider_bounds(indicator)
+ # Asked BEFORE the entry slider (not after) so entry's own bounds
+ # can already know whether the stop slider will even be rendered
+ # -- see entry_threshold_bounds's own docstring for why this
+ # matters: entry must never lose reachable values (e.g. RSI's own
+ # 50, percentile's own 0.49) to guard a slider that doesn't exist.
+ enable_stop = st.checkbox(
+ "Enable stop threshold",
value=True,
+ key="mr_enable_stop",
help=(
- "Off: also open short positions when price rises above the "
- "entry z-score, not only long positions on a drop below it — "
- "separate from the portfolio-level 'Long only' below."
+ "Force the position flat when the indicator moves past this "
+ "threshold, e.g. because the trailing mean itself has shifted "
+ "and the entry threshold is no longer expected to revert."
),
)
- if st.checkbox(
- "Enable stop z-score",
- value=True,
- key="mr_enable_stop",
+ # Reserved right here (directly below the checkbox it belongs to)
+ # and filled in further down, once entry_threshold's own value is
+ # known -- the stop slider's lower bound is entry_threshold + step,
+ # so it cannot be COMPUTED before the entry slider renders, but a
+ # placeholder lets it still be DISPLAYED here.
+ stop_threshold_slot = st.empty()
+ entry_min, entry_max = entry_threshold_bounds(
+ entry_min, entry_max, stop_max, step, stop_enabled=enable_stop
+ )
+ entry_default = min(max(entry_default, entry_min), entry_max)
+ entry_threshold = st.slider(
+ "Entry threshold",
+ entry_min,
+ entry_max,
+ entry_default,
+ step,
+ key=f"mr_entry_threshold_{indicator}",
help=(
- "Force the position flat when the z-score moves past this "
- "threshold, e.g. because the trailing mean itself has shifted "
- "and the entry z-score is no longer expected to revert."
+ "Open a position once the indicator moves beyond "
+ "± this threshold, betting on reversion back toward normal. "
+ "On this indicator's own scale — see Indicator's help above."
),
- ):
- params["stop_zscore"] = st.slider(
- "Stop z-score",
- entry_zscore + 0.1,
- 6.0,
- max(4.0, entry_zscore + 0.5),
- 0.1,
+ )
+ params["entry_threshold"] = entry_threshold
+ exit_bounds = exit_threshold_bounds(entry_threshold, step)
+ if exit_bounds is None:
+ st.caption(
+ "Exit threshold: 0.0 (the only value possible this close to "
+ "zero — entry threshold minus one step is not positive)."
+ )
+ params["exit_threshold"] = 0.0
+ else:
+ exit_min, exit_max = exit_bounds
+ params["exit_threshold"] = st.slider(
+ "Exit threshold",
+ exit_min,
+ exit_max,
+ min(exit_default, exit_max),
+ step,
+ key=f"mr_exit_threshold_{indicator}",
help=(
- "|z-score| beyond which the position is forced flat "
- "instead of waiting for reversion — protects against a "
- "move that keeps extending instead of reverting."
+ "Close the position once the indicator reverts back "
+ "inside ± this threshold."
),
)
+ params["long_only"] = st.checkbox(
+ "Long only",
+ value=True,
+ help=(
+ "Off: also open short positions when the indicator rises "
+ "above the entry threshold, not only long positions on a "
+ "drop below it — separate from the portfolio-level 'Long "
+ "only' below."
+ ),
+ )
+ if enable_stop:
+ with stop_threshold_slot:
+ params["stop_threshold"] = st.slider(
+ "Stop threshold",
+ entry_threshold + step,
+ stop_max,
+ max(stop_default, entry_threshold + step),
+ step,
+ key=f"mr_stop_threshold_{indicator}",
+ help=(
+ "Indicator magnitude beyond which the position is "
+ "forced flat instead of waiting for reversion — "
+ "protects against a move that keeps extending instead "
+ "of reverting."
+ ),
+ )
else:
- params["stop_zscore"] = None
+ params["stop_threshold"] = None
+ with st.expander("Advanced parameters"):
+ st.caption(
+ "Independent of the indicator-based stop_threshold above — "
+ "either or both can be active."
+ )
+ params["stop_loss_pct"], params["take_profit_pct"] = (
+ _risk_control_pct_inputs("mr")
+ )
elif strategy_name == "trend_following":
fast_window = st.slider(
"Fast window",
@@ -528,8 +638,16 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict:
"separate from the portfolio-level 'Long only' below."
),
)
+ with st.expander("Advanced parameters"):
+ params["stop_loss_pct"], params["take_profit_pct"] = (
+ _risk_control_pct_inputs("tf")
+ )
elif strategy_name == "pairs_trading":
- st.caption("Pairs trading needs exactly two symbols (symbol_a, symbol_b).")
+ st.caption(
+ "Pairs trading needs at least two symbols loaded above; exactly "
+ "two of them (symbol_a, symbol_b) are then chosen below as the "
+ "pair's legs."
+ )
if len(symbols) < 2:
st.error("Enter at least two symbols above to configure a pairs trade.")
else:
@@ -559,66 +677,121 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict:
"to run the ADF cointegration test that gates entries."
),
)
- params["zscore_window"] = st.slider(
- "Z-score window (periods)",
+ pt_indicator = st.selectbox(
+ "Indicator",
+ list(MEAN_REVERSION_UI_INDICATORS),
+ key="pt_indicator",
+ help=(
+ "Which zero-centered indicator of the spread drives "
+ "entries/exits/stop -- same three choices as Mean "
+ "Reversion's own Indicator, applied to the spread "
+ "residual instead of a raw price."
+ ),
+ )
+ params["indicator"] = pt_indicator
+ (
+ pt_entry_min,
+ pt_entry_max,
+ pt_entry_default,
+ pt_exit_default,
+ pt_stop_max,
+ pt_stop_default,
+ pt_step,
+ ) = mean_reversion_slider_bounds(pt_indicator)
+ # Asked BEFORE the entry slider (not after, and moved out of
+ # the "Advanced pairs parameters" expander below to make that
+ # possible) so entry's own bounds can already know whether the
+ # stop slider will even be rendered -- see entry_threshold_
+ # bounds's own docstring and the mean_reversion block above.
+ pt_enable_stop = st.checkbox(
+ "Enable stop threshold",
+ value=True,
+ key="pt_enable_stop",
+ help=(
+ "Force the pair flat when the indicator moves past "
+ "this threshold, e.g. because the hedge relationship "
+ "itself has broken down."
+ ),
+ )
+ # Reserved right here (directly below the checkbox it belongs
+ # to) and filled in further down, once pt_entry's own value is
+ # known -- the stop slider's lower bound is pt_entry + pt_step,
+ # so it cannot be COMPUTED before the entry slider renders, but
+ # a placeholder lets it still be DISPLAYED here, outside the
+ # "Advanced pairs parameters" expander below.
+ pt_stop_threshold_slot = st.empty()
+ pt_entry_min, pt_entry_max = entry_threshold_bounds(
+ pt_entry_min,
+ pt_entry_max,
+ pt_stop_max,
+ pt_step,
+ stop_enabled=pt_enable_stop,
+ )
+ pt_entry_default = min(max(pt_entry_default, pt_entry_min), pt_entry_max)
+ params["indicator_window"] = st.slider(
+ "Indicator window (periods)",
10,
150,
63,
1,
help=(
- "Trailing window used to compute the spread's rolling "
- "mean and standard deviation that the entry/exit/stop "
- "z-scores are measured against."
+ "Trailing window used to compute the spread's centered "
+ "indicator, and the cadence of the ADF gate re-check."
),
)
- entry_zscore = st.slider(
- "Entry z-score",
- 1.0,
- 4.0,
- 2.0,
- 0.1,
- help=(
- "Open the pair once the spread z-score moves beyond ± "
- "this threshold, betting the spread reverts toward its "
- "trailing mean."
- ),
- )
- params["entry_zscore"] = entry_zscore
- params["exit_zscore"] = st.slider(
- "Exit z-score",
- 0.0,
- round(entry_zscore - 0.1, 1),
- 0.5,
- 0.1,
+ pt_entry = st.slider(
+ "Entry threshold",
+ pt_entry_min,
+ pt_entry_max,
+ pt_entry_default,
+ pt_step,
+ key=f"pt_entry_threshold_{pt_indicator}",
help=(
- "Close the pair once the spread z-score reverts back "
- "inside ± this threshold."
+ "Open the pair once the spread's indicator moves beyond "
+ "± this threshold, betting the spread reverts toward "
+ "normal."
),
)
- with st.expander("Advanced pairs parameters"):
- if st.checkbox(
- "Enable stop z-score",
- value=True,
- key="pt_enable_stop",
- help=(
- "Force the pair flat when the spread z-score moves "
- "past this threshold, e.g. because the hedge "
- "relationship itself has broken down."
- ),
- ):
- params["stop_zscore"] = st.slider(
- "Stop z-score",
- entry_zscore + 0.1,
- 6.0,
- max(4.0, entry_zscore + 0.5),
- 0.1,
+ params["entry_threshold"] = pt_entry
+ if pt_enable_stop:
+ with pt_stop_threshold_slot:
+ params["stop_threshold"] = st.slider(
+ "Stop threshold",
+ pt_entry + pt_step,
+ pt_stop_max,
+ max(pt_stop_default, pt_entry + pt_step),
+ pt_step,
+ key=f"pt_stop_threshold_{pt_indicator}",
help=(
- "|z-score| beyond which the pair is forced flat "
- "instead of waiting for reversion."
+ "Indicator magnitude beyond which the pair is "
+ "forced flat instead of waiting for reversion."
),
)
- else:
- params["stop_zscore"] = None
+ else:
+ params["stop_threshold"] = None
+ pt_exit_bounds = exit_threshold_bounds(pt_entry, pt_step)
+ if pt_exit_bounds is None:
+ st.caption(
+ "Exit threshold: 0.0 (the only value possible this close "
+ "to zero — entry threshold minus one step is not "
+ "positive)."
+ )
+ params["exit_threshold"] = 0.0
+ else:
+ pt_exit_min, pt_exit_max = pt_exit_bounds
+ params["exit_threshold"] = st.slider(
+ "Exit threshold",
+ pt_exit_min,
+ pt_exit_max,
+ min(pt_exit_default, pt_exit_max),
+ pt_step,
+ key=f"pt_exit_threshold_{pt_indicator}",
+ help=(
+ "Close the pair once the indicator reverts back "
+ "inside ± this threshold."
+ ),
+ )
+ with st.expander("Advanced pairs parameters"):
params["dynamic_hedge_ratio"] = st.checkbox(
"Dynamic hedge ratio",
value=True,
@@ -627,19 +800,30 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict:
"instead of fixing it once at the formation window."
),
)
- params["adf_pvalue_threshold"] = st.slider(
- "ADF p-value threshold (entry gate)",
- 0.01,
- 0.50,
- 0.10,
- 0.01,
+ if st.checkbox(
+ "Enable ADF stationarity gate",
+ value=True,
+ key="pt_enable_adf_gate",
help=(
"New entries require an Augmented Dickey-Fuller test "
- "on the trailing spread to reach this p-value or "
- "below — lower is a stricter mean-reversion filter "
- "and rejects more entries. Open positions are exempt: "
- "they still exit only on the z-score exit/stop rules."
+ "on the trailing spread to produce a p-value at or "
+ "below the selected threshold -- lower is a "
+ "stricter mean-reversion filter and rejects more "
+ "entries. Open positions are exempt: they still "
+ "exit only on the entry/exit/stop threshold rules."
),
+ ):
+ params["adf_pvalue_threshold"] = st.slider(
+ "ADF p-value threshold (entry gate)",
+ 0.01,
+ 0.50,
+ 0.10,
+ 0.01,
+ )
+ else:
+ params["adf_pvalue_threshold"] = None
+ params["stop_loss_pct"], params["take_profit_pct"] = (
+ _risk_control_pct_inputs("pt")
)
return params
@@ -672,7 +856,7 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict:
mode = st.segmented_control(
"Mode",
- ["Backtest", "Walk-forward"],
+ ["Backtest", "Walk-forward", "Strategies"],
default="Backtest",
key="dashboard_mode",
help=(
@@ -680,7 +864,9 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict:
"chronological holdout. Walk-forward: repeatedly select parameters "
"on a validation block and evaluate them out-of-sample on the "
"following test block, stitched across the whole history — this is "
- "QuantLab's grid-search mechanism."
+ "QuantLab's grid-search mechanism. Strategies: research each "
+ "strategy's own economics, mathematics and parameters, with an "
+ "interactive lab -- no experiment configuration needed."
),
)
if mode is None:
@@ -689,758 +875,793 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict:
# --------------------------------------------------------------------------- #
# Sidebar configuration
# --------------------------------------------------------------------------- #
-with st.sidebar:
- st.header("Experiment configuration")
- st.subheader("Instruments")
- st.caption(
- "Pick symbols from any of the three sources below — they all "
- "combine into one multi-market universe."
- )
- with st.expander("Yahoo Finance", expanded=False):
- yahoo_symbols = _yahoo_symbols_picker()
- with st.expander("Binance", expanded=False):
- binance_symbols = _binance_symbols_picker()
- with st.expander("CSV (local files)", expanded=True):
- csv_symbols = _csv_symbols_picker()
-
- symbols, provenance, conflicts = _combine_instrument_picks(
- yahoo_symbols, binance_symbols, csv_symbols
- )
- if conflicts:
- st.error(
- "Picked from more than one source, so source/calendar would be "
- "ambiguous — remove the duplicate from one picker: "
- + ", ".join(sorted(conflicts))
- )
- if not symbols:
- st.warning("Pick at least one symbol above to configure an instrument.")
-
- instrument_rows = _instrument_table(symbols, provenance)
- use_bundled_demo_data = False
- if any(row["Source"] == "csv" for row in instrument_rows):
- use_bundled_demo_data = st.toggle(
- "Allow bundled synthetic demo data",
- value=False,
- help=(
- "If every requested CSV is absent from data/raw, use the "
- "bundled synthetic SPY/QQQ/TLT/GLD files instead. QuantLab "
- "never mixes local and bundled files, and unsupported "
- "symbols still fail explicitly."
- ),
- )
-
- instrument_calendars = {row["Calendar"] for row in instrument_rows}
- periods_per_year: int | None = None
- if len(instrument_calendars) > 1:
- st.warning(
- "Instruments span more than one calendar "
- f"({', '.join(sorted(instrument_calendars))}), so QuantLab "
- "cannot infer a single annualisation factor automatically — "
- "set one explicitly."
- )
- # Default to the 24/7 convention as soon as any instrument actually
- # trades continuously -- a mixed portfolio that includes one is
- # closer to a "always-open" annualisation than a pure business-day
- # one, and 252 silently understates volatility/Sharpe for it.
- default_periods_per_year = 365 if "24/7" in instrument_calendars else 252
- periods_per_year = int(
- st.number_input(
- "Periods per year (annualisation factor)",
- min_value=1,
- value=default_periods_per_year,
- step=1,
- help=(
- "Used to annualise Sharpe, volatility and vol-targeting "
- "across the whole portfolio. 252 for a business-day "
- "equity convention, 365 for a continuous 24/7 one."
- ),
- )
- )
-
- # Sidebar date fields need the full width to keep labels and values readable.
- start_date = st.date_input(
- "Start date",
- value=date(2019, 1, 1),
- help=(
- "Requested start of the sample. The actually observed range "
- "after data-quality filtering is reported once the backtest runs."
- ),
- )
- end_date = st.date_input(
- "End date",
- value=default_end_date(),
- help="Requested end of the sample, same caveat as Start date above.",
- )
-
- # Offer only frequencies compatible with every selected instrument's
- # source — the same intersection ExperimentConfig itself validates, so
- # the picker can never offer something the config would then reject.
- instrument_sources = {DataSourceName(row["Source"]) for row in instrument_rows}
- frequency_options = sorted(compatible_frequencies_for_sources(instrument_sources))
- # '1h' has no verified-closure handling for a mixed-calendar universe
- # (that machinery only operates at daily frequency) -- offering it would
- # let the config validator reject the run only after "Run backtest" is
- # clicked, so it's excluded here too, matching what ExperimentConfig
- # itself would refuse.
- intraday_blocked_by_mixed_calendar = (
- len(instrument_calendars) > 1 and "1h" in frequency_options
- )
- if intraday_blocked_by_mixed_calendar:
- frequency_options = [f for f in frequency_options if f != "1h"]
- frequency = st.selectbox(
- "Frequency",
- frequency_options,
- index=0 if frequency_options else None,
- help=(
- "Bar size requested from the data source; also sets the "
- "annualisation factor used by every risk metric."
- ),
- )
- if not frequency_options:
- st.error(
- "No frequency is compatible with every selected source — remove "
- "one of the conflicting sources above."
- )
- elif intraday_blocked_by_mixed_calendar:
+run = False
+if mode != "Strategies":
+ with st.sidebar:
+ st.header("Experiment configuration")
+ st.subheader("Instruments")
st.caption(
- "'1h' is unavailable for a mixed-calendar universe — verified "
- "closures only work at daily frequency."
+ "Pick symbols from any of the three sources below — they all "
+ "combine into one multi-market universe."
)
- if any(row["Source"] == "csv" for row in instrument_rows):
- st.caption(
- "For CSV data, frequency controls annualisation and data-quality "
- "checks but does not resample the file. A mismatch with the "
- "observed timestamps is reported after the run."
- )
- with st.expander("Advanced data settings"):
- missing_value_policy = st.selectbox(
- "Missing value policy",
- ["drop", "forward_fill", "raise", "none"],
- index=0,
- help=(
- "How the cleaner treats missing canonical market data bars: "
- "drop removes affected rows; forward_fill fills up to the "
- "limit below; raise fails the run on any gap; none leaves "
- "gaps as-is."
- ),
+ with st.expander("Yahoo Finance", expanded=False):
+ yahoo_symbols = _yahoo_symbols_picker()
+ with st.expander("Binance", expanded=False):
+ binance_symbols = _binance_symbols_picker()
+ with st.expander("CSV (local files)", expanded=True):
+ csv_symbols = _csv_symbols_picker()
+
+ symbols, provenance, conflicts = _combine_instrument_picks(
+ yahoo_symbols, binance_symbols, csv_symbols
)
- if missing_value_policy == "forward_fill":
- forward_fill_limit = st.number_input(
- "Forward-fill limit (consecutive bars)",
- min_value=1,
- value=1,
- step=1,
+ if conflicts:
+ st.error(
+ "Picked from more than one source, so source/calendar would be "
+ "ambiguous — remove the duplicate from one picker: "
+ + ", ".join(sorted(conflicts))
+ )
+ if not symbols:
+ st.warning("Pick at least one symbol above to configure an instrument.")
+
+ instrument_rows = _instrument_table(symbols, provenance)
+ use_bundled_demo_data = False
+ if any(row["Source"] == "csv" for row in instrument_rows):
+ use_bundled_demo_data = st.toggle(
+ "Allow bundled synthetic demo data",
+ value=False,
help=(
- "Maximum consecutive missing bars filled per symbol "
- "before the gap is left as-is."
+ "If every requested CSV is absent from data/raw, use the "
+ "bundled synthetic SPY/QQQ/TLT/GLD files instead. QuantLab "
+ "never mixes local and bundled files, and unsupported "
+ "symbols still fail explicitly."
),
)
- else:
- forward_fill_limit = 1
- strategy_options = available_strategies()
- if mode == "Walk-forward":
- # buy_and_hold has no parameters (BuyAndHoldStrategy._freeze_parameters()),
- # so there is nothing for fold-by-fold validation-block selection to
- # select — walk-forward would just repeat the same signal on every
- # fold for no benefit over a single backtest.
- strategy_options = [name for name in strategy_options if name != "buy_and_hold"]
- strategy_name = st.selectbox(
- "Strategy",
- strategy_options,
- index=0,
- help=(
- "Signal-generation method: time_series_momentum / "
- "cross_sectional_momentum (trend continuation); trend_following "
- "(moving-average trend); mean_reversion / pairs_trading "
- "(reversion to a trailing mean or spread)."
- + (
- ""
- if mode == "Walk-forward"
- else " buy_and_hold (no signal, baseline exposure) is also "
- "available here, but has no parameters to select, so it is "
- "hidden in Walk-forward mode."
+ instrument_calendars = {row["Calendar"] for row in instrument_rows}
+ periods_per_year: int | None = None
+ if len(instrument_calendars) > 1:
+ st.warning(
+ "Instruments span more than one calendar "
+ f"({', '.join(sorted(instrument_calendars))}), so QuantLab "
+ "cannot infer a single annualisation factor automatically — "
+ "set one explicitly."
+ )
+ # Default to the 24/7 convention as soon as any instrument actually
+ # trades continuously -- a mixed portfolio that includes one is
+ # closer to a "always-open" annualisation than a pure business-day
+ # one, and 252 silently understates volatility/Sharpe for it.
+ default_periods_per_year = 365 if "24/7" in instrument_calendars else 252
+ periods_per_year = int(
+ st.number_input(
+ "Periods per year (annualisation factor)",
+ min_value=1,
+ value=default_periods_per_year,
+ step=1,
+ help=(
+ "Used to annualise Sharpe, volatility and vol-targeting "
+ "across the whole portfolio. 252 for a business-day "
+ "equity convention, 365 for a continuous 24/7 one."
+ ),
+ )
)
- + " Its own parameters appear below."
- ),
- )
- if strategy_name != "buy_and_hold":
- st.subheader("Strategy parameters")
- strategy_parameters = _strategy_param_inputs(strategy_name, symbols)
-
- st.subheader("Portfolio")
- signal_scaling = strategy_parameters.get("signal_scaling")
- # Mirror ExperimentConfig's validators so an invalid combination can never
- # be selected in the first place, instead of failing only after "Run
- # backtest": equal_weight discards signal magnitude (breaks non-binary
- # time-series scaling), and volatility_adjusted already divides by
- # volatility itself (an inverse-volatility allocator would apply that
- # sizing a second time).
- allocator_note: str | None
- if strategy_name == "pairs_trading":
- allocator_options = ["signal_proportional"]
- allocator_note = (
- "Only signal_proportional is offered: pairs trading needs its "
- "signed hedge magnitude preserved exactly, not re-sized or "
- "reduced to a sign, to keep the pair's two legs offsetting."
- )
- elif (
- strategy_name == "time_series_momentum"
- and signal_scaling == "volatility_adjusted"
- ):
- allocator_options = ["signal_proportional"]
- allocator_note = (
- "Only signal_proportional is offered: volatility_adjusted "
- "scaling above already divides the signal by trailing "
- "volatility, so inverse_volatility or volatility_targeting "
- "would apply that sizing a second time."
- )
- elif strategy_name == "time_series_momentum" and signal_scaling == "continuous":
- allocator_options = [
- "signal_proportional",
- "inverse_volatility",
- "volatility_targeting",
- ]
- allocator_note = (
- "equal_weight is not offered: it discards signal magnitude and "
- "keeps only its sign, which would throw away the continuous "
- "scaling selected above."
- )
- else:
- allocator_options = [
- "equal_weight",
- "signal_proportional",
- "inverse_volatility",
- "volatility_targeting",
- ]
- allocator_note = None
- preferred_default = (
- "inverse_volatility"
- if "inverse_volatility" in allocator_options
- else allocator_options[0]
- )
- allocator = st.selectbox(
- "Allocator",
- allocator_options,
- index=allocator_options.index(preferred_default),
- help=(
- "equal_weight: same absolute weight on every active signal. "
- "signal_proportional: weight scales with signed signal magnitude. "
- "inverse_volatility: weight scales inversely with each asset's "
- "trailing volatility. volatility_targeting: inverse-volatility "
- "weights further scaled so total exposure tracks the target "
- "volatility below. Only allocators compatible with the selected "
- "strategy (and its signal scaling, for time_series_momentum) are "
- "listed."
- ),
- )
- if allocator_note is not None:
- st.caption(allocator_note)
- if strategy_name == "pairs_trading":
- maximum_weight = None
- long_only = False
- st.caption(
- "Per-asset weight caps and long-only are disabled to preserve the "
- "pair hedge (a pairs trade always holds one long and one short leg)."
- )
- else:
- maximum_weight = st.slider(
- "Max weight per asset",
- 0.05,
- 1.0,
- 0.30,
- 0.05,
+ # Sidebar date fields need the full width to keep labels and values readable.
+ start_date = st.date_input(
+ "Start date",
+ value=date(2019, 1, 1),
help=(
- "Hard cap on any single asset's absolute target weight, "
- "enforced as a portfolio constraint regardless of the "
- "allocator chosen above."
+ "Requested start of the sample. The actually observed range "
+ "after data-quality filtering is reported once the backtest runs."
),
)
- long_only = st.checkbox(
- "Long only (portfolio)",
- value=False,
+ end_date = st.date_input(
+ "End date",
+ value=default_end_date(),
+ help="Requested end of the sample, same caveat as Start date above.",
+ )
+
+ # Offer only frequencies compatible with every selected instrument's
+ # source — the same intersection ExperimentConfig itself validates, so
+ # the picker can never offer something the config would then reject.
+ instrument_sources = {DataSourceName(row["Source"]) for row in instrument_rows}
+ frequency_options = sorted(
+ compatible_frequencies_for_sources(instrument_sources)
+ )
+ # '1h' has no verified-closure handling for a mixed-calendar universe
+ # (that machinery only operates at daily frequency) -- offering it would
+ # let the config validator reject the run only after "Run backtest" is
+ # clicked, so it's excluded here too, matching what ExperimentConfig
+ # itself would refuse.
+ intraday_blocked_by_mixed_calendar = (
+ len(instrument_calendars) > 1 and "1h" in frequency_options
+ )
+ if intraday_blocked_by_mixed_calendar:
+ frequency_options = [f for f in frequency_options if f != "1h"]
+ frequency = st.selectbox(
+ "Frequency",
+ frequency_options,
+ index=0 if frequency_options else None,
help=(
- "Reject any negative target weight at the portfolio level, on "
- "top of whatever the strategy's own signals already allow."
+ "Bar size requested from the data source; also sets the "
+ "annualisation factor used by every risk metric."
),
)
- rebalance_frequency = st.selectbox(
- "Rebalance frequency",
- ["daily", "weekly", "monthly", "quarterly"],
- index=2,
- help=(
- "How often target weights are recomputed and traded toward. "
- "Between rebalances, QuantLab carries the previous target weights "
- "forward unchanged; it does not model price-driven weight drift."
- ),
- )
- if allocator == "volatility_targeting":
- enable_volatility_targeting = True
- st.caption("Volatility targeting is inherent to this allocator.")
- else:
- enable_volatility_targeting = st.toggle(
- "Enable volatility targeting",
- value=True,
- help=(
- "Scale the portfolio's overall exposure toward the annual "
- "volatility target. Disable this to keep the allocator's "
- "unscaled weights."
- ),
- )
- if allocator in {"inverse_volatility", "volatility_targeting"} or (
- enable_volatility_targeting
- ):
- volatility_window = st.slider(
- "Volatility window (periods)",
- 10,
- 252,
- 63,
- 1,
+ if not frequency_options:
+ st.error(
+ "No frequency is compatible with every selected source — remove "
+ "one of the conflicting sources above."
+ )
+ elif intraday_blocked_by_mixed_calendar:
+ st.caption(
+ "'1h' is unavailable for a mixed-calendar universe — verified "
+ "closures only work at daily frequency."
+ )
+ if any(row["Source"] == "csv" for row in instrument_rows):
+ st.caption(
+ "For CSV data, frequency controls annualisation and data-quality "
+ "checks but does not resample the file. A mismatch with the "
+ "observed timestamps is reported after the run."
+ )
+ with st.expander("Advanced data settings"):
+ missing_value_policy = st.selectbox(
+ "Missing value policy",
+ ["drop", "forward_fill", "raise", "none"],
+ index=0,
+ help=(
+ "How the cleaner treats missing canonical market data bars: "
+ "drop removes affected rows; forward_fill fills up to the "
+ "limit below; raise fails the run on any gap; none leaves "
+ "gaps as-is."
+ ),
+ )
+ if missing_value_policy == "forward_fill":
+ forward_fill_limit = st.number_input(
+ "Forward-fill limit (consecutive bars)",
+ min_value=1,
+ value=1,
+ step=1,
+ help=(
+ "Maximum consecutive missing bars filled per symbol "
+ "before the gap is left as-is."
+ ),
+ )
+ else:
+ forward_fill_limit = 1
+
+ strategy_options = available_strategies()
+ if mode == "Walk-forward":
+ # buy_and_hold has no parameters (BuyAndHoldStrategy._freeze_parameters()),
+ # so there is nothing for fold-by-fold validation-block selection to
+ # select — walk-forward would just repeat the same signal on every
+ # fold for no benefit over a single backtest.
+ strategy_options = [
+ name for name in strategy_options if name != "buy_and_hold"
+ ]
+ strategy_name = st.selectbox(
+ "Strategy",
+ strategy_options,
+ index=0,
help=(
- "Trailing window used to estimate realised volatility, for "
- "both inverse-volatility sizing and volatility targeting."
+ "Signal-generation method: time_series_momentum / "
+ "cross_sectional_momentum (trend continuation); trend_following "
+ "(moving-average trend); mean_reversion / pairs_trading "
+ "(reversion to a trailing mean or spread)."
+ + (
+ ""
+ if mode == "Walk-forward"
+ else " buy_and_hold (no signal, baseline exposure) is also "
+ "available here, but has no parameters to select, so it is "
+ "hidden in Walk-forward mode."
+ )
+ + " Its own parameters appear below."
),
)
- else:
- volatility_window = 63
- if enable_volatility_targeting:
- target_volatility: float | None = st.slider(
- "Target volatility (annual)",
- 0.05,
- 0.40,
- 0.12,
- 0.01,
+
+ if strategy_name != "buy_and_hold":
+ st.subheader("Strategy parameters")
+ strategy_parameters = _strategy_param_inputs(strategy_name, symbols)
+
+ st.subheader("Portfolio")
+ signal_scaling = strategy_parameters.get("signal_scaling")
+ # Mirror ExperimentConfig's validators so an invalid combination can never
+ # be selected in the first place, instead of failing only after "Run
+ # backtest": equal_weight discards signal magnitude (breaks non-binary
+ # time-series scaling), and volatility_adjusted already divides by
+ # volatility itself (an inverse-volatility allocator would apply that
+ # sizing a second time).
+ allocator_note: str | None
+ if strategy_name == "pairs_trading":
+ allocator_options = ["signal_proportional"]
+ allocator_note = (
+ "Only signal_proportional is offered: pairs trading needs its "
+ "signed hedge magnitude preserved exactly, not re-sized or "
+ "reduced to a sign, to keep the pair's two legs offsetting."
+ )
+ elif (
+ strategy_name == "time_series_momentum"
+ and signal_scaling == "volatility_adjusted"
+ ):
+ allocator_options = ["signal_proportional"]
+ allocator_note = (
+ "Only signal_proportional is offered: volatility_adjusted "
+ "scaling above already divides the signal by trailing "
+ "volatility, so inverse_volatility or volatility_targeting "
+ "would apply that sizing a second time."
+ )
+ elif (
+ strategy_name in ("time_series_momentum", "cross_sectional_momentum")
+ and signal_scaling == "continuous"
+ ):
+ allocator_options = [
+ "signal_proportional",
+ "inverse_volatility",
+ "volatility_targeting",
+ ]
+ allocator_note = (
+ "equal_weight is not offered: it discards signal magnitude and "
+ "keeps only its sign, which would throw away the continuous "
+ "scaling selected above."
+ )
+ else:
+ allocator_options = [
+ "equal_weight",
+ "signal_proportional",
+ "inverse_volatility",
+ "volatility_targeting",
+ ]
+ allocator_note = None
+ preferred_default = (
+ "inverse_volatility"
+ if "inverse_volatility" in allocator_options
+ else allocator_options[0]
+ )
+ allocator = st.selectbox(
+ "Allocator",
+ allocator_options,
+ index=allocator_options.index(preferred_default),
help=(
- "Desired annualised portfolio volatility. Exposure is "
- "scaled, up to 'Max leverage' below, toward this target "
- "using the volatility window above."
+ "equal_weight: same absolute weight on every active signal. "
+ "signal_proportional: weight scales with signed signal magnitude. "
+ "inverse_volatility: weight scales inversely with each asset's "
+ "trailing volatility. volatility_targeting: inverse-volatility "
+ "weights further scaled so total exposure tracks the target "
+ "volatility below. Only allocators compatible with the selected "
+ "strategy (and its signal scaling, for time_series_momentum) are "
+ "listed."
),
)
- maximum_leverage = st.slider(
- "Max leverage",
- 1.0,
- 3.0,
- 1.5,
- 0.1,
+ if allocator_note is not None:
+ st.caption(allocator_note)
+ if strategy_name == "pairs_trading":
+ maximum_weight = None
+ long_only = False
+ st.caption(
+ "Per-asset weight caps and long-only are disabled to preserve the "
+ "pair hedge (a pairs trade always holds one long and one short leg)."
+ )
+ else:
+ maximum_weight = st.slider(
+ "Max weight per asset",
+ 0.05,
+ 1.0,
+ 0.30,
+ 0.05,
+ help=(
+ "Hard cap on any single asset's absolute target weight, "
+ "enforced as a portfolio constraint regardless of the "
+ "allocator chosen above."
+ ),
+ )
+ long_only = st.checkbox(
+ "Long only (portfolio)",
+ value=False,
+ help=(
+ "Reject any negative target weight at the portfolio level, on "
+ "top of whatever the strategy's own signals already allow."
+ ),
+ )
+ rebalance_frequency = st.selectbox(
+ "Rebalance frequency",
+ ["daily", "weekly", "monthly", "quarterly"],
+ index=2,
help=(
- "Ceiling on the volatility-targeting scale-up, e.g. 1.5 "
- "allows up to 150% gross exposure even if hitting the "
- "target volatility would ask for more."
+ "How often target weights are recomputed and traded toward. "
+ "Between rebalances, each asset's own price move drifts its "
+ "executed weight (model_weight_drift, on by default); a "
+ "scheduled rebalance still always trades toward its "
+ "freshly-decided target, even if that target happens to "
+ "numerically match the previous one."
),
)
- else:
- target_volatility = None
- maximum_leverage = 1.0
-
- if strategy_name == "pairs_trading":
- target_minimum_weight = None
- maximum_gross_exposure = None
- maximum_net_exposure = None
- target_maximum_positions = None
- maximum_turnover = None
- st.caption(
- "Advanced portfolio constraints are disabled for pairs_trading: "
- "a minimum position size, position count cap, or exposure cap "
- "could drop one leg and break the pair hedge."
- )
- else:
- # Set by the non-pairs_trading branch above whenever this branch runs.
- assert maximum_weight is not None
- with st.expander("Advanced portfolio constraints"):
- if st.checkbox(
- "Enable minimum position size",
- value=False,
+ if allocator == "volatility_targeting":
+ enable_volatility_targeting = True
+ st.caption("Volatility targeting is inherent to this allocator.")
+ else:
+ enable_volatility_targeting = st.toggle(
+ "Enable volatility targeting",
+ value=True,
help=(
- "Reject any target weight smaller than this instead of "
- "holding a near-zero position."
+ "Scale the portfolio's overall exposure toward the annual "
+ "volatility target. Disable this to keep the allocator's "
+ "unscaled weights."
),
- ):
- target_minimum_weight = st.slider(
- "Minimum position size",
- 0.0,
- maximum_weight,
- min(0.02, maximum_weight),
- 0.01,
- help="Smallest allowed non-zero target weight per asset.",
- )
- else:
- target_minimum_weight = None
- if st.checkbox(
- "Cap gross exposure",
- value=False,
+ )
+ if allocator in {"inverse_volatility", "volatility_targeting"} or (
+ enable_volatility_targeting
+ ):
+ volatility_window = st.slider(
+ "Volatility window (periods)",
+ 10,
+ 252,
+ 63,
+ 1,
help=(
- "Limit total absolute exposure (sum of |weight|) across all assets."
+ "Trailing window used to estimate realised volatility, for "
+ "both inverse-volatility sizing and volatility targeting."
),
- ):
- maximum_gross_exposure = st.slider(
- "Max gross exposure",
- 0.1,
- 3.0,
- 1.0,
- 0.1,
- help="Ceiling on gross exposure, enforced on top of Max leverage.",
- )
- else:
- maximum_gross_exposure = None
- if st.checkbox(
- "Cap net exposure",
- value=False,
- help="Limit net directional exposure (sum of signed weights).",
- ):
- maximum_net_exposure = st.slider(
- "Max net exposure",
- 0.0,
- 3.0,
- 1.0,
- 0.1,
- help="Ceiling on |long weight - short weight| across all assets.",
- )
- else:
- maximum_net_exposure = None
- if st.checkbox(
- "Cap number of positions",
- value=False,
+ )
+ else:
+ volatility_window = 63
+ if enable_volatility_targeting:
+ target_volatility: float | None = st.slider(
+ "Target volatility (annual)",
+ 0.05,
+ 0.40,
+ 0.12,
+ 0.01,
help=(
- "Limit how many assets can be held with a non-zero "
- "target weight at once."
+ "Desired annualised portfolio volatility. Exposure is "
+ "scaled, up to 'Max leverage' below, toward this target "
+ "using the volatility window above."
),
- ):
- target_maximum_positions = st.number_input(
- "Max number of positions",
- min_value=1,
- value=min(10, max(1, len(symbols))),
- step=1,
- help="Largest number of simultaneously non-zero target weights.",
- )
- else:
- target_maximum_positions = None
- if st.checkbox(
- "Cap turnover per rebalance",
- value=False,
+ )
+ maximum_leverage = st.slider(
+ "Max leverage",
+ 1.0,
+ 3.0,
+ 1.5,
+ 0.1,
help=(
- "Limit how much total weight can change at each "
- "rebalance, spreading large shifts over several periods."
+ "Ceiling on the volatility-targeting scale-up, e.g. 1.5 "
+ "allows up to 150% gross exposure even if hitting the "
+ "target volatility would ask for more."
),
- ):
- maximum_turnover = st.slider(
- "Max turnover per rebalance",
- 0.05,
- 2.0,
- 0.5,
- 0.05,
- help="Maximum L1 weight change allowed at each rebalance.",
- )
- else:
- maximum_turnover = None
-
- st.subheader("Validation")
- validation_ratio: float | None = None
- test_ratio: float | None = None
- train_window = 500
- validation_window = 126
- test_window = 126
- expanding = True
- optimization_metric = "sharpe"
- parameter_grid: dict[str, list] = {}
- if mode == "Walk-forward":
- st.caption(
- "Select parameters on each fold's validation block and evaluate "
- "them out-of-sample on the following test block, repeated and "
- "stitched across the whole history. This mode's own Results "
- "tab shows that stitched out-of-sample evidence, not a "
- "full-sample fit."
- )
- train_window = st.number_input(
- "Train window (periods)",
- min_value=10,
- value=500,
- step=10,
- help="Training periods per fold, used to fit the strategy state.",
- )
- validation_window = st.number_input(
- "Validation window (periods)",
- min_value=5,
- value=126,
- step=5,
- help="Validation periods per fold, used to select parameters.",
- )
- test_window = st.number_input(
- "Test window (periods)",
- min_value=5,
- value=126,
- step=5,
- help="Out-of-sample test periods per fold, stitched into the OOS series.",
- )
- expanding = st.toggle(
- "Expanding training window",
- value=True,
- help=(
- "On: each fold's training block grows to include everything "
- "before it. Off: training slides forward, always Train "
- "window periods long."
- ),
- )
- optimization_metric = st.selectbox(
- "Optimization metric",
- ["sharpe", "sortino", "calmar", "total_return"],
- index=0,
- help="Metric used to pick the best parameter combination on each fold.",
- )
- st.caption(
- "Parameters to search below — leave empty to use a compact, "
- "strategy-specific default grid at run time."
- )
- grid_param_names = sorted(strategy_parameter_names(strategy_name))
- selected_grid_params = st.multiselect(
- "Grid parameters",
- grid_param_names,
- help="Strategy parameters to vary across candidate values.",
- )
- for parameter_name in selected_grid_params:
- raw_values = st.text_input(
- f"Candidate values for {parameter_name} (comma-separated)",
- key=f"wf_grid_{parameter_name}",
)
- parameter_grid[parameter_name] = parse_parameter_grid_values(raw_values)
- # A rough estimate only (used to warn about a slow configuration
- # before data is loaded) -- treat the universe as 24/7 only when
- # every instrument genuinely is, otherwise fall back to the
- # business-day convention.
- is_247_market = bool(instrument_rows) and all(
- row["Calendar"] == "24/7" for row in instrument_rows
- )
- estimated_backtests = estimate_walk_forward_backtest_count(
- start_date=start_date,
- end_date=end_date or default_end_date(),
- is_247_market=is_247_market,
- train_window=train_window,
- validation_window=validation_window,
- test_window=test_window,
- expanding=expanding,
- parameter_grid=parameter_grid,
- )
- if estimated_backtests <= 0:
- st.warning(
- "No walk-forward fold fits the requested date range and "
- "windows — widen the date range or shorten the windows.",
- icon="⚠️",
+ else:
+ target_volatility = None
+ maximum_leverage = 1.0
+
+ if strategy_name == "pairs_trading":
+ target_minimum_weight = None
+ maximum_gross_exposure = None
+ maximum_net_exposure = None
+ target_maximum_positions = None
+ maximum_turnover = None
+ st.caption(
+ "Advanced portfolio constraints are disabled for pairs_trading: "
+ "a minimum position size, position count cap, or exposure cap "
+ "could drop one leg and break the pair hedge."
)
- else:
- enable_holdout = st.checkbox(
- "Chronological holdout (train / validation / test)",
- value=False,
- help=(
- "Split one continuous backtest chronologically and report "
- "each block separately. No fitting or parameter tuning "
- "happens here. Treat the trailing test block as "
- "out-of-sample only if you fixed the strategy and "
- "parameters before inspecting it; the headline metric "
- "cards still describe the full sample."
- ),
- )
- if enable_holdout:
- validation_ratio = st.slider(
- "Validation fraction",
- 0.05,
- 0.4,
- 0.2,
- 0.05,
+ else:
+ # Set by the non-pairs_trading branch above whenever this branch runs.
+ assert maximum_weight is not None
+ with st.expander("Advanced portfolio constraints"):
+ if st.checkbox(
+ "Enable minimum position size",
+ value=False,
+ help=(
+ "Reject any target weight smaller than this instead of "
+ "holding a near-zero position."
+ ),
+ ):
+ target_minimum_weight = st.slider(
+ "Minimum position size",
+ 0.0,
+ maximum_weight,
+ min(0.02, maximum_weight),
+ 0.01,
+ help="Smallest allowed non-zero target weight per asset.",
+ )
+ else:
+ target_minimum_weight = None
+ if st.checkbox(
+ "Cap gross exposure",
+ value=False,
+ help=(
+ "Limit total absolute exposure (sum of |weight|) "
+ "across all assets."
+ ),
+ ):
+ maximum_gross_exposure = st.slider(
+ "Max gross exposure",
+ 0.1,
+ 3.0,
+ 1.0,
+ 0.1,
+ help=(
+ "Ceiling on gross exposure, enforced on top of "
+ "Max leverage."
+ ),
+ )
+ else:
+ maximum_gross_exposure = None
+ if st.checkbox(
+ "Cap net exposure",
+ value=False,
+ help="Limit net directional exposure (sum of signed weights).",
+ ):
+ maximum_net_exposure = st.slider(
+ "Max net exposure",
+ 0.0,
+ 3.0,
+ 1.0,
+ 0.1,
+ help=(
+ "Ceiling on |long weight - short weight| across all assets."
+ ),
+ )
+ else:
+ maximum_net_exposure = None
+ if st.checkbox(
+ "Cap number of positions",
+ value=False,
+ help=(
+ "Limit how many assets can be held with a non-zero "
+ "target weight at once."
+ ),
+ ):
+ target_maximum_positions = st.number_input(
+ "Max number of positions",
+ min_value=1,
+ value=min(10, max(1, len(symbols))),
+ step=1,
+ help=(
+ "Largest number of simultaneously non-zero target weights."
+ ),
+ )
+ else:
+ target_maximum_positions = None
+ if st.checkbox(
+ "Cap turnover per period",
+ value=False,
+ help=(
+ "Limit how much total weight can change on any single "
+ "row, spreading a large rebalance over several periods."
+ ),
+ ):
+ maximum_turnover = st.slider(
+ "Max turnover per period",
+ 0.05,
+ 2.0,
+ 0.5,
+ 0.05,
+ help="Maximum L1 weight change allowed on any single row.",
+ )
+ else:
+ maximum_turnover = None
+
+ st.subheader("Validation")
+ validation_ratio: float | None = None
+ test_ratio: float | None = None
+ train_window = 500
+ validation_window = 126
+ test_window = 126
+ expanding = True
+ optimization_metric = "sharpe"
+ parameter_grid: dict[str, list] = {}
+ if mode == "Walk-forward":
+ st.caption(
+ "Select parameters on each fold's validation block and evaluate "
+ "them out-of-sample on the following test block, repeated and "
+ "stitched across the whole history. This mode's own Results "
+ "tab shows that stitched out-of-sample evidence, not a "
+ "full-sample fit."
+ )
+ train_window = st.number_input(
+ "Train window (periods)",
+ min_value=10,
+ value=500,
+ step=10,
+ help="Training periods per fold, used to fit the strategy state.",
+ )
+ validation_window = st.number_input(
+ "Validation window (periods)",
+ min_value=5,
+ value=126,
+ step=5,
+ help="Validation periods per fold, used to select parameters.",
+ )
+ test_window = st.number_input(
+ "Test window (periods)",
+ min_value=5,
+ value=126,
+ step=5,
help=(
- "Middle chronological slice reported separately for "
- "manual assessment. This dashboard backtest does not "
- "tune or select parameters automatically."
+ "Out-of-sample test periods per fold, stitched into the OOS series."
),
)
- test_ratio = st.slider(
- "Test fraction",
- 0.05,
- 0.4,
- 0.2,
- 0.05,
+ expanding = st.toggle(
+ "Expanding training window",
+ value=True,
help=(
- "Final chronological slice, reported separately as the "
- "'Test' block. Genuinely out-of-sample only if the "
- "strategy and parameters were fixed before it was ever "
- "inspected — this dashboard has no way to verify that."
+ "On: each fold's training block grows to include everything "
+ "before it. Off: training slides forward, always Train "
+ "window periods long."
),
)
+ optimization_metric = st.selectbox(
+ "Optimization metric",
+ ["sharpe", "sortino", "calmar", "total_return"],
+ index=0,
+ help="Metric used to pick the best parameter combination on each fold.",
+ )
+ st.caption(
+ "Parameters to search below — leave empty to use a compact, "
+ "strategy-specific default grid at run time."
+ )
+ grid_param_names = sorted(strategy_parameter_names(strategy_name))
+ selected_grid_params = st.multiselect(
+ "Grid parameters",
+ grid_param_names,
+ help="Strategy parameters to vary across candidate values.",
+ )
+ for parameter_name in selected_grid_params:
+ raw_values = st.text_input(
+ f"Candidate values for {parameter_name} (comma-separated)",
+ key=f"wf_grid_{parameter_name}",
+ )
+ parameter_grid[parameter_name] = parse_parameter_grid_values(raw_values)
+ # A rough estimate only (used to warn about a slow configuration
+ # before data is loaded) -- treat the universe as 24/7 only when
+ # every instrument genuinely is, otherwise fall back to the
+ # business-day convention.
+ is_247_market = bool(instrument_rows) and all(
+ row["Calendar"] == "24/7" for row in instrument_rows
+ )
+ estimated_backtests = estimate_walk_forward_backtest_count(
+ start_date=start_date,
+ end_date=end_date or default_end_date(),
+ is_247_market=is_247_market,
+ train_window=train_window,
+ validation_window=validation_window,
+ test_window=test_window,
+ expanding=expanding,
+ parameter_grid=parameter_grid,
+ )
+ if estimated_backtests <= 0:
+ st.warning(
+ "No walk-forward fold fits the requested date range and "
+ "windows — widen the date range or shorten the windows.",
+ icon="⚠️",
+ )
+ else:
+ enable_holdout = st.checkbox(
+ "Chronological holdout (train / validation / test)",
+ value=False,
+ help=(
+ "Split one continuous backtest chronologically and report "
+ "each block separately. No fitting or parameter tuning "
+ "happens here. Treat the trailing test block as "
+ "out-of-sample only if you fixed the strategy and "
+ "parameters before inspecting it; the headline metric "
+ "cards still describe the full sample."
+ ),
+ )
+ if enable_holdout:
+ validation_ratio = st.slider(
+ "Validation fraction",
+ 0.05,
+ 0.4,
+ 0.2,
+ 0.05,
+ help=(
+ "Middle chronological slice reported separately for "
+ "manual assessment. This dashboard backtest does not "
+ "tune or select parameters automatically."
+ ),
+ )
+ test_ratio = st.slider(
+ "Test fraction",
+ 0.05,
+ 0.4,
+ 0.2,
+ 0.05,
+ help=(
+ "Final chronological slice, reported separately as the "
+ "'Test' block. Genuinely out-of-sample only if the "
+ "strategy and parameters were fixed before it was ever "
+ "inspected — this dashboard has no way to verify that."
+ ),
+ )
- st.subheader("Costs & capital")
- initial_capital = st.number_input(
- "Initial capital",
- 1_000.0,
- value=100_000.0,
- step=1_000.0,
- help=(
- "Starting portfolio value in currency units. Scales every "
- "currency-denominated figure (costs, traded notional) but not "
- "percentage returns."
- ),
- )
- risk_free_rate_percent = float(
- st.number_input(
- "Risk-free rate (annual %)",
- value=2.0,
- step=0.1,
- format="%.2f",
+ st.subheader("Costs & capital")
+ initial_capital = st.number_input(
+ "Initial capital",
+ 1_000.0,
+ value=100_000.0,
+ step=1_000.0,
help=(
- "Annual rate used for excess-return metrics and the cash "
- "benchmark. Enter 2 for 2%."
+ "Starting portfolio value in currency units. Scales every "
+ "currency-denominated figure (costs, traded notional) but not "
+ "percentage returns."
),
)
- )
- benchmark_kind = st.selectbox(
- "Benchmark",
- options=["symbol", "equal_weight", "first_asset", "cash"],
- index=0,
- format_func={
- "symbol": "Symbol",
- "equal_weight": "Equal weight",
- "first_asset": "First asset",
- "cash": "Cash",
- }.__getitem__,
- help=(
- "Compare the strategy with an external symbol, an equal-weight "
- "portfolio, the first universe asset, or cash earning the "
- "configured risk-free rate."
- ),
- )
- # The benchmark symbol is itself an instrument (source + calendar), with
- # the same provenance rule as the table above: if it duplicates a
- # tradable instrument, its source/calendar are reused verbatim rather
- # than letting the user configure an inconsistency the config would
- # reject anyway (source/calendar must match exactly when they overlap).
- if benchmark_kind == "symbol":
- benchmark_symbol = (
- st.text_input(
- "Benchmark symbol",
- "SPY",
- help="External symbol to compare the strategy against.",
+ risk_free_rate_percent = float(
+ st.number_input(
+ "Risk-free rate (annual %)",
+ value=2.0,
+ step=0.1,
+ format="%.2f",
+ help=(
+ "Annual rate used for excess-return metrics and the cash "
+ "benchmark. Enter 2 for 2%."
+ ),
)
- .strip()
- .upper()
)
- matching_instrument = next(
- (row for row in instrument_rows if row["Instrument"] == benchmark_symbol),
- None,
+ benchmark_kind = st.selectbox(
+ "Benchmark",
+ options=["symbol", "equal_weight", "first_asset", "cash"],
+ index=0,
+ format_func={
+ "symbol": "Symbol",
+ "equal_weight": "Equal weight",
+ "first_asset": "First asset",
+ "cash": "Cash",
+ }.__getitem__,
+ help=(
+ "Compare the strategy with an external symbol, an equal-weight "
+ "portfolio, the first universe asset, or cash earning the "
+ "configured risk-free rate."
+ ),
)
- if matching_instrument is not None:
- benchmark_source = matching_instrument["Source"]
- benchmark_calendar = matching_instrument["Calendar"]
- st.caption(
- f"{benchmark_symbol} is already a tradable instrument — its "
- f"source ({benchmark_source}) and calendar "
- f"({benchmark_calendar}) are reused as-is."
+ # The benchmark symbol is itself an instrument (source + calendar), with
+ # the same provenance rule as the table above: if it duplicates a
+ # tradable instrument, its source/calendar are reused verbatim rather
+ # than letting the user configure an inconsistency the config would
+ # reject anyway (source/calendar must match exactly when they overlap).
+ if benchmark_kind == "symbol":
+ benchmark_symbol = (
+ st.text_input(
+ "Benchmark symbol",
+ "SPY",
+ help="External symbol to compare the strategy against.",
+ )
+ .strip()
+ .upper()
)
- elif benchmark_symbol:
- detected_source = detect_source(benchmark_symbol)
- source_options = ["yahoo", "binance", "csv"]
- benchmark_source = st.selectbox(
- "Benchmark source",
- source_options,
- index=source_options.index(
- detected_source.value if detected_source else "csv"
+ matching_instrument = next(
+ (
+ row
+ for row in instrument_rows
+ if row["Instrument"] == benchmark_symbol
),
- key="benchmark_source_select",
- help="Data source for the external benchmark symbol.",
- )
- benchmark_calendar = st.text_input(
- "Benchmark calendar",
- detect_calendar(benchmark_symbol, DataSourceName(benchmark_source))
- or "XNYS",
- key="benchmark_calendar_input",
- help="'24/7' for a continuous market, or a "
- "pandas_market_calendars name such as XNYS, XHKG, XLON.",
+ None,
)
+ if matching_instrument is not None:
+ benchmark_source = matching_instrument["Source"]
+ benchmark_calendar = matching_instrument["Calendar"]
+ st.caption(
+ f"{benchmark_symbol} is already a tradable instrument — its "
+ f"source ({benchmark_source}) and calendar "
+ f"({benchmark_calendar}) are reused as-is."
+ )
+ elif benchmark_symbol:
+ detected_source = detect_source(benchmark_symbol)
+ source_options = ["yahoo", "binance", "csv"]
+ benchmark_source = st.selectbox(
+ "Benchmark source",
+ source_options,
+ index=source_options.index(
+ detected_source.value if detected_source else "csv"
+ ),
+ key="benchmark_source_select",
+ help="Data source for the external benchmark symbol.",
+ )
+ benchmark_calendar = st.text_input(
+ "Benchmark calendar",
+ detect_calendar(benchmark_symbol, DataSourceName(benchmark_source))
+ or "XNYS",
+ key="benchmark_calendar_input",
+ help="'24/7' for a continuous market, or a "
+ "pandas_market_calendars name such as XNYS, XHKG, XLON.",
+ )
+ else:
+ benchmark_source = "csv"
+ benchmark_calendar = "XNYS"
else:
+ benchmark_symbol = ""
benchmark_source = "csv"
benchmark_calendar = "XNYS"
- else:
- benchmark_symbol = ""
- benchmark_source = "csv"
- benchmark_calendar = "XNYS"
- if benchmark_kind == "first_asset":
- first_symbol = symbols[0] if symbols else "the first universe symbol"
- st.caption(f"Benchmark asset: {first_symbol}")
- commission_bps = st.slider(
- "Commission (bps)",
- 0.0,
- 20.0,
- 2.0,
- 0.5,
- help="Broker commission charged per unit of traded notional, in basis points.",
- )
- spread_bps = st.slider(
- "Spread (bps)",
- 0.0,
- 20.0,
- 3.0,
- 0.5,
- help=(
- "Full quoted bid-ask spread in basis points; half is charged "
- "whenever a trade crosses it."
- ),
- )
- slippage_bps = st.slider(
- "Slippage (bps)",
- 0.0,
- 20.0,
- 2.0,
- 0.5,
- help=(
- "Additional execution cost beyond commission and spread, "
- "modelling market impact under the constant slippage model "
- "below."
- ),
- )
- with st.expander("Advanced execution settings"):
- slippage_model = st.selectbox(
- "Slippage model",
- ["constant", "volume"],
- index=0,
+ if benchmark_kind == "first_asset":
+ first_symbol = symbols[0] if symbols else "the first universe symbol"
+ st.caption(f"Benchmark asset: {first_symbol}")
+ commission_bps = st.slider(
+ "Commission (bps)",
+ 0.0,
+ 20.0,
+ 2.0,
+ 0.5,
+ help=(
+ "Broker commission charged per unit of traded notional, "
+ "in basis points."
+ ),
+ )
+ spread_bps = st.slider(
+ "Spread (bps)",
+ 0.0,
+ 20.0,
+ 3.0,
+ 0.5,
help=(
- "constant applies the slippage bps above uniformly to every "
- "trade. volume instead scales slippage with each trade's "
- "size relative to average daily volume, using the impact "
- "coefficient below."
+ "Full quoted bid-ask spread in basis points; half is charged "
+ "whenever a trade crosses it."
),
)
- if slippage_model == "volume":
- impact_coefficient = st.number_input(
- "Volume impact coefficient",
- min_value=0.0,
- value=0.1,
- step=0.01,
+ slippage_bps = st.slider(
+ "Slippage (bps)",
+ 0.0,
+ 20.0,
+ 2.0,
+ 0.5,
+ help=(
+ "Additional execution cost beyond commission and spread, "
+ "modelling market impact under the constant slippage model "
+ "below."
+ ),
+ )
+ with st.expander("Advanced execution settings"):
+ slippage_model = st.selectbox(
+ "Slippage model",
+ ["constant", "volume"],
+ index=0,
help=(
- "Multiplies sqrt(order size / average daily volume) — "
- "added on top of the slippage bps above. For liquid "
- "instruments (e.g. SPY, QQQ) and a modest position size "
- "relative to their average daily volume, that square "
- "root is tiny, so even a large coefficient can leave "
- "results looking identical to the constant model — this "
- "term is built to matter for large orders in thin "
- "markets, not small ones in deep markets."
+ "constant applies the slippage bps above uniformly to every "
+ "trade. volume instead scales slippage with each trade's "
+ "size relative to average daily volume, using the impact "
+ "coefficient below."
),
)
+ if slippage_model == "volume":
+ impact_coefficient = st.number_input(
+ "Volume impact coefficient",
+ min_value=0.0,
+ value=0.1,
+ step=0.01,
+ help=(
+ "Multiplies sqrt(order size / average daily volume) — "
+ "added on top of the slippage bps above. For liquid "
+ "instruments (e.g. SPY, QQQ) and a modest position size "
+ "relative to their average daily volume, that square "
+ "root is tiny, so even a large coefficient can leave "
+ "results looking identical to the constant model — this "
+ "term is built to matter for large orders in thin "
+ "markets, not small ones in deep markets."
+ ),
+ )
+ else:
+ impact_coefficient = 0.1
+
+ submission_blocked = bool(conflicts) or not symbols or not frequency_options
+ # Same stable key for both -- exactly one of the two ever renders in
+ # a given run (mutually exclusive on `mode`), and a stable key lets
+ # callers (dashboard tests) find this specific button regardless of
+ # how many OTHER sidebar buttons render before it.
+ if mode == "Walk-forward":
+ run = st.button(
+ "Run walk-forward",
+ type="primary",
+ width="stretch",
+ disabled=submission_blocked,
+ key="run_button",
+ )
else:
- impact_coefficient = 0.1
-
- submission_blocked = bool(conflicts) or not symbols or not frequency_options
- if mode == "Walk-forward":
- run = st.button(
- "Run walk-forward",
- type="primary",
- width="stretch",
- disabled=submission_blocked,
- )
- else:
- run = st.button(
- "Run backtest",
- type="primary",
- width="stretch",
- disabled=submission_blocked,
- )
+ run = st.button(
+ "Run backtest",
+ type="primary",
+ width="stretch",
+ disabled=submission_blocked,
+ key="run_button",
+ )
def _run_and_store(
@@ -1494,15 +1715,17 @@ def _on_progress(done: int, total: int) -> None:
return _on_progress
-def _render_bootstrap_interpretation() -> None:
+def _render_bootstrap_interpretation(confidence_level: float = 0.90) -> None:
"""Explain how to read the bootstrap summary table's columns."""
+ pct = f"{confidence_level:.0%}"
st.caption(
- "How to read this: p05/p95 form a 90% interval across resamples of "
- "these same, already-realised returns. If a statistic's p05 sits on "
- "the wrong side of zero (e.g. a negative CAGR or Sharpe), ordinary "
- "resampling variation in this exact history could plausibly have "
- "produced a loss — the result isn't robust to resampling yet, "
- "regardless of how good the point estimate looks."
+ f"How to read this: p_lower/p_upper form a {pct} interval across "
+ "resamples of these same, already-realised returns. If a "
+ "statistic's p_lower sits on the wrong side of zero (e.g. a "
+ "negative CAGR or Sharpe), ordinary resampling variation in this "
+ "exact history could plausibly have produced a loss — the result "
+ "isn't robust to resampling yet, regardless of how good the point "
+ "estimate looks."
)
@@ -1653,8 +1876,14 @@ def _render_robustness_tab(result: BacktestResult) -> None:
)
bootstrap_summary = st.session_state.get("bootstrap_summary")
if bootstrap_summary is not None:
- st.dataframe(bootstrap_summary, width="stretch", hide_index=True)
- _render_bootstrap_interpretation()
+ st.dataframe(
+ format_bootstrap_summary(bootstrap_summary),
+ width="stretch",
+ hide_index=True,
+ )
+ _render_bootstrap_interpretation(
+ result.config.robustness.bootstrap.confidence_level
+ )
st.markdown("#### Permutation Monte Carlo")
st.caption(
@@ -1921,8 +2150,14 @@ def _render_walk_forward_robustness_tab(wf: WalkForwardResult) -> None:
)
wf_bootstrap_summary = st.session_state.get("wf_bootstrap_summary")
if wf_bootstrap_summary is not None:
- st.dataframe(wf_bootstrap_summary, width="stretch", hide_index=True)
- _render_bootstrap_interpretation()
+ st.dataframe(
+ format_bootstrap_summary(wf_bootstrap_summary),
+ width="stretch",
+ hide_index=True,
+ )
+ _render_bootstrap_interpretation(
+ oos_result.config.robustness.bootstrap.confidence_level
+ )
st.markdown("#### Permutation Monte Carlo")
st.caption(
@@ -2068,7 +2303,16 @@ def _run_wf_sensitivity() -> None:
def _collect_backtest_robustness_evidence() -> tuple[
dict[str, object], tuple[object, ...]
]:
- """Gather every on-demand Backtest-mode robustness result for the report."""
+ """Gather every on-demand Backtest-mode robustness result for the report.
+
+ Also folds in the current strategy's own results diagnostics (e.g.
+ pairs trading's correlation/spread/ADF section), when its profile
+ declares any -- otherwise a report downloaded from the dashboard would
+ show that section live in the Results tab but silently drop it from
+ the exported HTML. A failed diagnostics computation still contributes
+ a status note rather than vanishing without a trace (mirrors the
+ Results tab's own warning; see `_render_strategy_diagnostics`).
+ """
evidence: dict[str, object] = {}
cache_parts: list[object] = []
for session_key, label in (
@@ -2081,6 +2325,40 @@ def _collect_backtest_robustness_evidence() -> tuple[
cache_parts.append(id(value) if value is not None else None)
if value is not None:
evidence[label] = value
+
+ result = st.session_state.get("result")
+ diagnostics = st.session_state.get("strategy_diagnostics")
+ diagnostics_error = st.session_state.get("strategy_diagnostics_error")
+ diagnostics_cache_part: object = diagnostics_error
+ if result is not None:
+ profile = get_profile(result.config.strategy_name)
+ if profile is not None and profile.results_diagnostics is not None:
+ if diagnostics is not None:
+ section = profile.results_diagnostics.report_section(diagnostics)
+ evidence[profile.results_diagnostics.key] = section
+ # A live Results-tab widget belonging to this diagnostic
+ # (e.g. its own forward-return horizon or window slider)
+ # can change this section's content without changing
+ # `diagnostics` itself -- `report_section` reads such a
+ # widget's current value straight from session_state (see
+ # `live_widget_value`). Cache on the section's own
+ # rendered content, not `id(diagnostics)`, so the exported
+ # report is correctly regenerated when the user moves one
+ # of those widgets instead of silently reusing a stale
+ # cached HTML report.
+ diagnostics_cache_part = (
+ tuple(section.table.columns),
+ tuple(section.table.itertuples(index=False, name=None)),
+ section.chart_data_uri,
+ section.note,
+ )
+ elif diagnostics_error is not None:
+ evidence[profile.results_diagnostics.key] = DiagnosticsSection(
+ table=pd.DataFrame(
+ {"Status": [f"Could not compute: {diagnostics_error}"]}
+ ),
+ )
+ cache_parts.append(diagnostics_cache_part)
return evidence, tuple(cache_parts)
@@ -2219,6 +2497,8 @@ def _clear_backtest_result_state() -> None:
"permutation_test",
"sensitivity",
"report_html",
+ "strategy_diagnostics",
+ "strategy_diagnostics_error",
):
st.session_state.pop(key, None)
@@ -2238,21 +2518,96 @@ def _clear_walk_forward_result_state() -> None:
st.session_state.pop(key, None)
+def _compute_strategy_diagnostics(data: pd.DataFrame, config: ExperimentConfig) -> Any:
+ """Compute the current strategy's results diagnostics, if it declares any.
+
+ Generic by design -- reads ``profile.results_diagnostics`` off whichever
+ profile is registered for ``config.strategy_name`` instead of branching
+ on a strategy name, so a strategy without diagnostics (the common case)
+ costs nothing beyond one dict lookup. ``data`` must be the EXACT frame
+ the displayed backtest itself ran on (see
+ ``run_dashboard_backtest_with_data``) -- reloading it separately could
+ silently observe different data for a remote source between the two
+ calls.
+ """
+ profile = get_profile(config.strategy_name)
+ if profile is None or profile.results_diagnostics is None:
+ return None
+ return profile.results_diagnostics.compute(data, config)
+
+
+def _render_strategy_diagnostics(result: BacktestResult) -> None:
+ """Render the Results-tab diagnostics declared by the current strategy's profile.
+
+ Renders nothing for a strategy without a declared ``results_diagnostics``
+ (the default) -- never an empty section, per the Strategy Explorer
+ architecture's generic, name-free dispatch. When the strategy DOES
+ declare one but computing it raised, surfaces that failure as a visible
+ warning instead of the diagnostics silently vanishing from the page.
+ """
+ profile = get_profile(result.config.strategy_name)
+ if profile is None or profile.results_diagnostics is None:
+ return
+ error = st.session_state.get("strategy_diagnostics_error")
+ if error is not None:
+ st.warning(
+ f"Backtest completed, but strategy diagnostics could not be "
+ f"computed: {error}"
+ )
+ return
+ diagnostics = st.session_state.get("strategy_diagnostics")
+ if diagnostics is None:
+ return
+ profile.results_diagnostics.render(st, diagnostics)
+
+
+def _render_walk_forward_diagnostics_note(oos_result: BacktestResult) -> None:
+ """Explain why Strategy Explorer results diagnostics aren't shown here.
+
+ Each walk-forward fold can select different strategy parameters than
+ the base config and covers only that fold's slice of history -- a
+ diagnostic computed once, on the full history with the base config's
+ parameters, would not actually describe what any individual fold
+ traded. Shown only for a strategy that DOES declare
+ ``results_diagnostics`` (so a strategy without one, the common case,
+ never gets a stray note here).
+ """
+ profile = get_profile(oos_result.config.strategy_name)
+ if profile is None or profile.results_diagnostics is None:
+ return
+ st.caption(
+ "This strategy has Strategy Explorer results diagnostics (see "
+ "Backtest mode) -- not shown here because each walk-forward fold "
+ "can select different parameters than this run's base config, so "
+ "a single full-history diagnostic would not accurately describe "
+ "every fold's own selection."
+ )
+
+
def _execute_backtest() -> None:
inputs = _collect_inputs()
_clear_backtest_result_state()
with st.spinner("Running backtest…"):
try:
config = build_config_from_inputs(inputs)
- result, warnings = run_dashboard_backtest(config)
+ result, warnings, data = run_dashboard_backtest_with_data(config)
except Exception as exc:
logger.exception("Dashboard backtest failed")
st.error(f"Backtest failed: {exc}")
return
+ diagnostics: Any = None
+ diagnostics_error: str | None = None
+ try:
+ diagnostics = _compute_strategy_diagnostics(data, config)
+ except Exception as exc:
+ logger.exception("Strategy Explorer results diagnostics failed")
+ diagnostics_error = str(exc)
st.session_state["result"] = result
st.session_state["result_inputs"] = inputs
st.session_state["warnings"] = warnings
+ st.session_state["strategy_diagnostics"] = diagnostics
+ st.session_state["strategy_diagnostics_error"] = diagnostics_error
def _execute_walk_forward() -> None:
@@ -2330,6 +2685,7 @@ def _execute_walk_forward() -> None:
render_charts(st, oos_result)
render_gross_net_comparison(st, oos_result)
render_exposure_and_cost_charts(st, oos_result)
+ _render_walk_forward_diagnostics_note(oos_result)
if tab_trades.open:
with tab_trades:
render_trade_table(st, oos_result)
@@ -2347,6 +2703,12 @@ def _execute_walk_forward() -> None:
cache_key_extra=wf_cache_parts,
session_key="wf_report_html",
)
+elif mode == "Strategies":
+ selected_strategy = st.session_state.get("explorer_strategy")
+ if selected_strategy is None:
+ explorer_gallery.render(st)
+ else:
+ explorer_detail.render(st, selected_strategy)
else:
result = st.session_state.get("result")
if result is None:
@@ -2388,6 +2750,7 @@ def _execute_walk_forward() -> None:
render_charts(st, result)
render_gross_net_comparison(st, result)
render_exposure_and_cost_charts(st, result)
+ _render_strategy_diagnostics(result)
if tab_trades.open:
with tab_trades:
render_trade_table(st, result)
diff --git a/src/quantlab/dashboard/components.py b/src/quantlab/dashboard/components.py
index ad7fa34..38fa936 100644
--- a/src/quantlab/dashboard/components.py
+++ b/src/quantlab/dashboard/components.py
@@ -6,11 +6,13 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, cast
import numpy as np
import pandas as pd
+from quantlab.backtesting.trade_log import parse_adjustment_codes
+from quantlab.dashboard.state import binance_trading_symbols, yahoo_common_symbols
from quantlab.reporting.charts import (
ACCENT,
BENCHMARK,
@@ -25,6 +27,206 @@
if TYPE_CHECKING:
from quantlab.backtesting.result import BacktestResult
+ from quantlab.data.base import SymbolSuggestion
+ from quantlab.features.pairs_diagnostics import PairDiagnostics
+
+#: Shown in a symbol picker's help tooltip whenever its preloaded list isn't
+#: a complete universe -- currently only Yahoo's, since Binance's list
+#: genuinely is complete.
+INCOMPLETE_LIST_NOTE = (
+ "Not every symbol is suggested — if yours is missing, type its exact "
+ "ticker and it'll still be accepted."
+)
+
+
+def parse_symbols(raw: str) -> list[str]:
+ """Normalise symbols and remove duplicates while preserving their order."""
+ return list(dict.fromkeys(s.strip().upper() for s in raw.split(",") if s.strip()))
+
+
+def label_for(suggestion: SymbolSuggestion) -> str:
+ """A symbol's display label -- ``"SYMBOL — description"`` when known."""
+ if suggestion.description:
+ return f"{suggestion.symbol} — {suggestion.description}"
+ return suggestion.symbol
+
+
+def cached_binance_universe(st: Any) -> list[SymbolSuggestion]:
+ """Binance's full active spot-symbol universe, refreshed hourly.
+
+ Fetched once per hour (per Streamlit cache entry, no arguments) rather
+ than per keystroke: once loaded, picking symbols from it is instant,
+ client-side dropdown filtering — no server round trip per character.
+ """
+
+ @st.cache_data(ttl=3600, show_spinner="Loading Binance's symbol list…")
+ def _load() -> list[SymbolSuggestion]:
+ return binance_trading_symbols()
+
+ return cast("list[SymbolSuggestion]", _load())
+
+
+def binance_universe_labels(st: Any) -> dict[str, str]:
+ """Binance's cached universe as ``{symbol: display label}``."""
+ return {s.symbol: label_for(s) for s in cached_binance_universe(st)}
+
+
+def yahoo_universe_labels() -> dict[str, str]:
+ """The bundled S&P 500 + major-ETF reference list as ``{symbol: label}``.
+
+ Yahoo has no downloadable "every symbol" endpoint the way Binance does,
+ so this static, bundled list stands in as an instant, offline universe —
+ covering what most dashboard users will look for, not every symbol Yahoo
+ can actually serve.
+ """
+ return {s.symbol: label_for(s) for s in yahoo_common_symbols()}
+
+
+#: (entry_min, entry_max, stop_max, step) UI ranges per mean_reversion
+#: `indicator` -- the entry/exit/stop DEFAULTS come from `quantlab.
+#: strategies.mean_reversion.INDICATOR_DEFAULT_THRESHOLDS` (the strategy's
+#: own single source of truth), not duplicated here.
+_MEAN_REVERSION_INDICATOR_UI_RANGES: dict[str, tuple[float, float, float, float]] = {
+ "zscore": (0.5, 4.0, 8.0, 0.1),
+ "bollinger": (0.1, 3.0, 4.0, 0.05),
+ "rsi": (5.0, 50.0, 50.0, 1.0),
+ "distance_ma": (0.01, 0.3, 0.4, 0.01),
+ "percentile": (0.01, 0.49, 0.49, 0.01),
+}
+
+
+def mean_reversion_slider_bounds(
+ indicator: str,
+) -> tuple[float, float, float, float, float, float, float]:
+ """UI slider bounds for one `mean_reversion` `indicator`'s thresholds.
+
+ Returns ``(entry_min, entry_max, entry_default, exit_default,
+ stop_max, stop_default, step)``. The three defaults come from
+ ``quantlab.strategies.mean_reversion.INDICATOR_DEFAULT_THRESHOLDS`` --
+ shared by the main dashboard sidebar and the Strategy Explorer lab so
+ neither can silently drift from what the strategy itself resolves to
+ when a threshold is left unset.
+ """
+ from quantlab.strategies.mean_reversion import INDICATOR_DEFAULT_THRESHOLDS
+
+ entry_min, entry_max, stop_max, step = _MEAN_REVERSION_INDICATOR_UI_RANGES[
+ indicator
+ ]
+ entry_default, exit_default, stop_default = INDICATOR_DEFAULT_THRESHOLDS[indicator]
+ return (
+ entry_min,
+ entry_max,
+ entry_default,
+ exit_default,
+ stop_max,
+ stop_default,
+ step,
+ )
+
+
+def entry_threshold_bounds(
+ entry_min: float,
+ entry_max: float,
+ stop_max: float,
+ step: float,
+ *,
+ stop_enabled: bool,
+) -> tuple[float, float]:
+ """Entry threshold's own ``(min, max)``, narrowed only as needed.
+
+ The low end keeps ``entry_min`` UNCHANGED -- entry never loses a
+ reachable value (e.g. ``distance_ma``'s/``percentile``'s own
+ step-sized minimum, ``0.01``) just to dodge the exit slider's own
+ degenerate near-zero case; see :func:`exit_threshold_bounds`, which
+ handles that case directly (fixing ``exit_threshold = 0.0`` instead of
+ rendering a slider at all) rather than narrowing entry's domain to
+ avoid it ever arising.
+
+ The high end is narrowed only for the stop slider, and only when it
+ actually exists: its own min is ``entry + step`` (its max is
+ ``stop_max``), but it is only rendered when ``stop_enabled`` -- so
+ entry only needs to stay 2 steps below ``stop_max`` while that slider
+ actually exists. With stop disabled, entry keeps its FULL original
+ upper range (e.g. RSI can still reach 50, percentile can still reach
+ 0.49) rather than silently losing reachable values for a slider that
+ was never going to be built.
+ """
+ hi = min(entry_max, stop_max - 2.0 * step) if stop_enabled else entry_max
+ return entry_min, hi
+
+
+def exit_threshold_bounds(
+ entry_threshold: float, step: float
+) -> tuple[float, float] | None:
+ """Exit threshold's own ``(min, max)``, or ``None`` when only 0.0 is valid.
+
+ The exit slider's max is ``entry_threshold - step`` (its min is fixed
+ at ``0.0``). When ``entry_threshold <= step``, that max is ``<= 0.0``
+ -- Streamlit's slider rejects ``min == max``, so there is no slider
+ to render at all: the only mathematically valid ``exit_threshold`` at
+ that point is exactly ``0.0``, and the caller should show that
+ directly (a caption or a disabled widget) instead of calling
+ ``st.slider`` with a degenerate range.
+ """
+ exit_max = round(entry_threshold - step, 10)
+ if exit_max <= 0.0:
+ return None
+ return 0.0, exit_max
+
+
+def symbols_picker(
+ st: Any,
+ label_by_symbol: dict[str, str],
+ key: str,
+ default_symbols: tuple[str, ...],
+ *,
+ accept_new_options: bool = False,
+ help_suffix: str = "",
+) -> list[str]:
+ """A single instant, client-side-filtered dropdown over a preloaded universe.
+
+ Typing filters the already-loaded option list in the browser (like a
+ search-engine dropdown) — no server round trip per character. When
+ ``accept_new_options`` is set, a symbol absent from the preloaded list
+ (Yahoo's bundled universe is large but not exhaustive — Yahoo has no
+ downloadable "every symbol" list to preload the way Binance does) can
+ still be typed and added directly. ``help_suffix`` lets a caller add
+ context-specific guidance (e.g. the main sidebar's pairs_trading note)
+ without this function assuming any particular caller's workflow.
+ """
+ help_text = "Tradable universe — start typing to filter"
+ help_text += (
+ ", or enter an exact symbol not in the list. " if accept_new_options else ". "
+ )
+ help_text += help_suffix
+ if accept_new_options:
+ help_text += " " + INCOMPLETE_LIST_NOTE
+
+ if key not in st.session_state:
+ st.session_state[key] = [
+ label_by_symbol[s] for s in default_symbols if s in label_by_symbol
+ ]
+ # No `label_visibility="collapsed"` here: Streamlit hides the help
+ # tooltip icon along with a collapsed label, and that icon is the only
+ # place the market-calendar/incomplete-list notes above are surfaced.
+ with st.container(border=True):
+ picked_labels = st.multiselect(
+ "Symbols",
+ options=list(label_by_symbol.values()),
+ key=key,
+ placeholder=(
+ "Type to find or add any symbol…"
+ if accept_new_options
+ else "Type to find a symbol…"
+ ),
+ accept_new_options=accept_new_options,
+ help=help_text,
+ )
+ symbol_by_label = {label: symbol for symbol, label in label_by_symbol.items()}
+ return [
+ symbol_by_label.get(str(label), str(label).strip().upper())
+ for label in picked_labels
+ ]
def render_metric_cards(st: Any, result: BacktestResult) -> None:
@@ -52,7 +254,13 @@ def formatted_metric(key: str, spec: str) -> str:
"Cumulative modelled transaction costs, expressed in the same "
"currency units as initial capital.",
),
- ("Number of trades", f"{result.number_of_trades()}", None),
+ (
+ "Number of fills",
+ f"{result.number_of_trades()}",
+ "Trade-log rows, one per symbol per executed order -- a "
+ "declared multi-symbol position (e.g. a pairs_trading hedge) "
+ "that trades both legs contributes one row per leg.",
+ ),
]
cols = st.columns(4)
for i, (label, value, help_text) in enumerate(cards):
@@ -293,33 +501,376 @@ def render_exposure_and_cost_charts(st: Any, result: BacktestResult) -> None:
col2.plotly_chart(fig_cost, width="stretch")
+def render_pair_diagnostics(
+ st: Any,
+ diagnostics: PairDiagnostics,
+ *,
+ entry_threshold: float,
+ exit_threshold: float,
+ stop_threshold: float | None,
+ adf_pvalue_threshold: float | None,
+) -> None:
+ """Render a pairs-trading result's correlation/hedge-ratio/spread diagnostics.
+
+ Shown only for a pairs_trading backtest -- the pairs_trading Strategy
+ Explorer profile's own ``results_diagnostics`` is the single place
+ that decides whether this section is computed at all (see
+ ``quantlab.dashboard.explorer.profile.ResultsDiagnostics``); nothing
+ here or in its caller checks the strategy's name.
+
+ The centered-indicator chart mirrors the interactive lab's own
+ "Trading thresholds" section exactly (same overlay helper, same viable-
+ entry gating rule: threshold crossed AND, if configured, the causal
+ rolling ADF p-value at that bar is <= ``adf_pvalue_threshold`` --
+ :meth:`~quantlab.strategies.pairs_trading.PairsTradingStrategy.
+ _stationarity_gate`'s own condition, using the SAME rolling ADF series
+ already shown above, never a second, potentially diverging computation).
+ """
+ from quantlab.dashboard.explorer.shared_components import (
+ VIABLE_ENTRY_MARKER_COLOR,
+ centered_indicator_threshold_overlay,
+ render_price_chart,
+ render_stationarity_card,
+ )
+
+ st.subheader("Pair relationship diagnostics")
+ st.caption(
+ f"{diagnostics.symbol_a} / {diagnostics.symbol_b} -- return "
+ f"correlation {diagnostics.correlation:.2f}."
+ )
+ render_price_chart(
+ st,
+ {"Hedge ratio (beta)": diagnostics.hedge_ratio},
+ title="Rolling hedge ratio",
+ yaxis_title="Beta",
+ )
+ render_price_chart(
+ st,
+ {"Spread": diagnostics.spread},
+ title=f"{diagnostics.symbol_a}/{diagnostics.symbol_b} spread",
+ yaxis_title="Spread",
+ )
+
+ indicator = diagnostics.spread_indicator
+ threshold_series, line_colors = centered_indicator_threshold_overlay(
+ indicator,
+ f"{diagnostics.indicator} indicator",
+ entry_threshold=entry_threshold,
+ exit_threshold=exit_threshold,
+ stop_threshold=stop_threshold,
+ )
+ crosses_entry = (indicator > entry_threshold) | (indicator < -entry_threshold)
+ if adf_pvalue_threshold is not None:
+ gate_open = (
+ diagnostics.rolling_adf_pvalue.reindex(indicator.index)
+ <= adf_pvalue_threshold
+ ).fillna(False)
+ else:
+ gate_open = pd.Series(True, index=indicator.index)
+ viable = crosses_entry & gate_open
+ viable_marker = indicator.where(viable)
+ markers = (
+ {"Viable entry (threshold crossed + ADF gate open)": viable_marker}
+ if viable.any()
+ else None
+ )
+ marker_colors = (
+ {"Viable entry (threshold crossed + ADF gate open)": VIABLE_ENTRY_MARKER_COLOR}
+ if markers
+ else {}
+ )
+ render_price_chart(
+ st,
+ threshold_series,
+ title=f"Centered '{diagnostics.indicator}' indicator of the spread "
+ "with entry/exit/stop thresholds",
+ yaxis_title="Centered indicator",
+ markers=markers,
+ colors={**line_colors, **marker_colors},
+ )
+ gate_text = (
+ f"ADF gate at p <= {adf_pvalue_threshold:g}"
+ if adf_pvalue_threshold is not None
+ else "ADF gate disabled"
+ )
+ n_viable = int(viable.sum())
+ if stop_threshold is not None:
+ stop_breaches = int(
+ ((indicator > stop_threshold) | (indicator < -stop_threshold)).sum()
+ )
+ stop_clause = (
+ f" **{stop_breaches}** bar(s) cross the stop threshold "
+ f"({stop_threshold:g})."
+ )
+ else:
+ stop_clause = " Stop threshold is disabled."
+ st.caption(
+ f"**{int(crosses_entry.sum())}** bar(s) cross the entry threshold; "
+ f"**{n_viable}** of those are viable ({gate_text})."
+ + stop_clause
+ + " Not an entry count: current state, rebalancing and execution can "
+ "still prevent or delay a trade."
+ )
+
+ render_stationarity_card(st, diagnostics.adf_result, label="ADF (spread)")
+ render_stationarity_card(
+ st, diagnostics.cointegration_result, label="Engle-Granger cointegration"
+ )
+ if diagnostics.rolling_adf_pvalue.notna().any():
+ render_price_chart(
+ st,
+ {"Rolling ADF p-value": diagnostics.rolling_adf_pvalue},
+ title="Stationarity stability over time",
+ yaxis_title="p-value",
+ )
+ half_life_text = (
+ f"{diagnostics.half_life:.1f} periods"
+ if np.isfinite(diagnostics.half_life)
+ else "not mean-reverting over this sample (no finite half-life)"
+ )
+ hedge_stability_text = (
+ f"{diagnostics.hedge_ratio_stability:.4f}"
+ if np.isfinite(diagnostics.hedge_ratio_stability)
+ else "n/a"
+ )
+ col_hl, col_stab = st.columns(2)
+ col_hl.metric("Half-life", half_life_text)
+ col_stab.metric("Hedge-ratio stability (std of beta)", hedge_stability_text)
+ st.caption(
+ "A larger hedge-ratio std means the fitted slope changes more in "
+ "this sample. It is measured in beta's scale-dependent units, so "
+ "compare it across settings for the same ordered pair, not across pairs."
+ )
+
+
+#: Fixed display schema for `render_trade_table` -- the SAME 15 columns, in
+#: this exact order, for every strategy. Only the values differ; a column is
+#: never added, dropped or reordered based on which strategy produced the
+#: result (`test_dashboard.py` asserts this across several strategies).
+_TRADE_TABLE_DISPLAY_COLUMNS = [
+ "Timestamp",
+ "Symbol",
+ "Action",
+ "Previous weight",
+ "New weight",
+ "Weight change",
+ "Trigger",
+ "Trigger detail",
+ "Adjustments",
+ "Position origin",
+ "Position origin date",
+ "Details",
+ "Reference price",
+ "Traded notional",
+ "Total cost",
+]
+
+
+def _is_missing(value: object) -> bool:
+ """True for None/NaN/NaT/`pd.NA`, without raising on a non-scalar value.
+
+ A plain ``isinstance(value, float) and pd.isna(value)`` check misses
+ ``pd.NA`` (not a ``float`` subclass) and
+ non-float-64 NaN scalars like ``numpy.float32('nan')``/
+ ``numpy.float16('nan')`` (also not ``float`` subclasses -- only
+ ``numpy.float64`` is, via CPython's numpy integration) -- either would
+ then reach ``str(value)`` -> ``parse_adjustment_codes()`` as a bogus
+ ``""``/``"nan"`` code and raise ``BacktestError`` on an unknown
+ adjustment code, instead of being treated as simply missing.
+ """
+ if not pd.api.types.is_scalar(value):
+ return False
+ return bool(pd.isna(cast(Any, value)))
+
+
+def _format_adjustments(value: object) -> object:
+ """Cosmetically re-space the "+"-joined machine codes for readability.
+
+ Still the exact same codes, never a fusion of different columns'
+ data. `None` (no adjustment layer acted on this trade) stays `None`,
+ never a blanked-away real value.
+ """
+ if _is_missing(value):
+ return None
+ return " + ".join(parse_adjustment_codes(str(value)))
+
+
+def _compose_details(trigger_details: object, adjustment_details: object) -> object:
+ """Human-readable summary combining the trigger's and adjustment(s)' text.
+
+ E.g. "Trigger: oversold entry; Adjustment: maximum_weight 0.62 ->
+ 0.50". Purely a reading aid -- the raw, machine-readable columns
+ this is built from are untouched in `result.trades`/the CSV export
+ below.
+ """
+ parts = []
+ if not _is_missing(trigger_details):
+ parts.append(f"Trigger: {trigger_details}")
+ if not _is_missing(adjustment_details):
+ parts.append(f"Adjustment: {adjustment_details}")
+ return "; ".join(parts) if parts else None
+
+
def render_trade_table(st: Any, result: BacktestResult) -> None:
- """Render the trade table with a CSV download."""
+ """Render a uniform trade table with a CSV download.
+
+ The same 15 display columns (`_TRADE_TABLE_DISPLAY_COLUMNS`), in the
+ same order, are shown for every strategy -- only the VALUES differ.
+ Missing information (no adjustment on this trade, no reason
+ attribution available at all for this run, e.g. a walk-forward
+ out-of-sample result) renders as a blank cell; the column itself
+ always stays, it is never conditionally dropped.
+
+ `Position origin`/`Position origin date` are always shown, on every
+ row, even when they duplicate `Trigger` -- blanking them
+ conditionally would make an empty cell ambiguous between "no origin"
+ and "hidden because redundant", exactly the ambiguity this design
+ avoids: blank means either that no strategic position is currently
+ active (the decision proxy is flat) or that position-origin
+ attribution was unavailable for this result -- notably for a stitched
+ walk-forward out-of-sample result, which does not carry it -- never a
+ value hidden for brevity.
+
+ This is a read-only presentation view derived from `result.trades`.
+ The CSV download button below always exports the raw, full
+ 21-column `result.trades` frame untouched, regardless of what is
+ shown here -- the visible table's schema never drives the export's.
+ """
trades = result.trades
if trades.empty:
st.info("No trades were recorded for this configuration.")
return
- display_cols = [
- "timestamp",
- "symbol",
- "side",
- "weight_change",
- "traded_notional",
- "total_cost",
+ from quantlab.backtesting.trade_log import stop_loss_take_profit_trigger_counts
+ from quantlab.dashboard.explorer.shared_components import strong
+
+ trigger_counts = stop_loss_take_profit_trigger_counts(trades)
+ strategy_params = result.config.strategy_parameters
+ stop_loss_enabled = strategy_params.get("stop_loss_pct") is not None
+ take_profit_enabled = strategy_params.get("take_profit_pct") is not None
+ # Gated on whether each was actually CONFIGURED, not on whether it
+ # fired -- a configured-but-never-triggered stop/target (count 0) is a
+ # meaningful fact worth showing, and independent of the other: enabling
+ # only stop-loss must never imply take-profit was also active at 0.
+ if stop_loss_enabled or take_profit_enabled:
+ parts = []
+ if stop_loss_enabled:
+ parts.append(
+ f"Stop-loss affected {strong(str(trigger_counts['stop_loss']))} "
+ "symbol-position exit(s)"
+ )
+ if take_profit_enabled:
+ label = "take-profit" if parts else "Take-profit"
+ parts.append(
+ f"{label} affected {strong(str(trigger_counts['take_profit']))} "
+ "symbol-position exit(s)"
+ )
+ st.caption(
+ "; ".join(parts) + " -- counted per trade-log row, so a "
+ "declared multi-symbol position (e.g. a pairs_trading hedge) "
+ "that force-flattens contributes one row per leg, not "
+ "necessarily one distinct stop-loss/take-profit EVENT.",
+ unsafe_allow_html=True,
+ )
+ details = [
+ _compose_details(trigger_detail, adjustment_detail)
+ for trigger_detail, adjustment_detail in zip(
+ trades["trigger_reason_details"],
+ trades["adjustment_reason_details"],
+ strict=True,
+ )
]
+ display_trades = pd.DataFrame(
+ {
+ "Timestamp": trades["timestamp"],
+ "Symbol": trades["symbol"],
+ "Action": trades["action"],
+ "Previous weight": trades["previous_weight"],
+ "New weight": trades["new_weight"],
+ "Weight change": trades["weight_change"],
+ "Trigger": trades["trigger_reason_code"],
+ "Trigger detail": trades["trigger_reason_detail_code"],
+ "Adjustments": trades["adjustment_reason_codes"].map(_format_adjustments),
+ "Position origin": trades["position_strategy_origin_code"],
+ "Position origin date": trades["position_strategy_origin_timestamp"],
+ "Details": details,
+ "Reference price": trades["reference_price"],
+ "Traded notional": trades["traded_notional"],
+ "Total cost": trades["total_cost"],
+ }
+ )
+ assert list(display_trades.columns) == _TRADE_TABLE_DISPLAY_COLUMNS
st.dataframe(
- trades[display_cols],
+ display_trades,
width="stretch",
height=320,
hide_index=True,
column_config={
- "weight_change": st.column_config.NumberColumn(
+ "Timestamp": st.column_config.DatetimeColumn(
+ "Timestamp", format="YYYY-MM-DD HH:mm"
+ ),
+ "Previous weight": st.column_config.NumberColumn(
+ "Previous weight", format="percent"
+ ),
+ "New weight": st.column_config.NumberColumn("New weight", format="percent"),
+ "Weight change": st.column_config.NumberColumn(
"Weight change", format="percent"
),
- "traded_notional": st.column_config.NumberColumn(
+ "Trigger": st.column_config.TextColumn(
+ "Trigger",
+ help="The single most-upstream event that initiated the "
+ "target change. Blank when reason attribution wasn't "
+ "available for this run (e.g. a walk-forward "
+ "out-of-sample result) or when nothing upstream changed.",
+ ),
+ "Trigger detail": st.column_config.TextColumn(
+ "Trigger detail",
+ help="The strategy's own precise sub-cause for the "
+ "trigger, when identifiable. Blank is not a bug -- "
+ "portfolio_rebalance/volatility_target_adjustment "
+ "legitimately have no sub-cause.",
+ ),
+ "Adjustments": st.column_config.TextColumn(
+ "Adjustments",
+ help="Every downstream layer that modified, delayed, "
+ "redistributed, constrained or forced the executed size "
+ "-- can combine several causes on the same trade (e.g. "
+ "a constraint AND turnover_cap together). Blank means no "
+ "adjustment layer acted on this trade.",
+ ),
+ "Position origin": st.column_config.TextColumn(
+ "Position origin",
+ help="Origin of the currently active strategic regime "
+ "for this symbol/leg -- not necessarily the origin of "
+ "the currently executed weight, nor an execution "
+ "timestamp. Always shown, even when it duplicates "
+ "Trigger. Blank means no strategic position is "
+ "currently active (flat), never a value hidden for "
+ "brevity.",
+ ),
+ "Position origin date": st.column_config.DatetimeColumn(
+ "Position origin date",
+ format="YYYY-MM-DD HH:mm",
+ help="Date of the strategic transition that created the "
+ "currently active regime -- can be much earlier than "
+ "this trade's own date.",
+ ),
+ "Details": st.column_config.TextColumn(
+ "Details",
+ help="Trigger/adjustment free text combined for quick "
+ "reading. The machine-readable columns this is built "
+ "from remain intact in the CSV export below.",
+ ),
+ "Reference price": st.column_config.NumberColumn(
+ "Reference price",
+ format="%.2f",
+ help="The prior-period price used by the backtest to "
+ "value this trade and compute its notional/costs -- not "
+ "a real fill price from an order book.",
+ ),
+ "Traded notional": st.column_config.NumberColumn(
"Traded notional (currency units)", format="localized"
),
- "total_cost": st.column_config.NumberColumn(
+ "Total cost": st.column_config.NumberColumn(
"Total cost (currency units)", format="localized"
),
},
diff --git a/src/quantlab/dashboard/explorer/__init__.py b/src/quantlab/dashboard/explorer/__init__.py
new file mode 100644
index 0000000..5fb7747
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/__init__.py
@@ -0,0 +1,30 @@
+"""Strategy Explorer: the dashboard's per-strategy research/education mode.
+
+The registry in :mod:`quantlab.dashboard.explorer.profile` is this
+package's public extension point: a new strategy gains a gallery card and
+detail page purely by registering a :class:`StrategyProfile` in its own
+``explorer/profiles/.py`` module (mirroring how
+``quantlab.strategies.base.register_strategy`` works for trading logic
+itself) -- nothing in ``app.py``/``cli.py``/``html_report.py`` needs to
+change or name the new strategy.
+"""
+
+from __future__ import annotations
+
+from quantlab.dashboard.explorer.profile import (
+ ParameterDoc,
+ ResultsDiagnostics,
+ StrategyProfile,
+ available_profiles,
+ get_profile,
+ register_profile,
+)
+
+__all__ = [
+ "ParameterDoc",
+ "ResultsDiagnostics",
+ "StrategyProfile",
+ "available_profiles",
+ "get_profile",
+ "register_profile",
+]
diff --git a/src/quantlab/dashboard/explorer/detail.py b/src/quantlab/dashboard/explorer/detail.py
new file mode 100644
index 0000000..0612a05
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/detail.py
@@ -0,0 +1,93 @@
+"""Strategy Explorer detail page: full profile + interactive lab.
+
+Section layout intentionally merges "Mathematical definition" and
+"Signals" into one expander -- every bundled profile documents its signal
+pipeline as part of the same walk-through, so a separate, identically
+worded section would just be a duplicate, not new information.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from quantlab.dashboard.explorer.profile import ParameterDoc
+
+
+def render(st: Any, strategy_name: str) -> None:
+ """Render the detail page for one strategy, or a fallback if unregistered."""
+ from quantlab.dashboard.explorer.profile import get_profile
+
+ if st.button("<- Back to gallery", key="explorer_back"):
+ st.session_state.pop("explorer_strategy", None)
+ st.rerun()
+ return
+
+ profile = get_profile(strategy_name)
+ if profile is None:
+ st.warning(
+ f"No Strategy Explorer content is registered yet for "
+ f"'{strategy_name}'. It is still fully usable in Backtest/"
+ "Walk-forward mode -- only this page's documentation is "
+ "missing."
+ )
+ return
+
+ st.title(profile.display_name)
+ st.caption(profile.category)
+
+ with st.expander("Overview", expanded=True):
+ st.markdown(profile.overview_md)
+ with st.expander("Economic intuition"):
+ st.markdown(profile.economic_intuition_md)
+ with st.expander("Mathematical definition & signals"):
+ st.markdown(profile.mathematical_definition_md)
+ with st.expander("Assumptions"):
+ st.markdown(profile.assumptions_md)
+ with st.expander("Parameters"):
+ _render_parameters(st, profile.parameters)
+ with st.expander("Diagnostics"):
+ st.markdown(profile.diagnostics_md)
+ # A plain `st.expander` still runs its body every rerun even while
+ # collapsed -- this one does real work (data loads, OLS fits, ADF/
+ # cointegration tests, chart builds), so it uses the stateful/lazy
+ # variant (`key` + `on_change="rerun"`) instead: `.open` reports
+ # whether it is actually expanded, and the lab only runs then. Simply
+ # visiting this page (or interacting with any OTHER widget on it) no
+ # longer silently re-triggers the lab's full computation.
+ lab_expander = st.expander(
+ "Interactive laboratory",
+ key=f"explorer_lab_expander_{strategy_name}",
+ on_change="rerun",
+ )
+ if lab_expander.open:
+ with lab_expander:
+ profile.lab(st)
+ with st.expander("Interpretation"):
+ st.markdown(profile.interpretation_md)
+ with st.expander("Limitations & failure modes"):
+ st.markdown(profile.limitations_md)
+ if profile.references_md:
+ with st.expander("References / Further reading"):
+ st.markdown(profile.references_md)
+
+
+def _render_parameters(st: Any, parameters: list[ParameterDoc]) -> None:
+ if not parameters:
+ st.caption("This strategy has no configurable parameters.")
+ return
+ for index, parameter in enumerate(parameters):
+ st.markdown(f"**`{parameter.name}`** -- default: `{parameter.default}`")
+ st.markdown(
+ f"- **What**: {parameter.what}\n"
+ f"- **Where**: {parameter.where}\n"
+ f"- **Why**: {parameter.why}\n"
+ f"- **Typical range**: {parameter.typical_range}\n"
+ f"- **Increasing it**: {parameter.effect_increase}\n"
+ f"- **Decreasing it**: {parameter.effect_decrease}\n"
+ f"- **Trade-offs**: {parameter.tradeoffs}"
+ )
+ if parameter.interactions:
+ st.markdown(f"- **Interactions**: {parameter.interactions}")
+ if index < len(parameters) - 1:
+ st.divider()
diff --git a/src/quantlab/dashboard/explorer/gallery.py b/src/quantlab/dashboard/explorer/gallery.py
new file mode 100644
index 0000000..df2805d
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/gallery.py
@@ -0,0 +1,83 @@
+"""Strategy Explorer gallery: a card per registered strategy.
+
+Always driven by ``available_strategies()``, never a hard-coded list -- a
+strategy without a registered profile still gets a card (with a
+"documentation coming soon" placeholder) instead of silently disappearing.
+
+Every card is the same fixed height (so a short overview and a long one
+produce identically-sized cards), with a visible "Open" button that
+navigates to that strategy's detail page. An earlier design instead
+stretched an invisible ``st.button`` to cover the whole card via absolute
+positioning -- unreliable across Streamlit versions/themes since it
+depended on the exact DOM structure of Streamlit-internal containers, not
+a documented API -- a plain, visible button is simpler and actually works.
+
+The fixed height is applied via this CSS, NOT ``st.container``'s own
+``height=`` parameter: that parameter always renders as a scrollable region
+(``overflow-y: auto``) regardless of whether the content actually overflows,
+which is exactly the stray scrollbar this design avoids -- ``overflow:
+hidden`` here clips instead of scrolling (the truncated summary text below
+is already sized to fit, so clipping is not expected to ever trigger in
+practice).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+_CARD_HEIGHT = 230
+_SUMMARY_CHAR_LIMIT = 150
+
+_CARD_HEIGHT_CSS = f"""
+
+"""
+
+
+def _truncate(text: str, limit: int) -> str:
+ """Truncate on a word boundary.
+
+ Keeps every card's summary the same rough length regardless of how
+ long that strategy's overview is.
+ """
+ text = text.strip()
+ if len(text) <= limit:
+ return text
+ return text[:limit].rsplit(" ", 1)[0].rstrip(",.;:-") + "..."
+
+
+def render(st: Any) -> None:
+ """Render the gallery of strategy cards."""
+ from quantlab.dashboard.explorer.profile import get_profile
+ from quantlab.strategies.base import available_strategies
+
+ st.subheader("Strategies")
+ st.caption(
+ "Pick a strategy to explore its economics, mathematics, "
+ "assumptions, diagnostics and an interactive research lab."
+ )
+ st.html(_CARD_HEIGHT_CSS)
+ strategies = available_strategies()
+ columns = st.columns(3)
+ for index, name in enumerate(strategies):
+ profile = get_profile(name)
+ column = columns[index % 3]
+ with (
+ column,
+ st.container(key=f"explorer_card_{name}", border=True),
+ ):
+ if profile is not None:
+ st.markdown(f"##### {profile.display_name}")
+ st.caption(profile.category)
+ summary = profile.overview_md.strip().split("\n\n")[0]
+ st.write(_truncate(summary, _SUMMARY_CHAR_LIMIT))
+ else:
+ st.markdown(f"##### {name}")
+ st.caption("Documentation coming soon.")
+ if st.button("Open", key=f"explorer_open_{name}", width="stretch"):
+ st.session_state["explorer_strategy"] = name
+ st.rerun()
diff --git a/src/quantlab/dashboard/explorer/labs/__init__.py b/src/quantlab/dashboard/explorer/labs/__init__.py
new file mode 100644
index 0000000..a18d279
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/labs/__init__.py
@@ -0,0 +1,3 @@
+"""Per-strategy interactive lab render functions, referenced by profiles."""
+
+from __future__ import annotations
diff --git a/src/quantlab/dashboard/explorer/labs/buy_and_hold.py b/src/quantlab/dashboard/explorer/labs/buy_and_hold.py
new file mode 100644
index 0000000..e4f3cee
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/labs/buy_and_hold.py
@@ -0,0 +1,114 @@
+"""Interactive Buy & Hold lab.
+
+Price explorer, plus the strategy's own counter-intuitive point: a
+"buy and hold" *signal* does not, in a real portfolio held at fixed share
+counts, mean *static weights* once more than one asset is involved -- this
+lab illustrates that theoretical drift. QuantLab's own accounting engine
+does NOT currently reproduce it (see the caption near the chart below and
+docs/limitations.md's "Rebalancing is a step function" note): it holds
+weights constant between rebalance dates by construction, a deliberate
+vectorised-backtest simplification, not (yet) a price-driven recomputation.
+"""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any
+
+from quantlab.logging_config import get_logger
+
+logger = get_logger(__name__)
+
+
+def render(st: Any) -> None:
+ """Render the Buy & Hold interactive lab."""
+ from quantlab.dashboard.explorer.shared_components import (
+ load_explorer_prices_cached,
+ render_price_chart,
+ render_symbol_and_source_picker,
+ )
+ from quantlab.dashboard.state import default_end_date
+ from quantlab.data.base import price_matrix
+
+ st.markdown("#### Price explorer")
+ picker_result = render_symbol_and_source_picker(
+ st,
+ key_prefix="explorer_bh",
+ default_symbols=("SPY", "QQQ"),
+ )
+ if picker_result is None:
+ st.info("Pick at least one symbol above to continue.")
+ return
+ symbols, source, calendar, use_bundled_demo_data = picker_result
+ col_start, col_end = st.columns(2)
+ start_date = col_start.date_input(
+ "Start date", value=date(2019, 1, 1), key="explorer_bh_start"
+ )
+ end_date = col_end.date_input(
+ "End date", value=default_end_date(), key="explorer_bh_end"
+ )
+
+ try:
+ data = load_explorer_prices_cached(
+ st,
+ symbols,
+ source=source,
+ calendar=calendar,
+ start_date=start_date,
+ end_date=end_date,
+ use_bundled_demo_data=use_bundled_demo_data,
+ )
+ except Exception as exc:
+ logger.exception("Buy & Hold lab: could not load data")
+ st.error(f"Could not load data: {exc}")
+ return
+ prices = price_matrix(data)
+ available = [symbol for symbol in symbols if symbol in prices.columns]
+ if not available:
+ st.warning("None of the selected symbols have data in this range.")
+ return
+ render_price_chart(
+ st, {symbol: prices[symbol] for symbol in available}, title="Price"
+ )
+
+ if len(available) < 2:
+ st.info(
+ "Pick a second symbol below to see why 'always invested' does "
+ "not mean 'static weights' once more than one asset is held."
+ )
+ return
+
+ st.markdown("#### Why weights would drift even though the signal never changes")
+ st.caption(
+ "buy_and_hold's own signal is simply 'invested wherever price data "
+ "exists' -- it never rebalances by itself. In a REAL portfolio held "
+ "at fixed share counts, each asset's OWN return would move its "
+ "share of the total value, so realized weights would drift away "
+ "from equal (or whatever the initial split was) purely from price "
+ "divergence, well before any portfolio-level rebalance schedule "
+ "intervenes."
+ )
+ initial_weight = 1.0 / len(available)
+ normalized = prices[available] / prices[available].iloc[0]
+ drifted_value = normalized * initial_weight
+ drifted_weights = drifted_value.div(drifted_value.sum(axis=1), axis=0)
+ render_price_chart(
+ st,
+ {symbol: drifted_weights[symbol] for symbol in available},
+ title="Theoretical weight drift with no rebalancing (starting "
+ f"equal at {initial_weight:.0%} each) -- illustrative, not what "
+ "QuantLab's accounting currently reproduces",
+ yaxis_title="Weight",
+ )
+ st.caption(
+ "**This chart is a theoretical illustration of share-count drift, "
+ "not a preview of a QuantLab backtest.** QuantLab's own accounting "
+ "engine currently holds weights CONSTANT between rebalance dates "
+ "by construction (a deliberate vectorised-backtest simplification "
+ "-- see docs/limitations.md, 'Rebalancing is a step function'), so "
+ "it does not (yet) reproduce the drift shown above. In a real "
+ "portfolio, the configured `rebalance_frequency` would periodically "
+ "reset this drift back toward target -- less often means more "
+ "drift between resets, more often means closer to the target split "
+ "but more turnover/costs."
+ )
diff --git a/src/quantlab/dashboard/explorer/labs/cross_sectional_momentum.py b/src/quantlab/dashboard/explorer/labs/cross_sectional_momentum.py
new file mode 100644
index 0000000..7aa871f
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/labs/cross_sectional_momentum.py
@@ -0,0 +1,305 @@
+"""Interactive Cross-Sectional Momentum lab.
+
+Momentum formation -> asset ranking on a chosen date -> momentum
+persistence -> parameter comparison. Uses the exact same
+``quantlab.features.momentum``/``cross_sectional`` functions
+``CrossSectionalMomentumStrategy`` itself calls.
+"""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any
+
+import pandas as pd
+
+from quantlab.logging_config import get_logger
+
+logger = get_logger(__name__)
+
+
+def render(st: Any) -> None:
+ """Render the Cross-Sectional Momentum interactive lab."""
+ from quantlab.dashboard.explorer.shared_components import (
+ load_explorer_prices_cached,
+ render_price_chart,
+ render_symbol_and_source_picker,
+ strong,
+ )
+ from quantlab.dashboard.state import default_end_date
+ from quantlab.data.base import price_matrix
+ from quantlab.features.cross_sectional import select_top_bottom
+ from quantlab.features.momentum import (
+ cross_sectional_momentum_persistence,
+ momentum,
+ momentum_persistence,
+ )
+
+ st.markdown("#### Universe and momentum formation")
+ picker_result = render_symbol_and_source_picker(
+ st,
+ key_prefix="explorer_csmom",
+ default_symbols=("SPY", "QQQ", "TLT", "GLD"),
+ )
+ if picker_result is None:
+ st.info("Pick at least two symbols above to continue.")
+ return
+ symbols, source, calendar, use_bundled_demo_data = picker_result
+ col_start, col_end = st.columns(2)
+ start_date = col_start.date_input(
+ "Start date", value=date(2019, 1, 1), key="explorer_csmom_start"
+ )
+ end_date = col_end.date_input(
+ "End date", value=default_end_date(), key="explorer_csmom_end"
+ )
+ if len(symbols) < 2:
+ st.info("Pick at least two symbols above to continue.")
+ return
+
+ try:
+ data = load_explorer_prices_cached(
+ st,
+ symbols,
+ source=source,
+ calendar=calendar,
+ start_date=start_date,
+ end_date=end_date,
+ use_bundled_demo_data=use_bundled_demo_data,
+ )
+ except Exception as exc:
+ logger.exception("Cross-Sectional Momentum lab: could not load data")
+ st.error(f"Could not load data: {exc}")
+ return
+ prices = price_matrix(data)
+ available = [symbol for symbol in symbols if symbol in prices.columns]
+ if len(available) < 2:
+ st.warning("Fewer than two selected symbols have data in this range.")
+ return
+ prices = prices[available]
+
+ col_look, col_skip = st.columns(2)
+ lookback = col_look.slider(
+ "lookback_period", 21, 504, 252, key="explorer_csmom_lookback"
+ )
+ skip = col_skip.slider(
+ "skip_period", 0, min(63, lookback - 1), 21, key="explorer_csmom_skip"
+ )
+ scores = momentum(prices, lookback, skip)
+ render_price_chart(
+ st,
+ {symbol: scores[symbol] for symbol in available},
+ title="Momentum score per symbol",
+ yaxis_title="Momentum score",
+ )
+
+ st.markdown("#### Asset ranking on a chosen date")
+ valid_dates = scores.dropna(how="all").index
+ if len(valid_dates) == 0:
+ st.info("No date has a defined momentum score yet -- widen the range.")
+ return
+ chosen_date = st.select_slider(
+ "Date",
+ options=list(valid_dates),
+ value=valid_dates[-1],
+ key="explorer_csmom_date",
+ format_func=lambda d: d.strftime("%Y-%m-%d"),
+ )
+ col_top, col_short, col_bottom = st.columns(3)
+ top_fraction = col_top.slider(
+ "top_fraction", 0.1, 1.0, 0.25, 0.05, key="explorer_csmom_top"
+ )
+ long_short = col_short.checkbox(
+ "long_short (also short the bottom fraction)",
+ value=False,
+ key="explorer_csmom_long_short",
+ )
+ bottom_fraction = (
+ col_bottom.slider(
+ "bottom_fraction",
+ 0.0,
+ 1.0 - top_fraction,
+ min(0.25, 1.0 - top_fraction),
+ 0.05,
+ key="explorer_csmom_bottom",
+ )
+ if long_short
+ else 0.0
+ )
+ signal_scaling = st.selectbox(
+ "signal_scaling",
+ ["binary", "continuous"],
+ key="explorer_csmom_signal_scaling",
+ help="binary weights every selected asset identically. continuous "
+ "weights each selected asset by its RANK within its own selected "
+ "leg, divided by that leg's own selected count -- illustrated on "
+ "the stop-loss/take-profit chart below via the strategy's real "
+ "generate_signals().",
+ )
+ row = scores.loc[[chosen_date]]
+ selection = select_top_bottom(row, top_fraction, bottom_fraction)
+ ranking = pd.DataFrame(
+ {
+ "Momentum score": row.iloc[0],
+ "Rank": row.iloc[0].rank(ascending=False),
+ "Selected": selection.iloc[0].map({1.0: "top", 0.0: "no", -1.0: "bottom"}),
+ }
+ ).sort_values("Rank")
+ st.dataframe(ranking, width="stretch")
+
+ st.markdown("#### Cross-sectional momentum persistence")
+ st.caption(
+ "The question this strategy actually trades: on each date, do "
+ "assets ranked higher on momentum go on to earn higher subsequent "
+ "returns than assets ranked lower, RELATIVE TO EACH OTHER? A "
+ "single asset's own serial correlation (see the time-series "
+ "diagnostic below) is neither necessary nor sufficient for this. "
+ "When long_short is disabled, the bottom group below is a research "
+ "comparison only -- it is not a short book held by the strategy."
+ )
+ holding_period = st.slider(
+ "holding_period (for the future return)",
+ 1,
+ 126,
+ 21,
+ key="explorer_csmom_holding",
+ )
+ # When long_short is disabled, top_fraction alone can still legitimately
+ # reach 1.0 (a valid strategy configuration -- select the whole universe
+ # as "top"). Reusing it verbatim as the comparison bottom fraction would
+ # then push top_fraction + bottom_fraction past 1 and make
+ # select_top_bottom() raise. comparison_bottom_fraction is capped to
+ # what actually fits, and exists purely for this diagnostic comparison
+ # -- it never governs a real short book (see the caption above).
+ comparison_bottom_fraction = min(top_fraction, max(0.0, 1.0 - top_fraction))
+ effective_bottom = bottom_fraction if long_short else comparison_bottom_fraction
+ persistence = cross_sectional_momentum_persistence(
+ prices,
+ lookback,
+ skip,
+ holding_period,
+ top_fraction=top_fraction,
+ bottom_fraction=effective_bottom,
+ )
+ if persistence.empty:
+ st.info(
+ "Not enough dates with at least 3 scored assets to compute "
+ "cross-sectional persistence yet -- widen the date range, "
+ "shorten lookback_period, or add more symbols to the universe."
+ )
+ else:
+ render_price_chart(
+ st,
+ {"Rank correlation": persistence["rank_correlation"]},
+ title="Spearman rank correlation: momentum score vs. subsequent "
+ "return, across the universe",
+ yaxis_title="Rank correlation",
+ )
+ render_price_chart(
+ st,
+ {"Top - bottom spread return": persistence["top_minus_bottom"]},
+ title=f"Realized top({top_fraction:.0%}) minus bottom"
+ f"({effective_bottom:.0%}) {holding_period}-period return",
+ yaxis_title="Return",
+ )
+ mean_corr = persistence["rank_correlation"].mean()
+ mean_spread = persistence["top_minus_bottom"].mean()
+ st.caption(
+ f"Mean rank correlation over this sample: {strong(f'{mean_corr:.3f}')}. "
+ f"Mean top-minus-bottom spread: {strong(f'{mean_spread:.3%}')}. "
+ "Descriptive sample evidence, not a hypothesis test -- overlapping "
+ "holding periods across consecutive dates are not independent "
+ "observations.",
+ unsafe_allow_html=True,
+ )
+
+ st.markdown("#### Time-series diagnostic (for comparison)")
+ st.caption(
+ "A single asset's own past-momentum-vs-future-return relationship "
+ "-- this is the TIME-SERIES momentum question (see the Time-Series "
+ "Momentum strategy page), not what cross-sectional momentum "
+ "actually trades. Shown here only as a point of comparison."
+ )
+ persistence_symbol = st.selectbox(
+ "Symbol", available, key="explorer_csmom_persist_symbol"
+ )
+ paired = momentum_persistence(
+ prices[persistence_symbol], lookback, skip, holding_period
+ )
+ if paired.empty:
+ st.info("Not enough history to pair momentum with a future return yet.")
+ else:
+ import plotly.graph_objects as go
+
+ fig = go.Figure(
+ go.Scatter(
+ x=paired["past_momentum"],
+ y=paired["future_return"],
+ mode="markers",
+ marker={"size": 5, "opacity": 0.5},
+ )
+ )
+ fig.update_layout(
+ title=f"{persistence_symbol}: past momentum vs. subsequent "
+ f"{holding_period}-period return (time-series diagnostic)",
+ xaxis_title="Past momentum score",
+ yaxis_title="Future return",
+ height=380,
+ )
+ st.plotly_chart(fig, width="stretch")
+ correlation = paired["past_momentum"].corr(paired["future_return"])
+ st.caption(
+ f"Correlation between the two columns above: "
+ f"{strong(f'{correlation:.3f}')}.",
+ unsafe_allow_html=True,
+ )
+
+ st.markdown("#### Parameter comparison")
+ compare_symbol = st.selectbox(
+ "Symbol", available, key="explorer_csmom_compare_symbol"
+ )
+ lookbacks_to_compare = st.multiselect(
+ "lookback_period values to compare",
+ options=[63, 126, 189, 252, 378, 504],
+ default=[126, 252, 504],
+ key="explorer_csmom_compare_lookbacks",
+ )
+ if lookbacks_to_compare:
+ series = {
+ f"lookback={lb}": momentum(prices[compare_symbol], lb, skip)
+ for lb in lookbacks_to_compare
+ }
+ render_price_chart(
+ st,
+ series,
+ title=f"{compare_symbol}: momentum score at different lookbacks",
+ yaxis_title="Momentum score",
+ )
+
+ try:
+ from quantlab.dashboard.explorer.shared_components import (
+ render_stop_loss_take_profit_illustration,
+ )
+ from quantlab.strategies.momentum import CrossSectionalMomentumStrategy
+
+ cs_strategy = CrossSectionalMomentumStrategy(
+ lookback_period=lookback,
+ skip_period=skip,
+ top_fraction=top_fraction,
+ bottom_fraction=bottom_fraction,
+ long_short=long_short,
+ signal_scaling=signal_scaling,
+ )
+ cs_signals = cs_strategy.generate_signals(data)
+ except Exception as exc:
+ logger.exception(
+ "Cross-Sectional Momentum lab: could not compute signals for "
+ "the stop-loss/take-profit illustration"
+ )
+ st.error(f"Could not compute the selection signal for this illustration: {exc}")
+ else:
+ render_stop_loss_take_profit_illustration(
+ st,
+ cs_signals[compare_symbol],
+ prices[compare_symbol],
+ key_prefix="explorer_csmom",
+ )
diff --git a/src/quantlab/dashboard/explorer/labs/mean_reversion.py b/src/quantlab/dashboard/explorer/labs/mean_reversion.py
new file mode 100644
index 0000000..8ca8986
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/labs/mean_reversion.py
@@ -0,0 +1,318 @@
+"""Interactive Mean Reversion lab.
+
+Price explorer -> indicator comparison (the three primary indicators
+`MeanReversionStrategy` offers in the main UI, on the SAME data) -> the
+real backtestable state machine (calling `MeanReversionStrategy` directly,
+for whichever `indicator` is selected) -> stationarity diagnostics (ADF,
+half-life, Hurst). Every indicator call is the exact function
+`MeanReversionStrategy` itself uses -- nothing here is a second
+implementation.
+"""
+
+from __future__ import annotations
+
+import math
+from datetime import date
+from typing import Any
+
+from quantlab.logging_config import get_logger
+
+logger = get_logger(__name__)
+
+
+def render(st: Any) -> None:
+ """Render the Mean Reversion interactive lab (see module docstring)."""
+ from quantlab.dashboard.explorer.shared_components import (
+ centered_indicator_threshold_overlay,
+ load_explorer_prices_cached,
+ render_price_chart,
+ render_stationarity_card,
+ render_stop_loss_take_profit_illustration,
+ render_symbol_and_source_picker,
+ strong,
+ )
+ from quantlab.dashboard.state import default_end_date
+ from quantlab.data.base import price_matrix
+ from quantlab.features.mean_reversion import (
+ half_life,
+ rolling_percentile_rank,
+ rolling_zscore,
+ rsi,
+ )
+ from quantlab.features.stationarity import adf_test, hurst_exponent
+
+ st.markdown("#### Price explorer")
+ picker_result = render_symbol_and_source_picker(
+ st,
+ key_prefix="explorer_mr",
+ default_symbols=("SPY", "QQQ", "TLT", "GLD"),
+ )
+ if picker_result is None:
+ st.info("Pick at least one symbol above to load price data.")
+ return
+ symbols, source, calendar, use_bundled_demo_data = picker_result
+ symbol = st.selectbox("Symbol to analyze", symbols, key="explorer_mr_symbol")
+ col_start, col_end = st.columns(2)
+ start_date = col_start.date_input(
+ "Start date", value=date(2019, 1, 1), key="explorer_mr_start"
+ )
+ end_date = col_end.date_input(
+ "End date", value=default_end_date(), key="explorer_mr_end"
+ )
+
+ try:
+ data = load_explorer_prices_cached(
+ st,
+ symbols,
+ source=source,
+ calendar=calendar,
+ start_date=start_date,
+ end_date=end_date,
+ use_bundled_demo_data=use_bundled_demo_data,
+ )
+ except Exception as exc:
+ logger.exception("Mean Reversion lab: could not load data for %s", symbol)
+ st.error(f"Could not load data for {symbol}: {exc}")
+ return
+ prices_frame = price_matrix(data)
+ if symbol not in prices_frame.columns:
+ st.warning(f"No data for {symbol} in this range.")
+ return
+ prices = prices_frame[symbol]
+ render_price_chart(st, {symbol: prices}, title=f"{symbol} price")
+
+ st.markdown("#### Indicator comparison")
+ st.caption(
+ "Three different ways of asking 'how far from normal is this price "
+ "right now' -- the three primary indicators `MeanReversionStrategy` "
+ "offers via `indicator` -- compared on the same data. (Two further "
+ "indicators, Bollinger Bands and distance-to-MA, are also "
+ "implemented and usable programmatically -- see the Mathematical "
+ "definition below -- and not shown here: Bollinger's %B is a close "
+ "affine variant of the rolling z-score, while distance-to-MA "
+ "normalizes by price level rather than volatility and can diverge "
+ "from the z-score materially.)"
+ )
+ col_rsi, col_z, col_pct = st.columns(3)
+ use_rsi = col_rsi.checkbox("RSI", value=True, key="explorer_mr_use_rsi")
+ use_zscore = col_z.checkbox(
+ "Rolling z-score", value=True, key="explorer_mr_use_zscore"
+ )
+ use_percentile = col_pct.checkbox(
+ "Percentile rank", value=True, key="explorer_mr_use_percentile"
+ )
+
+ if use_rsi:
+ rsi_window = st.slider("RSI window", 2, 60, 14, key="explorer_mr_rsi_window")
+ render_price_chart(
+ st,
+ {"RSI": rsi(prices, rsi_window)},
+ title="RSI (30/70 conventionally mark oversold/overbought)",
+ yaxis_title="RSI",
+ )
+
+ if use_zscore:
+ z_window = st.slider(
+ "Z-score window", 2, 200, 20, key="explorer_mr_zscore_window"
+ )
+ zscore = rolling_zscore(prices, z_window)
+ render_price_chart(
+ st,
+ {"Z-score": zscore},
+ title="Rolling z-score (this is exactly what MeanReversionStrategy "
+ "trades when indicator='zscore', its default -- see the State "
+ "machine section below for entry/exit/stop thresholds overlaid "
+ "on whichever indicator is selected there)",
+ yaxis_title="Z-score",
+ )
+
+ if use_percentile:
+ pct_window = st.slider(
+ "Percentile window", 5, 200, 20, key="explorer_mr_pct_window"
+ )
+ percentile = rolling_percentile_rank(prices, pct_window)
+ render_price_chart(
+ st,
+ {"Percentile rank": percentile},
+ title="Trailing percentile rank (0 = lowest in window, "
+ "1 = highest, 0.5 = middle)",
+ yaxis_title="Percentile",
+ )
+
+ st.markdown("#### State machine (indicator / entry / exit / stop / long_only)")
+ st.caption(
+ "The strategy state emitted by `MeanReversionStrategy` on this "
+ "data for the parameters below -- computed by calling the real "
+ "strategy class directly, for whichever indicator is selected. It "
+ "is still a signal: allocator, constraints, rebalancing and "
+ "execution determine the final portfolio weight."
+ )
+ from quantlab.dashboard.components import (
+ entry_threshold_bounds,
+ exit_threshold_bounds,
+ mean_reversion_slider_bounds,
+ )
+ from quantlab.strategies.mean_reversion import UI_INDICATORS
+
+ sm_indicator = st.selectbox(
+ "indicator",
+ list(UI_INDICATORS),
+ key="explorer_mr_sm_indicator",
+ help="Selecting a different indicator resets the thresholds below "
+ "to that indicator's own defaults -- a threshold tuned for one "
+ "indicator's scale is not meaningful on another's.",
+ )
+ (
+ entry_min,
+ entry_max,
+ entry_default,
+ exit_default,
+ stop_max,
+ stop_default,
+ step,
+ ) = mean_reversion_slider_bounds(sm_indicator)
+ # Asked BEFORE the entry slider (not after) so entry's own bounds can
+ # already know whether the stop slider will even be rendered -- see
+ # entry_threshold_bounds's own docstring.
+ sm_use_stop = st.checkbox(
+ "stop_threshold enabled",
+ value=True,
+ key=f"explorer_mr_sm_use_stop_{sm_indicator}",
+ )
+ entry_min, entry_max = entry_threshold_bounds(
+ entry_min, entry_max, stop_max, step, stop_enabled=sm_use_stop
+ )
+ entry_default = min(max(entry_default, entry_min), entry_max)
+
+ col_entry, col_exit = st.columns(2)
+ sm_entry = col_entry.slider(
+ "entry_threshold",
+ entry_min,
+ entry_max,
+ entry_default,
+ step,
+ key=f"explorer_mr_sm_entry_{sm_indicator}",
+ )
+ sm_exit_bounds = exit_threshold_bounds(sm_entry, step)
+ if sm_exit_bounds is None:
+ col_exit.caption(
+ "exit_threshold: 0.0 (the only value possible this close to zero)"
+ )
+ sm_exit = 0.0
+ else:
+ sm_exit_min, sm_exit_max = sm_exit_bounds
+ sm_exit = col_exit.slider(
+ "exit_threshold",
+ sm_exit_min,
+ sm_exit_max,
+ min(exit_default, sm_exit_max),
+ step,
+ key=f"explorer_mr_sm_exit_{sm_indicator}",
+ )
+ sm_stop = (
+ st.slider(
+ "stop_threshold (limits the indicator's own deviation "
+ "tolerated, not the realized monetary loss)",
+ sm_entry + step,
+ stop_max,
+ max(stop_default, sm_entry + step),
+ step,
+ key=f"explorer_mr_sm_stop_{sm_indicator}",
+ )
+ if sm_use_stop
+ else None
+ )
+ sm_long_only = st.checkbox(
+ "long_only (short entries never trigger when True)",
+ value=True,
+ key="explorer_mr_sm_long_only",
+ )
+ sm_lookback = st.slider(
+ "lookback_period (for this state machine)",
+ 2,
+ 200,
+ 20,
+ key="explorer_mr_sm_lookback",
+ )
+ try:
+ from quantlab.strategies.mean_reversion import (
+ MeanReversionStrategy,
+ _centered_indicator,
+ )
+
+ strategy = MeanReversionStrategy(
+ lookback_period=sm_lookback,
+ indicator=sm_indicator,
+ entry_threshold=sm_entry,
+ exit_threshold=sm_exit,
+ stop_threshold=sm_stop,
+ long_only=sm_long_only,
+ )
+ state = strategy.generate_signals(data)[symbol]
+ indicator = _centered_indicator(
+ prices_frame[[symbol]], sm_indicator, sm_lookback, 2.0
+ )[symbol]
+ except Exception as exc:
+ logger.exception("Mean Reversion lab: could not compute the state machine")
+ st.error(f"Could not compute the state machine for these parameters: {exc}")
+ else:
+ threshold_series, line_colors = centered_indicator_threshold_overlay(
+ indicator,
+ f"{sm_indicator} indicator",
+ entry_threshold=sm_entry,
+ exit_threshold=sm_exit,
+ stop_threshold=sm_stop,
+ )
+ render_price_chart(
+ st,
+ threshold_series,
+ title=f"Centered '{sm_indicator}' indicator with entry/exit/stop "
+ "thresholds",
+ yaxis_title="Centered indicator",
+ colors=line_colors,
+ )
+ render_price_chart(
+ st,
+ {"Position (state)": state},
+ title="MeanReversionStrategy state signal for these parameters",
+ yaxis_title="Signal state",
+ )
+ time_in_position = float((state != 0.0).mean())
+ stop_text = "disabled" if sm_stop is None else f"at {sm_stop:g}"
+ st.caption(
+ f"Time in position: {strong(f'{time_in_position:.1%}')} of bars. "
+ f"{'Long-only' if sm_long_only else 'Long/short'} -- "
+ f"stop_threshold {strong(stop_text)}.",
+ unsafe_allow_html=True,
+ )
+ render_stop_loss_take_profit_illustration(
+ st, state, prices, key_prefix="explorer_mr"
+ )
+
+ st.markdown("#### Stationarity tests")
+ st.caption(
+ "Is this sample consistent with mean reversion, or does the selected "
+ "test fail to reject a unit-root model? The answer depends on the "
+ "sample and test specification; it does not validate profitability."
+ )
+ adf_window = st.slider(
+ "Test on the trailing N periods", 30, 1000, 252, key="explorer_mr_adf_window"
+ )
+ tested_series = prices.dropna().iloc[-adf_window:]
+ render_stationarity_card(
+ st, adf_test(tested_series), label=f"ADF (last {adf_window} periods)"
+ )
+ hl = half_life(tested_series)
+ hl_text = f"{hl:.1f} periods" if math.isfinite(hl) else "no finite estimate"
+ hurst = hurst_exponent(tested_series)
+ hurst_text = f"{hurst:.3f}" if math.isfinite(hurst) else "n/a (too little data)"
+ col_hl, col_hurst = st.columns(2)
+ col_hl.metric("Half-life", hl_text)
+ col_hurst.metric("Hurst exponent", hurst_text)
+ st.caption(
+ "H < 0.5 indicates anti-persistence under this estimator and "
+ "sample (~0.5 a random walk, > 0.5 a trending/persistent series). "
+ "It is neither a stationarity test nor proof of exploitable mean "
+ "reversion -- a descriptive estimate on this sample, not a "
+ "hypothesis test."
+ )
diff --git a/src/quantlab/dashboard/explorer/labs/pairs_trading.py b/src/quantlab/dashboard/explorer/labs/pairs_trading.py
new file mode 100644
index 0000000..371cb1b
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/labs/pairs_trading.py
@@ -0,0 +1,410 @@
+"""Interactive Pairs Trading lab.
+
+Workflow: universe selection -> correlation screening -> pair inspection ->
+hedge ratio -> spread -> stationarity/cointegration -> mean-reversion
+characteristics -> trading thresholds. Every number here comes from
+``quantlab.features.pairs_diagnostics.compute_pair_diagnostics`` -- the
+exact function the Results tab and the HTML report also use, so this lab
+shows the same hedge ratio and ADF p-value as a real backtest of the same
+pair whenever the data range, symbols, price type and parameters match
+(this lab's own controls let a user explore different ones on purpose).
+"""
+
+from __future__ import annotations
+
+import math
+from datetime import date
+from typing import Any
+
+import pandas as pd
+
+from quantlab.logging_config import get_logger
+
+logger = get_logger(__name__)
+
+
+def render(st: Any) -> None:
+ """Render the Pairs Trading interactive lab (see module docstring)."""
+ from quantlab.dashboard.explorer.shared_components import (
+ VIABLE_ENTRY_MARKER_COLOR,
+ centered_indicator_threshold_overlay,
+ load_explorer_prices_cached,
+ render_correlation_matrix,
+ render_price_chart,
+ render_stationarity_card,
+ render_symbol_and_source_picker,
+ strong,
+ )
+ from quantlab.dashboard.state import default_end_date
+ from quantlab.data.base import price_matrix
+ from quantlab.features.correlation import correlation_matrix
+ from quantlab.features.pairs_diagnostics import compute_pair_diagnostics
+
+ st.markdown("#### 1. Universe selection")
+ picker_result = render_symbol_and_source_picker(
+ st,
+ key_prefix="explorer_pairs",
+ default_symbols=("SPY", "QQQ", "TLT", "GLD"),
+ )
+ if picker_result is None:
+ st.info("Pick at least two symbols above to continue.")
+ return
+ symbols, source, calendar, use_bundled_demo_data = picker_result
+ col_start, col_end = st.columns(2)
+ start_date = col_start.date_input(
+ "Start date", value=date(2019, 1, 1), key="explorer_pairs_start"
+ )
+ end_date = col_end.date_input(
+ "End date", value=default_end_date(), key="explorer_pairs_end"
+ )
+
+ if len(symbols) < 2:
+ st.info("Pick at least two symbols above to continue.")
+ return
+
+ try:
+ data = load_explorer_prices_cached(
+ st,
+ symbols,
+ source=source,
+ calendar=calendar,
+ start_date=start_date,
+ end_date=end_date,
+ use_bundled_demo_data=use_bundled_demo_data,
+ )
+ except Exception as exc:
+ logger.exception("Pairs Trading lab: could not load data for %s", symbols)
+ st.error(f"Could not load data for {symbols}: {exc}")
+ return
+ prices = price_matrix(data)
+ available = [symbol for symbol in symbols if symbol in prices.columns]
+ if len(available) < 2:
+ st.warning(
+ "Fewer than two of the selected symbols have data in this range "
+ "-- widen the date range or pick different symbols."
+ )
+ return
+
+ st.markdown("#### 2. Correlation analysis")
+ st.caption(
+ "Correlation alone is not sufficient for pairs trading: two assets "
+ "can be highly correlated in returns yet never form a stable, "
+ "tradable spread. Use this to screen candidates, not to pick a pair."
+ )
+ render_correlation_matrix(st, correlation_matrix(prices[available]))
+
+ st.markdown("#### 3. Pair inspection")
+ col_a, col_b = st.columns(2)
+ symbol_a = col_a.selectbox("Symbol A", available, key="explorer_pairs_a")
+ remaining = [symbol for symbol in available if symbol != symbol_a] or available
+ symbol_b = col_b.selectbox("Symbol B", remaining, key="explorer_pairs_b")
+ normalized = prices[[symbol_a, symbol_b]] / prices[[symbol_a, symbol_b]].iloc[0]
+ render_price_chart(
+ st,
+ {symbol_a: normalized[symbol_a], symbol_b: normalized[symbol_b]},
+ title="Normalized prices (both start at 1.0)",
+ yaxis_title="Normalized level",
+ )
+
+ st.markdown("#### 4-5. Hedge ratio and spread")
+ st.caption(
+ "The hedge ratio (beta) comes from a trailing OLS fit of A on B "
+ "over `formation_window`; the spread is A minus that fitted line. "
+ "The sliders below default smaller than PairsTradingStrategy's own "
+ "defaults (formation_window=252, indicator_window=63) so this lab "
+ "produces a usable spread on the shorter bundled offline demo "
+ "date ranges -- widen them to match a real backtest's config."
+ )
+ formation_window = st.slider(
+ "formation_window (periods)", 20, 500, 100, key="explorer_pairs_formation"
+ )
+ indicator_window = st.slider(
+ "indicator_window (periods)", 5, 200, 20, key="explorer_pairs_indicator_window"
+ )
+ dynamic = st.checkbox(
+ "dynamic_hedge_ratio (refit the OLS every period vs. once at formation)",
+ value=True,
+ key="explorer_pairs_dynamic",
+ )
+
+ try:
+ diagnostics = compute_pair_diagnostics(
+ prices,
+ symbol_a,
+ symbol_b,
+ formation_window=formation_window,
+ indicator_window=indicator_window,
+ dynamic_hedge_ratio=dynamic,
+ )
+ except Exception as exc:
+ logger.exception(
+ "Pairs Trading lab: could not compute diagnostics for %s/%s",
+ symbol_a,
+ symbol_b,
+ )
+ st.error(f"Could not compute diagnostics for {symbol_a}/{symbol_b}: {exc}")
+ return
+
+ render_price_chart(
+ st,
+ {"Hedge ratio (beta)": diagnostics.hedge_ratio},
+ title="Rolling hedge ratio",
+ yaxis_title="Beta",
+ )
+ render_price_chart(
+ st,
+ {"Spread": diagnostics.spread},
+ title=f"{symbol_a}/{symbol_b} spread (A - intercept - beta*B)",
+ yaxis_title="Spread",
+ )
+
+ st.markdown("#### 6. Stationarity and cointegration")
+ st.caption(
+ "Distinct questions: is the spread itself stationary (ADF), and are "
+ "A and B cointegrated as a pair (Engle-Granger)? A pair can pass one "
+ "and not the other. The displayed full-sample ADF is exploratory; "
+ "with a dynamic hedge it tests an adaptively assembled rolling spread."
+ )
+ render_stationarity_card(st, diagnostics.adf_result, label="ADF (spread)")
+ render_stationarity_card(
+ st, diagnostics.cointegration_result, label="Engle-Granger cointegration"
+ )
+ if diagnostics.rolling_adf_pvalue.notna().any():
+ render_price_chart(
+ st,
+ {"Rolling ADF p-value": diagnostics.rolling_adf_pvalue},
+ title="Causal periodic ADF gate used for new entries",
+ yaxis_title="p-value",
+ )
+ else:
+ st.info(
+ "Not enough history yet for a rolling stationarity check "
+ "-- widen the date range or shrink formation_window."
+ )
+
+ st.markdown("#### 7. Mean-reversion characteristics")
+ col_hl, col_stab = st.columns(2)
+ half_life_text = (
+ f"{diagnostics.half_life:.1f} periods"
+ if math.isfinite(diagnostics.half_life)
+ else "no finite estimate for this sample"
+ )
+ col_hl.metric("Half-life", half_life_text)
+ col_stab.metric(
+ "Hedge-ratio stability (std of beta)",
+ f"{diagnostics.hedge_ratio_stability:.4f}"
+ if math.isfinite(diagnostics.hedge_ratio_stability)
+ else "n/a",
+ )
+ st.caption(
+ "A larger hedge-ratio std means the fitted slope changes more in "
+ "this sample. It is measured in beta's scale-dependent units, so "
+ "compare it across settings for the same ordered pair, not across pairs."
+ )
+
+ st.markdown("#### 8. Trading thresholds")
+ st.caption(
+ "The same three-indicator choice as Mean Reversion (see that "
+ "strategy's own page), applied to this pair's spread instead of a "
+ "raw price."
+ )
+ from quantlab.dashboard.components import (
+ entry_threshold_bounds,
+ exit_threshold_bounds,
+ mean_reversion_slider_bounds,
+ )
+ from quantlab.strategies.pairs_trading import INDICATORS as PAIRS_INDICATORS
+ from quantlab.strategies.pairs_trading import _centered_spread_indicator
+
+ indicator_choice = st.selectbox(
+ "indicator", list(PAIRS_INDICATORS), key="explorer_pairs_indicator"
+ )
+ (
+ entry_min,
+ entry_max,
+ entry_default,
+ exit_default,
+ stop_max,
+ stop_default,
+ step,
+ ) = mean_reversion_slider_bounds(indicator_choice)
+ # Asked BEFORE the entry slider (not after) so entry's own bounds can
+ # already know whether the stop slider will even be rendered -- see
+ # entry_threshold_bounds's own docstring.
+ use_stop = st.checkbox(
+ "stop_threshold enabled",
+ value=True,
+ key=f"explorer_pairs_use_stop_{indicator_choice}",
+ )
+ entry_min, entry_max = entry_threshold_bounds(
+ entry_min, entry_max, stop_max, step, stop_enabled=use_stop
+ )
+ entry_default = min(max(entry_default, entry_min), entry_max)
+ entry = st.slider(
+ "entry_threshold",
+ entry_min,
+ entry_max,
+ entry_default,
+ step,
+ key=f"explorer_pairs_entry_{indicator_choice}",
+ )
+ pairs_exit_bounds = exit_threshold_bounds(entry, step)
+ if pairs_exit_bounds is None:
+ st.caption("exit_threshold: 0.0 (the only value possible this close to zero)")
+ exit_ = 0.0
+ else:
+ pairs_exit_min, pairs_exit_max = pairs_exit_bounds
+ exit_ = st.slider(
+ "exit_threshold",
+ pairs_exit_min,
+ pairs_exit_max,
+ min(exit_default, pairs_exit_max),
+ step,
+ key=f"explorer_pairs_exit_{indicator_choice}",
+ )
+ stop = (
+ st.slider(
+ "stop_threshold (force-closes a position regardless of direction "
+ "-- limits the indicator's own deviation tolerated, not the "
+ "realized monetary loss)",
+ entry + step,
+ stop_max,
+ max(stop_default, entry + step),
+ step,
+ key=f"explorer_pairs_stop_{indicator_choice}",
+ )
+ if use_stop
+ else None
+ )
+ use_adf_gate = st.checkbox(
+ "Require the ADF stationarity gate for a viable entry",
+ value=True,
+ key="explorer_pairs_use_adf_gate",
+ )
+ adf_threshold = (
+ st.slider(
+ "adf_pvalue_threshold",
+ 0.01,
+ 0.50,
+ 0.10,
+ 0.01,
+ key="explorer_pairs_adf_threshold",
+ )
+ if use_adf_gate
+ else None
+ )
+
+ indicator = _centered_spread_indicator(
+ diagnostics.spread, indicator_choice, indicator_window
+ )
+ threshold_series, line_colors = centered_indicator_threshold_overlay(
+ indicator,
+ f"{indicator_choice} indicator",
+ entry_threshold=entry,
+ exit_threshold=exit_,
+ stop_threshold=stop,
+ )
+
+ # A date is a VIABLE entry only when the indicator actually crosses the
+ # entry threshold AND (if the gate is enabled) the rolling ADF p-value
+ # at that date is <= adf_threshold -- mirrors PairsTradingStrategy.
+ # _stationarity_gate's own condition exactly, using the same rolling
+ # ADF series already displayed in step 6 above (never a second,
+ # potentially diverging ADF computation).
+ crosses_entry = (indicator > entry) | (indicator < -entry)
+ if adf_threshold is not None:
+ gate_open = diagnostics.rolling_adf_pvalue.reindex(indicator.index) <= (
+ adf_threshold
+ )
+ gate_open = gate_open.fillna(False)
+ else:
+ gate_open = pd.Series(True, index=indicator.index)
+ viable = crosses_entry & gate_open
+ viable_marker = indicator.where(viable)
+ markers = (
+ {"Viable entry (threshold crossed + ADF gate open)": viable_marker}
+ if viable.any()
+ else None
+ )
+ marker_colors = (
+ {"Viable entry (threshold crossed + ADF gate open)": VIABLE_ENTRY_MARKER_COLOR}
+ if markers
+ else {}
+ )
+ render_price_chart(
+ st,
+ threshold_series,
+ title=f"'{indicator_choice}' indicator of the spread, with threshold "
+ "overlays (not simulated trades)",
+ yaxis_title="Centered indicator",
+ markers=markers,
+ colors={**line_colors, **marker_colors},
+ )
+ n_viable = int(viable.sum())
+ st.caption(
+ f"{strong(str(n_viable))} bar(s) in this sample are a viable entry: "
+ "the entry threshold is crossed AND"
+ + (
+ " the ADF gate is open."
+ if adf_threshold is not None
+ else " (gate disabled)."
+ )
+ + " Not an entry count: current state, rebalancing and execution "
+ "can still prevent or delay a trade.",
+ unsafe_allow_html=True,
+ )
+ if stop is not None:
+ stop_breaches = int(((indicator > stop) | (indicator < -stop)).sum())
+ st.caption(
+ f"{strong(str(stop_breaches))} bar(s) in this sample cross the "
+ "stop threshold. If a position were open, the strategy state "
+ "would request flat; rebalancing and execution determine when "
+ "the weight changes. This limits further indicator-distance "
+ "exposure but NOT the realized monetary loss on the "
+ "way there (gaps/execution delay/costs can still exceed what "
+ "the indicator distance alone suggests).",
+ unsafe_allow_html=True,
+ )
+ breaches = int(crosses_entry.sum())
+ st.caption(
+ f"{strong(str(breaches))} bar(s) in this sample cross the entry "
+ "threshold (before the ADF gate). A higher entry_threshold means "
+ "fewer threshold breaches; a lower exit_threshold means each trade is "
+ "held closer to full mean reversion before closing (more time in "
+ "the trade, less residual edge left uncaptured).",
+ unsafe_allow_html=True,
+ )
+
+ try:
+ from quantlab.dashboard.explorer.shared_components import (
+ render_stop_loss_take_profit_illustration,
+ )
+ from quantlab.strategies.pairs_trading import PairsTradingStrategy
+
+ pair_strategy = PairsTradingStrategy(
+ symbol_a=symbol_a,
+ symbol_b=symbol_b,
+ formation_window=formation_window,
+ indicator_window=indicator_window,
+ indicator=indicator_choice,
+ entry_threshold=entry,
+ exit_threshold=exit_,
+ stop_threshold=stop,
+ dynamic_hedge_ratio=dynamic,
+ adf_pvalue_threshold=adf_threshold,
+ )
+ pair_signals = pair_strategy.generate_signals(data)
+ except Exception as exc:
+ logger.exception(
+ "Pairs Trading lab: could not compute signals for the "
+ "stop-loss/take-profit illustration"
+ )
+ st.error(f"Could not compute the pair's signal for this illustration: {exc}")
+ else:
+ render_stop_loss_take_profit_illustration(
+ st,
+ {symbol_a: pair_signals[symbol_a], symbol_b: pair_signals[symbol_b]},
+ {symbol_a: prices[symbol_a], symbol_b: prices[symbol_b]},
+ key_prefix="explorer_pairs",
+ position_groups=((symbol_a, symbol_b),),
+ )
diff --git a/src/quantlab/dashboard/explorer/labs/time_series_momentum.py b/src/quantlab/dashboard/explorer/labs/time_series_momentum.py
new file mode 100644
index 0000000..3a5d0fd
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/labs/time_series_momentum.py
@@ -0,0 +1,210 @@
+"""Interactive Time-Series Momentum lab.
+
+Momentum formation for a single asset -> comparing the three
+`signal_scaling` modes on the SAME score -> momentum persistence ->
+volatility diagnostic (for the volatility_adjusted mode).
+"""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any
+
+import numpy as np
+
+from quantlab.logging_config import get_logger
+
+logger = get_logger(__name__)
+
+
+def _periods_per_year_for_calendar(calendar: str) -> int:
+ """Annualisation factor matching the real strategy's own convention.
+
+ ``TimeSeriesMomentumStrategy``'s own ``periods_per_year`` is injected
+ from the experiment's own data frequency, not fixed: 365 for a 24/7
+ market, 252 for a session-bound one (see its ``ParameterDoc``'s
+ ``typical_range``). Extracted as its own function so this derivation
+ is directly testable without needing a Streamlit runtime.
+ """
+ from quantlab.data.calendar import is_247
+
+ return 365 if is_247(calendar) else 252
+
+
+def render(st: Any) -> None:
+ """Render the Time-Series Momentum interactive lab."""
+ from quantlab.dashboard.explorer.shared_components import (
+ load_explorer_prices_cached,
+ render_price_chart,
+ render_stop_loss_take_profit_illustration,
+ render_symbol_and_source_picker,
+ strong,
+ )
+ from quantlab.dashboard.state import default_end_date
+ from quantlab.data.base import price_matrix
+ from quantlab.features.momentum import (
+ momentum,
+ momentum_persistence,
+ volatility_adjusted_momentum,
+ )
+ from quantlab.features.returns import simple_returns
+ from quantlab.features.volatility import realized_volatility
+
+ st.markdown("#### Momentum formation")
+ picker_result = render_symbol_and_source_picker(
+ st,
+ key_prefix="explorer_tsmom",
+ default_symbols=("SPY", "QQQ", "TLT", "GLD"),
+ )
+ if picker_result is None:
+ st.info("Pick at least one symbol above to load price data.")
+ return
+ symbols, source, calendar, use_bundled_demo_data = picker_result
+ symbol = st.selectbox("Symbol to analyze", symbols, key="explorer_tsmom_symbol")
+ col_start, col_end = st.columns(2)
+ start_date = col_start.date_input(
+ "Start date", value=date(2019, 1, 1), key="explorer_tsmom_start"
+ )
+ end_date = col_end.date_input(
+ "End date", value=default_end_date(), key="explorer_tsmom_end"
+ )
+
+ try:
+ data = load_explorer_prices_cached(
+ st,
+ symbols,
+ source=source,
+ calendar=calendar,
+ start_date=start_date,
+ end_date=end_date,
+ use_bundled_demo_data=use_bundled_demo_data,
+ )
+ except Exception as exc:
+ logger.exception("Time-Series Momentum lab: could not load data for %s", symbol)
+ st.error(f"Could not load data for {symbol}: {exc}")
+ return
+ prices_frame = price_matrix(data)
+ if symbol not in prices_frame.columns:
+ st.warning(f"No data for {symbol} in this range.")
+ return
+ prices = prices_frame[symbol]
+
+ col_look, col_skip = st.columns(2)
+ lookback = col_look.slider(
+ "lookback_period", 21, 504, 252, key="explorer_tsmom_lookback"
+ )
+ skip = col_skip.slider(
+ "skip_period", 0, min(63, lookback - 1), 21, key="explorer_tsmom_skip"
+ )
+ score = momentum(prices, lookback, skip)
+ render_price_chart(
+ st, {"Momentum score": score}, title=f"{symbol}: raw momentum score"
+ )
+
+ st.markdown("#### Comparing signal_scaling modes")
+ st.caption(
+ "The same underlying score, mapped to a strategy signal three "
+ "different ways. These are pre-long_only, pre-allocator values: "
+ "portfolio construction and execution still determine the final weight."
+ )
+ binary_signal = np.sign(score)
+ vol_window = st.slider(
+ "volatility_window (for the volatility_adjusted mode only -- "
+ "continuous uses its own rolling dispersion of the score, over "
+ "lookback_period, not this window)",
+ 5,
+ 200,
+ 63,
+ key="explorer_tsmom_vol_window",
+ )
+ dispersion = score.rolling(lookback, min_periods=min(20, lookback)).std(ddof=1)
+ continuous_signal = (score / dispersion).clip(-1.0, 1.0)
+ # Derived from the selected calendar -- not a tunable knob exposed
+ # here, but must still match the real strategy's own convention (see
+ # _periods_per_year_for_calendar's own docstring). Fixing this at 252
+ # unconditionally used to silently mis-annualise the volatility_
+ # adjusted panel below for a 24/7 (e.g. Binance) selection.
+ periods_per_year = _periods_per_year_for_calendar(calendar)
+ vol = realized_volatility(
+ simple_returns(prices), window=vol_window, periods_per_year=periods_per_year
+ )
+ # Calls the exact same public helper TimeSeriesMomentumStrategy itself
+ # delegates to for this mode, rather than reimplementing the division
+ # -- a zero-volatility window is masked to NaN there, never silently
+ # producing a false +-1.0 signal via a stray inf/-inf before `.clip()`.
+ vol_adjusted_signal = volatility_adjusted_momentum(
+ prices, lookback, skip, vol_window, periods_per_year
+ ).clip(-1.0, 1.0)
+ render_price_chart(
+ st,
+ {
+ "binary": binary_signal,
+ "continuous": continuous_signal,
+ "volatility_adjusted": vol_adjusted_signal,
+ },
+ title=f"{symbol}: the same score under each signal_scaling mode",
+ yaxis_title="Signal",
+ )
+ illustration_mode = st.selectbox(
+ "Illustrate stop-loss/take-profit on which signal_scaling mode",
+ ["binary", "continuous", "volatility_adjusted"],
+ key="explorer_tsmom_illustration_mode",
+ )
+ illustration_signal = {
+ "binary": binary_signal,
+ "continuous": continuous_signal,
+ "volatility_adjusted": vol_adjusted_signal,
+ }[illustration_mode]
+ render_stop_loss_take_profit_illustration(
+ st, illustration_signal.fillna(0.0), prices, key_prefix="explorer_tsmom"
+ )
+ render_price_chart(
+ st,
+ {"Annualized volatility": vol},
+ title="Volatility used by the volatility_adjusted mode "
+ f"(annualised at {periods_per_year} periods/year)",
+ yaxis_title="Volatility",
+ )
+
+ st.markdown("#### Momentum persistence")
+ st.caption(
+ "Does a high past momentum score actually predict a higher "
+ "subsequent return for THIS asset, on this data?"
+ )
+ holding_period = st.slider(
+ "holding_period (for the future return)",
+ 1,
+ 126,
+ 21,
+ key="explorer_tsmom_holding",
+ )
+ paired = momentum_persistence(prices, lookback, skip, holding_period)
+ if paired.empty:
+ st.info("Not enough history to pair momentum with a future return yet.")
+ return
+
+ import plotly.graph_objects as go
+
+ fig = go.Figure(
+ go.Scatter(
+ x=paired["past_momentum"],
+ y=paired["future_return"],
+ mode="markers",
+ marker={"size": 5, "opacity": 0.5},
+ )
+ )
+ fig.update_layout(
+ title=f"{symbol}: past momentum vs. subsequent {holding_period}-period return",
+ xaxis_title="Past momentum score",
+ yaxis_title="Future return",
+ height=380,
+ )
+ st.plotly_chart(fig, width="stretch")
+ correlation = paired["past_momentum"].corr(paired["future_return"])
+ st.caption(
+ f"Correlation between the two columns above: "
+ f"{strong(f'{correlation:.3f}')}. Descriptive sample evidence, not a "
+ "hypothesis test -- overlapping holding periods across consecutive "
+ "dates are not independent observations.",
+ unsafe_allow_html=True,
+ )
diff --git a/src/quantlab/dashboard/explorer/labs/trend_following.py b/src/quantlab/dashboard/explorer/labs/trend_following.py
new file mode 100644
index 0000000..1ec6382
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/labs/trend_following.py
@@ -0,0 +1,192 @@
+"""Interactive Trend Following lab.
+
+Fast/slow moving-average crossover -> whipsaw diagnostic (how often the
+signal flips) -> trend-strength diagnostic (Efficiency Ratio) ->
+parameter comparison.
+"""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any
+
+import numpy as np
+
+from quantlab.logging_config import get_logger
+
+logger = get_logger(__name__)
+
+
+def render(st: Any) -> None:
+ """Render the Trend Following interactive lab."""
+ from quantlab.dashboard.explorer.shared_components import (
+ ENTRY_LINE_COLOR,
+ EXIT_LINE_COLOR,
+ load_explorer_prices_cached,
+ render_price_chart,
+ render_stop_loss_take_profit_illustration,
+ render_symbol_and_source_picker,
+ strong,
+ )
+ from quantlab.dashboard.state import default_end_date
+ from quantlab.data.base import price_matrix
+ from quantlab.features.momentum import ma_crossover_signal, moving_average
+ from quantlab.features.technical import efficiency_ratio
+
+ st.markdown("#### Fast/slow moving-average crossover")
+ picker_result = render_symbol_and_source_picker(
+ st,
+ key_prefix="explorer_trend",
+ default_symbols=("SPY", "QQQ", "TLT", "GLD"),
+ )
+ if picker_result is None:
+ st.info("Pick at least one symbol above to load price data.")
+ return
+ symbols, source, calendar, use_bundled_demo_data = picker_result
+ symbol = st.selectbox("Symbol to analyze", symbols, key="explorer_trend_symbol")
+ col_start, col_end = st.columns(2)
+ start_date = col_start.date_input(
+ "Start date", value=date(2019, 1, 1), key="explorer_trend_start"
+ )
+ end_date = col_end.date_input(
+ "End date", value=default_end_date(), key="explorer_trend_end"
+ )
+
+ try:
+ data = load_explorer_prices_cached(
+ st,
+ symbols,
+ source=source,
+ calendar=calendar,
+ start_date=start_date,
+ end_date=end_date,
+ use_bundled_demo_data=use_bundled_demo_data,
+ )
+ except Exception as exc:
+ logger.exception("Trend Following lab: could not load data for %s", symbol)
+ st.error(f"Could not load data for {symbol}: {exc}")
+ return
+ prices_frame = price_matrix(data)
+ if symbol not in prices_frame.columns:
+ st.warning(f"No data for {symbol} in this range.")
+ return
+ prices = prices_frame[symbol]
+
+ col_fast, col_slow = st.columns(2)
+ fast_window = col_fast.slider("fast_window", 2, 100, 20, key="explorer_trend_fast")
+ slow_window = col_slow.slider(
+ "slow_window",
+ fast_window + 1,
+ 300,
+ max(fast_window + 1, 100),
+ key="explorer_trend_slow",
+ )
+ fast_ma = moving_average(prices, fast_window)
+ slow_ma = moving_average(prices, slow_window)
+ render_price_chart(
+ st,
+ {
+ "Price": prices,
+ f"Fast MA ({fast_window})": fast_ma,
+ f"Slow MA ({slow_window})": slow_ma,
+ },
+ title=f"{symbol}: fast/slow moving-average crossover",
+ # Price is left at Plotly's own default first-trace color; Fast/
+ # Slow MA get explicit, visibly distinct colors (matching Results)
+ # so neither is ever mistaken for the price line itself.
+ colors={
+ f"Fast MA ({fast_window})": ENTRY_LINE_COLOR,
+ f"Slow MA ({slow_window})": EXIT_LINE_COLOR,
+ },
+ )
+ signal = ma_crossover_signal(prices, fast_window, slow_window)
+ long_only = st.checkbox(
+ "long_only (a downtrend goes flat instead of short)",
+ value=True,
+ key="explorer_trend_long_only",
+ )
+ executable_signal = signal.clip(lower=0.0) if long_only else signal
+ render_price_chart(
+ st,
+ {
+ "Executable signal (post warm-up fill)": executable_signal.fillna(0.0),
+ },
+ title="Crossover signal -- what generate_signals() actually returns "
+ f"({'long_only' if long_only else 'long/short'})",
+ yaxis_title="Signal",
+ )
+ render_stop_loss_take_profit_illustration(
+ st, executable_signal.fillna(0.0), prices, key_prefix="explorer_trend"
+ )
+
+ st.markdown("#### Whipsaw diagnostic")
+ st.caption(
+ "How often does the raw crossover direction change? Frequent changes "
+ "indicate whipsaw pressure, but they create trades and costs only if "
+ "they change a target sampled at a rebalance date. With long_only=True, "
+ "this raw diagnostic can also count movements within the clipped-flat "
+ "region, so it is an upper-bound indicator rather than executed turnover."
+ )
+ flips = signal.diff().fillna(0.0).ne(0.0)
+ window = st.slider(
+ "Count flips over the trailing N periods",
+ 20,
+ 504,
+ 126,
+ key="explorer_trend_flip_window",
+ )
+ rolling_flips = flips.rolling(window, min_periods=1).sum()
+ render_price_chart(
+ st,
+ {f"Raw crossover changes in trailing {window} periods": rolling_flips},
+ title="Raw crossover-change frequency over time",
+ yaxis_title="Flip count",
+ )
+
+ st.markdown("#### Trend-strength diagnostic (Efficiency Ratio)")
+ st.caption(
+ "Near 1: price moved efficiently in one direction (a clean trend, "
+ "favourable for this strategy). Near 0: the same net move took a "
+ "much choppier path (noise dominating -- unfavourable). A perfectly "
+ "flat window has an undefined 0/0 ratio; QuantLab displays 0.5 for "
+ "that special case as a neutral convention, not as trend evidence."
+ )
+ er_window = st.slider(
+ "Efficiency Ratio window",
+ 5,
+ 200,
+ min(slow_window, 200),
+ key="explorer_trend_er_window",
+ )
+ er = efficiency_ratio(prices, er_window)
+ render_price_chart(
+ st,
+ {"Efficiency Ratio": er},
+ title="Kaufman's Efficiency Ratio",
+ yaxis_title="ER",
+ )
+ st.caption(
+ f"Median Efficiency Ratio over this sample: "
+ f"{strong(f'{np.nanmedian(er.to_numpy()):.2f}')}.",
+ unsafe_allow_html=True,
+ )
+
+ st.markdown("#### Parameter comparison")
+ combos = st.multiselect(
+ "(fast_window, slow_window) combinations to compare",
+ options=["(10, 50)", "(20, 100)", "(50, 200)"],
+ default=["(10, 50)", "(20, 100)", "(50, 200)"],
+ key="explorer_trend_compare",
+ )
+ if combos:
+ series = {}
+ for combo in combos:
+ fast_str, slow_str = combo.strip("()").split(",")
+ fast, slow = int(fast_str), int(slow_str)
+ series[combo] = ma_crossover_signal(prices, fast, slow)
+ render_price_chart(
+ st,
+ series,
+ title=f"{symbol}: crossover signal at different windows",
+ yaxis_title="Signal",
+ )
diff --git a/src/quantlab/dashboard/explorer/profile.py b/src/quantlab/dashboard/explorer/profile.py
new file mode 100644
index 0000000..24000d0
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/profile.py
@@ -0,0 +1,225 @@
+"""Strategy Explorer content registry.
+
+Mirrors ``quantlab.strategies.base``'s registration pattern: each strategy
+declares its own pedagogical profile in ``explorer/profiles/.py``,
+registered by calling :func:`register_profile` at import time. The Strategy
+Explorer's own dispatch -- the gallery/detail pages and the optional
+Results-tab/report diagnostics -- only ever asks "does the current
+strategy's profile declare X", never special-cases a strategy by name. This
+does not extend to the rest of the dashboard: the regular Backtest/
+Walk-forward sidebar in ``app.py`` still branches on a strategy's name to
+render its own config widgets, unrelated to this registry.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import pandas as pd
+
+if TYPE_CHECKING:
+ from quantlab.config import ExperimentConfig
+ from quantlab.reporting.sections import DiagnosticsSection
+
+
+@dataclass(frozen=True)
+class ParameterDoc:
+ """Full explanation of one strategy constructor parameter.
+
+ ``what``/``where``/``why`` answer "what is it", "where in the signal
+ pipeline does it act" and "why does it exist". ``effect_increase``/
+ ``effect_decrease`` describe the observable consequence of moving it
+ in each direction -- the strategy's interactive lab should let a user
+ actually see this happen, not just read about it (see
+ :attr:`StrategyProfile.lab`).
+ """
+
+ name: str
+ what: str
+ where: str
+ why: str
+ default: str
+ typical_range: str
+ effect_increase: str
+ effect_decrease: str
+ tradeoffs: str
+ interactions: str = ""
+
+
+@dataclass(frozen=True)
+class ResultsDiagnostics:
+ """A strategy's own extra Results-tab/report diagnostics, if any.
+
+ ``compute`` takes the already-loaded canonical price/OHLCV frame and
+ the experiment config and returns a strategy-specific structured
+ result (e.g. ``PairDiagnostics``). ``render`` displays that result in
+ the dashboard Results tab (``st`` injected as the first argument, the
+ structured result as the second). ``report_section`` turns the same
+ structured result into a generic :class:`~quantlab.reporting.sections.
+ DiagnosticsSection` for the HTML report. ``key`` doubles as the
+ ``robustness`` dict key the CLI attaches it under and the
+ ``st.session_state`` key the dashboard stores it under -- must be
+ unique across every registered profile.
+ """
+
+ key: str
+ compute: Callable[[pd.DataFrame, ExperimentConfig], Any]
+ render: Callable[[Any, Any], None]
+ report_section: Callable[[Any], DiagnosticsSection]
+
+
+@dataclass(frozen=True)
+class StrategyProfile:
+ """The Strategy Explorer's complete content for one registered strategy.
+
+ ``strategy_name`` must match a name in ``quantlab.strategies.base.
+ available_strategies()``. Every markdown field is plain text/Markdown
+ rendered inside a collapsible ``st.expander`` section on the detail
+ page; none are required to be exhaustive on their own -- the
+ ``parameters`` list and the interactive ``lab`` carry the bulk of the
+ "make every parameter genuinely understood" requirement.
+ """
+
+ strategy_name: str
+ display_name: str
+ category: str
+ overview_md: str
+ economic_intuition_md: str
+ mathematical_definition_md: str
+ assumptions_md: str
+ diagnostics_md: str
+ interpretation_md: str
+ limitations_md: str
+ parameters: list[ParameterDoc]
+ lab: Callable[[Any], None]
+ references_md: str | None = None
+ results_diagnostics: ResultsDiagnostics | None = None
+
+
+_REGISTRY: dict[str, StrategyProfile] = {}
+
+#: Text fields every profile must actually fill in -- an empty one would
+#: silently render as a blank expander section rather than fail loudly at
+#: registration time.
+_REQUIRED_MARKDOWN_FIELDS = (
+ "overview_md",
+ "economic_intuition_md",
+ "mathematical_definition_md",
+ "assumptions_md",
+ "diagnostics_md",
+ "interpretation_md",
+ "limitations_md",
+)
+
+
+def register_profile(profile: StrategyProfile, *, replace: bool = False) -> None:
+ """Register a strategy's Strategy Explorer content.
+
+ Raises if a profile is already registered for ``profile.strategy_name``
+ unless ``replace=True`` (mirrors ``register_strategy``'s own guard
+ against accidental double-registration). Also enforces the contracts
+ ``StrategyProfile``/``ResultsDiagnostics`` document but did not
+ previously check: ``strategy_name`` names a real registered strategy,
+ every markdown field actually has content, no ``ParameterDoc`` name is
+ duplicated, ``compute``/``render``/``report_section`` are callable when
+ ``results_diagnostics`` is set, and (when it is) its ``key`` does not
+ collide with another already-registered profile's -- that key doubles
+ as the ``robustness`` dict key and the dashboard ``st.session_state``
+ key, so a collision would silently let two strategies clobber each
+ other's diagnostics.
+ """
+ if not isinstance(profile, StrategyProfile):
+ raise TypeError("profile must be a StrategyProfile.")
+ if profile.strategy_name in _REGISTRY and not replace:
+ raise ValueError(
+ f"A profile is already registered for '{profile.strategy_name}'."
+ )
+ # Local import (not module-level): forces `quantlab.strategies` to
+ # finish registering every built-in strategy right here if a caller
+ # hasn't already imported it, so this check is correct regardless of
+ # import order rather than only when this module happens to run after
+ # `quantlab.strategies` elsewhere.
+ from quantlab.strategies.base import available_strategies
+
+ if profile.strategy_name not in available_strategies():
+ raise ValueError(
+ f"Profile strategy_name {profile.strategy_name!r} is not a "
+ f"registered strategy. Registered: {available_strategies()}."
+ )
+ for field in _REQUIRED_MARKDOWN_FIELDS:
+ if not getattr(profile, field).strip():
+ raise ValueError(
+ f"Profile '{profile.strategy_name}': {field} must not be empty."
+ )
+ if not profile.display_name.strip():
+ raise ValueError(f"Profile '{profile.strategy_name}': display_name is empty.")
+ if not profile.category.strip():
+ raise ValueError(f"Profile '{profile.strategy_name}': category is empty.")
+ parameter_names = [parameter.name for parameter in profile.parameters]
+ duplicate_parameters = {
+ name for name in parameter_names if parameter_names.count(name) > 1
+ }
+ if duplicate_parameters:
+ raise ValueError(
+ f"Profile '{profile.strategy_name}': duplicate ParameterDoc "
+ f"name(s) {sorted(duplicate_parameters)}."
+ )
+ for parameter in profile.parameters:
+ # `interactions` is deliberately excluded: "" is its documented
+ # default for a parameter that genuinely has none to report.
+ for parameter_field in (
+ "name",
+ "what",
+ "where",
+ "why",
+ "default",
+ "typical_range",
+ "effect_increase",
+ "effect_decrease",
+ "tradeoffs",
+ ):
+ if not getattr(parameter, parameter_field).strip():
+ raise ValueError(
+ f"Profile '{profile.strategy_name}': ParameterDoc "
+ f"{parameter.name!r}'s {parameter_field} must not be empty."
+ )
+ if profile.results_diagnostics is not None:
+ for callback_name in ("compute", "render", "report_section"):
+ if not callable(getattr(profile.results_diagnostics, callback_name)):
+ raise ValueError(
+ f"Profile '{profile.strategy_name}': "
+ f"results_diagnostics.{callback_name} must be callable."
+ )
+ if not profile.results_diagnostics.key.strip():
+ raise ValueError(
+ f"Profile '{profile.strategy_name}': results_diagnostics.key "
+ "must not be empty."
+ )
+ key = profile.results_diagnostics.key
+ colliding = [
+ name
+ for name, existing in _REGISTRY.items()
+ if name != profile.strategy_name
+ and existing.results_diagnostics is not None
+ and existing.results_diagnostics.key == key
+ ]
+ if colliding:
+ raise ValueError(
+ f"Profile '{profile.strategy_name}': results_diagnostics.key "
+ f"{key!r} collides with already-registered profile(s) "
+ f"{colliding} -- this key must be unique across every "
+ "registered profile."
+ )
+ _REGISTRY[profile.strategy_name] = profile
+
+
+def get_profile(strategy_name: str) -> StrategyProfile | None:
+ """Return the registered profile for a strategy, or ``None``."""
+ return _REGISTRY.get(strategy_name)
+
+
+def available_profiles() -> list[str]:
+ """Return the names of every strategy with a registered profile."""
+ return sorted(_REGISTRY)
diff --git a/src/quantlab/dashboard/explorer/profiles/__init__.py b/src/quantlab/dashboard/explorer/profiles/__init__.py
new file mode 100644
index 0000000..c7d51a8
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/profiles/__init__.py
@@ -0,0 +1,21 @@
+"""Importing this package registers every strategy's Strategy Explorer profile.
+
+Mirrors how ``quantlab.strategies`` triggers each strategy's own
+``@register_strategy`` decorator by importing every strategy module --
+``get_profile()``/``available_profiles()`` (``quantlab.dashboard.explorer.
+profile``) are the actual public entry points, so nothing here needs
+re-exporting.
+"""
+
+from __future__ import annotations
+
+# Imported for their registration side effects (each module calls
+# register_profile() at import time).
+from quantlab.dashboard.explorer.profiles import (
+ buy_and_hold, # noqa: F401
+ cross_sectional_momentum, # noqa: F401
+ mean_reversion, # noqa: F401
+ pairs_trading, # noqa: F401
+ time_series_momentum, # noqa: F401
+ trend_following, # noqa: F401
+)
diff --git a/src/quantlab/dashboard/explorer/profiles/buy_and_hold.py b/src/quantlab/dashboard/explorer/profiles/buy_and_hold.py
new file mode 100644
index 0000000..4d46154
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/profiles/buy_and_hold.py
@@ -0,0 +1,164 @@
+"""Strategy Explorer profile for ``buy_and_hold``."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from quantlab.dashboard.explorer.profile import (
+ ParameterDoc,
+ StrategyProfile,
+ register_profile,
+)
+
+_OVERVIEW = """
+The simplest strategy in QuantLab, and the essential baseline every other
+strategy should be compared against: emit a long eligibility signal for
+every configured instrument while its price is available, with no timing
+decision at all. It has no view on direction, no entry/exit logic, and
+(with more than one asset) is not literally "hold forever at fixed
+weights" the way the name might suggest -- see the lab below for why.
+
+Typical horizon: the entire backtest period. Data needed: whatever the
+configured instruments have.
+"""
+
+_ECONOMIC_INTUITION = """
+Buy and hold exists as a strategy for two reasons: it can be a long-term
+investment approach in its own right (broad, diversified market exposure
+seeks to capture a long-run risk premium without the additional turnover
+associated with active timing), and it serves as the essential passive
+baseline against which every actively timed strategy in this project
+should be evaluated.
+An active strategy should demonstrate an objective benefit -- for example,
+higher risk-adjusted performance or lower drawdown -- after accounting for
+its extra transaction costs, turnover, execution risk and model risk.
+"""
+
+_MATH = """
+`generate_signals()` is a single line: `signal = prices.notna().astype
+(float)` -- exactly `1.0` wherever a valid price is available for a
+symbol and `0.0` otherwise. There is no lookback window, threshold,
+ranking rule, or strategy state to track.
+
+Crucially, these signals are not portfolio weights. They only indicate
+which assets are eligible to be held. The actual realized portfolio
+weights are determined downstream by the configured allocator and the
+`rebalance_frequency`.
+
+With a single asset, an eligibility signal that remains at `1.0` asks the
+allocator to hold that asset. It produces full exposure only in the
+absence of downstream scaling or constraints; volatility targeting,
+weight/exposure limits, turnover limits and execution timing can all
+reduce or delay the final weight. With multiple
+assets, however, an eligibility signal of 1.0 for every asset does not
+imply a weight of 1.0 in every asset; the allocator determines how
+capital is distributed among the eligible assets (see Assumptions / lab
+below).
+"""
+
+_ASSUMPTIONS = """
+**Economic**: the configured instrument(s) are assumed to be suitable for
+long-term exposure with no active timing view -- more plausible for a broad,
+long-term-appropriate holding, less appropriate for something
+mean-reverting, cyclical, or otherwise unsuited to indefinite exposure.
+
+**Portfolio construction**: with more than one instrument, "buy and hold" is
+implicitly also a statement about the *rebalance schedule* -- the
+strategy itself is silent on target weights, so the configured allocator and
+`rebalance_frequency` entirely determine how closely realized weights stay
+to any intended allocation.
+"""
+
+_DIAGNOSTICS = """
+The lab below is mostly a price explorer -- there is little to diagnose
+about a strategy with no timing parameters. Its main
+diagnostic, with two or more symbols selected, illustrates how much a
+real portfolio's weights drift away from an equal split purely
+because of differences in each asset's return between rebalances -- the
+concrete illustration of why "always invested" is not the same claim as
+"static weights". This matches what a real QuantLab backtest itself now
+does by default (`portfolio.model_weight_drift=True` -- see
+docs/backtesting.md's Weight drift section); it stays a standalone,
+theoretical illustration only in that it never runs the actual
+accounting/cost/compliance-correction pipeline, so it can still diverge
+in the details from a genuine backtest of this strategy.
+"""
+
+_INTERPRETATION = """
+As a baseline: any actively timed strategy backtested over the same
+instruments and period should be compared against this one's Sharpe,
+CAGR and drawdown -- underperforming buy and hold net of costs is a
+meaningful signal that the added complexity is not earning its keep on
+this data. As a strategy in its own right with multiple assets: the
+weight-drift chart shows why `rebalance_frequency` matters even here,
+despite this strategy having no parameters of its own -- QuantLab's own
+accounting now models this same intra-period drift by default (see
+docs/backtesting.md's Weight drift section), so `rebalance_frequency`
+genuinely shapes the realized backtest, not only a real portfolio it is
+meant to approximate.
+"""
+
+_LIMITATIONS = """
+**No strategy-level risk management**: the signal itself remains long
+through a sustained decline -- it has no stop or de-risking rule. The
+allocator, volatility target and portfolio constraints may still scale
+the final exposure. **No timing skill claim**: by construction it cannot
+outperform through market timing because there is none -- its reported
+performance instead reflects the returns of the selected instruments
+together with the configured allocation, constraints, rebalancing and
+execution rules. **Weight drift is modeled by default**
+(`portfolio.model_weight_drift=True`) -- each asset's own price move
+drifts its executed weight between real trades, matching a real
+portfolio held at fixed share counts (illustrated in the lab above);
+`model_weight_drift=False` remains available as an explicit legacy/
+reproducibility flag that reproduces the constant-weight step function
+instead (see docs/backtesting.md's Weight drift section).
+"""
+
+_PARAMETERS = [
+ ParameterDoc(
+ name="price_type",
+ what="Which price series ('adjusted_close' or 'close') "
+ "generate_signals() reads to decide whether a symbol is eligible "
+ "to be 'held' on a given date. Execution/costs always use the "
+ "raw close regardless.",
+ where="The sole input to the strategy's one-line signal.",
+ why="Determines only WHETHER a price exists on a date (affecting "
+ "the earliest date a delayed listing becomes eligible), not the "
+ "position's magnitude -- both price types are non-missing on the "
+ "same dates for a normal listing, so this rarely changes anything "
+ "in practice.",
+ default="adjusted_close",
+ typical_range="adjusted_close (recommended) or close.",
+ effect_increase="N/A -- a choice, not a magnitude.",
+ effect_decrease="N/A -- a choice, not a magnitude.",
+ tradeoffs="Essentially none for this strategy specifically; kept "
+ "for consistency with every other strategy's own signal_price_"
+ "type configuration.",
+ interactions="None -- this strategy has no other parameters to interact with.",
+ ),
+]
+
+
+def _lab(st: Any) -> None:
+ from quantlab.dashboard.explorer.labs.buy_and_hold import render
+
+ render(st)
+
+
+register_profile(
+ StrategyProfile(
+ strategy_name="buy_and_hold",
+ display_name="Buy & Hold",
+ category="Baseline",
+ overview_md=_OVERVIEW,
+ economic_intuition_md=_ECONOMIC_INTUITION,
+ mathematical_definition_md=_MATH,
+ assumptions_md=_ASSUMPTIONS,
+ diagnostics_md=_DIAGNOSTICS,
+ interpretation_md=_INTERPRETATION,
+ limitations_md=_LIMITATIONS,
+ parameters=_PARAMETERS,
+ lab=_lab,
+ )
+)
diff --git a/src/quantlab/dashboard/explorer/profiles/cross_sectional_momentum.py b/src/quantlab/dashboard/explorer/profiles/cross_sectional_momentum.py
new file mode 100644
index 0000000..7328bcf
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/profiles/cross_sectional_momentum.py
@@ -0,0 +1,608 @@
+"""Strategy Explorer profile for ``cross_sectional_momentum``."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import pandas as pd
+
+from quantlab.dashboard.explorer.profile import (
+ ParameterDoc,
+ ResultsDiagnostics,
+ StrategyProfile,
+ register_profile,
+)
+
+if TYPE_CHECKING:
+ from quantlab.config import ExperimentConfig
+ from quantlab.reporting.sections import DiagnosticsSection
+
+_OVERVIEW = """
+Cross-sectional momentum ranks a universe of assets by their recent
+performance and emits long signals for the strongest performers
+(optionally short signals for the weakest). Unlike time-series momentum --
+which asks "is THIS asset's own trend up or down" -- cross-sectional
+momentum only ever asks a relative
+question: "is this asset outperforming the OTHERS in the universe right
+now". An asset can have a positive score in a falling universe, or a
+negative score in a rising one.
+
+The score horizon is set by `lookback_period`; the configured rebalance
+frequency controls when rankings can change executed targets. Data needed:
+a reasonably broad universe of comparable assets (a sector, an asset
+class, a country set) with a shared history at least `lookback_period`
+long.
+"""
+
+_ECONOMIC_INTUITION = """
+Relative winner-minus-loser momentum has been documented historically over
+medium-term horizons (roughly 3-12 months) in several markets. Proposed drivers
+include investor underreaction to new information (prices adjust slowly,
+not instantly, to genuinely good/bad news) and herding/trend-following
+behaviour among market participants. This is a *relative*, not an
+absolute, bet: the strategy buys the best performers in the universe,
+whatever the universe as a whole is doing.
+"""
+
+_MATH = """
+`generate_signals()`'s pipeline, in order:
+
+1. **Score** -- `score = momentum(prices, lookback_period, skip_period)` =
+ `P_{t-skip} / P_{t-lookback} - 1` for every symbol, at every date.
+2. **Selection** -- `select_top_bottom(score, top_fraction, bottom_fraction
+ if long_short else 0.0)` picks the top `top_fraction` of the universe
+ (by score, that date) as `+1`, and -- only when `long_short=True` -- the
+ bottom `bottom_fraction` as `-1`. Every other asset is `0`. Selections
+ are disjoint: an asset is never both top and bottom. The ranking is
+ computed on every bar, but only values sampled by the portfolio's
+ rebalance schedule can alter executed targets.
+3. **`skip_period`** excludes the most recent periods from the lookback
+ window -- the classic "12-1" convention (12-month lookback, skip the
+ most recent month) exists because very recent short-term returns have
+ sometimes *reversed* rather than continued, which would otherwise partly cancel
+ out the momentum effect being captured.
+
+4. **`signal_scaling`** -- `binary` (default) emits the discrete
+ `{-1, 0, +1}` selection unchanged; `continuous` additionally scales
+ each selected asset's signal by its RANK within its own selected leg
+ (see the `signal_scaling` parameter below), still guaranteed monotone
+ in score and never zero for a selected asset. Either way, how much
+ capital each selected asset actually gets still also depends on the
+ portfolio allocator's own job downstream -- only the
+ `signal_proportional` allocator actually consumes a `continuous`
+ signal's magnitude; `equal_weight` discards it via `np.sign`.
+"""
+
+_ASSUMPTIONS = """
+**Economic**: the strategy assumes recent relative winners are more likely
+to outperform over its horizon in this universe; that may be absent or
+sample-specific.
+**Statistical**: momentum scores computed the same way across the whole
+universe are comparable (broadly similar volatility/liquidity regimes --
+comparing a mega-cap ETF's momentum score directly against a thinly-traded
+micro-cap's is less meaningful). **Implementation**: `top_fraction` (and
+`bottom_fraction`) leave enough assets in the selection to diversify
+idiosyncratic risk, given the configured universe size.
+"""
+
+_DIAGNOSTICS = """
+The lab below shows the momentum score itself for every universe member,
+a full ranking + selection snapshot on any chosen date, the actual
+cross-sectional test this strategy depends on (does a higher-RANKED
+asset go on to earn a higher subsequent return than a lower-ranked one,
+across the universe, at each date -- via a Spearman rank correlation and
+the realized top-minus-bottom spread return), a single-asset time-series
+diagnostic shown only for comparison, and a side-by-side comparison of
+the score at several different lookback windows.
+"""
+
+_INTERPRETATION = """
+A positive, reasonably stable rank correlation (and a positive top-minus-
+bottom spread) across most dates is descriptive support for the strategy's
+core premise on this data; a value near zero or negative weighs against it,
+regardless of how a particular backtest's aggregate performance looks. This
+is descriptive sample evidence, not a hypothesis test (overlapping holding periods are
+not independent observations). The single-asset time-series scatter
+answering a DIFFERENT question (does this one asset's own past predict
+its own future) can look positive even when the cross-sectional ranking
+signal is weak, or vice versa -- they are not substitutes for each other.
+In long-only mode the displayed bottom group is a diagnostic comparison,
+not a short book the strategy actually trades.
+The ranking snapshot is useful for sanity-checking `top_fraction`/
+`bottom_fraction` against the actual universe size -- e.g.
+`top_fraction=0.25` on a 4-symbol universe selects exactly one asset,
+which is a very different portfolio than the same fraction on 50 symbols.
+"""
+
+_LIMITATIONS = """
+**Momentum crashes**: momentum has historically suffered sharp, sudden
+reversals -- most notoriously around the 2009 market bottom -- when
+previously beaten-down assets rebound violently, hurting exactly the
+long-winners/short-losers positioning momentum takes. **Turnover**:
+changes in the top/bottom selection can generate meaningful turnover and
+transaction costs when they alter weights at rebalance dates, especially
+with a short `skip_period` or a volatile universe where rankings shuffle
+often. **Crowding**: momentum
+is one of the most widely traded factors; crowded positioning can amplify
+the crash risk above. **Small universes**: with few symbols,
+`top_fraction`/`bottom_fraction` select very few assets, concentrating
+idiosyncratic risk that a "diversified factor" framing usually assumes
+away.
+"""
+
+_REFERENCES = (
+ 'Jegadeesh & Titman (1993), ["Returns to Buying Winners and Selling '
+ 'Losers: Implications for Stock Market Efficiency"]('
+ "https://doi.org/10.1111/j.1540-6261.1993.tb04702.x), *Journal of "
+ "Finance* 48(1), is the foundational academic study of cross-sectional/"
+ "relative momentum -- it examines 3-12 month formation/holding periods "
+ '(and a 1-week-skip variant), not a literal "12-1" specification; the '
+ "specific 12-month-lookback/skip-one-month convention used by default "
+ "here is a common later variant in the literature/practice, not a direct "
+ "reproduction of the paper's methodology or evidence for this particular "
+ "universe."
+)
+
+_PARAMETERS = [
+ ParameterDoc(
+ name="lookback_period",
+ what="Total look-back window (periods) the momentum score is measured over.",
+ where="Step 1.",
+ why="Sets the horizon over which 'recent performance' is defined.",
+ default="252",
+ typical_range="126-252 periods (roughly 6-12 months of daily data).",
+ effect_increase="Captures a longer-horizon trend, less sensitive to "
+ "short-term noise, but slower to pick up a genuinely new trend.",
+ effect_decrease="More responsive to a recent shift in relative "
+ "performance, but noisier and more exposed to short-term reversal.",
+ tradeoffs="Horizon length vs. responsiveness -- 3-12 month "
+ "formation/holding horizons are common in the cited foundational study.",
+ interactions="Must exceed skip_period; interacts with the "
+ "portfolio's rebalance frequency (a lookback much shorter than the "
+ "rebalance interval largely resets the ranking every rebalance).",
+ ),
+ ParameterDoc(
+ name="skip_period",
+ what="Most recent periods excluded from the lookback window.",
+ where="Step 1, subtracted from the window used for the score.",
+ why="Very recent short-term returns have sometimes shown reversal; "
+ "skipping them reduces their influence on the momentum score.",
+ default="21",
+ typical_range="0-21 periods (0 to about 1 month of daily data).",
+ effect_increase="Excludes more recent history from the score -- "
+ "cleaner separation from short-term reversal, but the score reacts "
+ "more slowly to a genuine, very recent shift.",
+ effect_decrease="Includes more recent history -- more responsive, "
+ "but more exposed to the short-term-reversal effect canceling out "
+ "part of the momentum signal.",
+ tradeoffs="Purity of the momentum signal vs. responsiveness.",
+ interactions="Must be strictly less than lookback_period.",
+ ),
+ ParameterDoc(
+ name="top_fraction",
+ what="Fraction of the universe (by score, each date) selected as "
+ "the long side. The count is rounded down, with at least one asset "
+ "selected whenever the fraction is positive and data is available.",
+ where="Step 2.",
+ why="Controls concentration: how many of the best-ranked assets "
+ "actually get a position.",
+ default="0.25",
+ typical_range="0.1-0.5.",
+ effect_increase="More assets held -- more diversified, closer to "
+ "the whole universe's own behaviour, weaker tilt toward the very "
+ "best performers.",
+ effect_decrease="Fewer assets held -- more concentrated, a purer "
+ "bet on the top performers specifically, but more idiosyncratic "
+ "risk per position.",
+ tradeoffs="Diversification vs. concentration in the strongest signal.",
+ interactions="With long_short=True, top_fraction + bottom_fraction "
+ "must not exceed 1; with a small universe, a large fraction can "
+ "select nearly everyone, diluting the selection to almost nothing.",
+ ),
+ ParameterDoc(
+ name="bottom_fraction",
+ what="Fraction of the universe selected as the short side -- only "
+ "used when long_short=True, with the same floor/minimum-one count "
+ "rule as top_fraction.",
+ where="Step 2.",
+ why="Symmetric counterpart to top_fraction for the short leg.",
+ default="0.25",
+ typical_range="0.1-0.5.",
+ effect_increase="More short positions -- more diversified short "
+ "book, weaker conviction per short.",
+ effect_decrease="Fewer, higher-conviction short positions.",
+ tradeoffs="Same as top_fraction, applied to the short side.",
+ interactions="Ignored entirely when long_short=False; combined "
+ "with top_fraction must not exceed 1.",
+ ),
+ ParameterDoc(
+ name="long_short",
+ what="Whether the bottom-ranked assets are actively shorted "
+ "(True) or simply not held (False, long-only).",
+ where="Step 2 -- gates whether bottom_fraction has any effect at all.",
+ why="Many portfolios/mandates cannot or should not short; shorting "
+ "also adds financing, borrow and short-side risk.",
+ default="False",
+ typical_range="Boolean.",
+ effect_increase="N/A (boolean).",
+ effect_decrease="N/A (boolean).",
+ tradeoffs="True represents the classic long-winners/short-losers "
+ "construction but adds short-specific costs/risks (borrow and "
+ "unbounded theoretical loss on a runaway short); False is simpler "
+ "and avoids those, but no longer captures the short leg.",
+ interactions="bottom_fraction only matters when this is True.",
+ ),
+ ParameterDoc(
+ name="signal_scaling",
+ what="How the discrete top/bottom selection is expressed as a "
+ "signal magnitude: 'binary' (every selected asset gets identical "
+ "+1/-1 weight) or 'continuous' (each selected asset's weight is "
+ "its RANK within its own selected leg, divided by that leg's own "
+ "selected count -- e.g. the weakest of 4 selected longs gets "
+ "0.25, the strongest gets 1.0; the short leg mirrors this on the "
+ "most-negative-score side).",
+ where="Final signal output, after the Step 2 selection above.",
+ why="binary treats every selected name as an equally-strong bet; "
+ "continuous instead lets the strongest-ranked name in each leg "
+ "carry a larger weight than one that just barely qualified -- "
+ "WHICH assets are selected is unchanged either way, only their "
+ "relative size. Ranked within the leg rather than standardized "
+ "against the whole cross-section's mean/dispersion, which is NOT "
+ "guaranteed monotone in score when a leg straddles the "
+ "cross-sectional mean.",
+ default="binary",
+ typical_range="One of 'binary', 'continuous'.",
+ effect_increase="N/A -- a choice, not a magnitude.",
+ effect_decrease="N/A -- a choice, not a magnitude.",
+ tradeoffs="binary is simplest and treats every selected name "
+ "identically; continuous adds conviction-weighting within the "
+ "selection at the cost of a slightly less interpretable weight.",
+ interactions="Position SIZE within the selection is otherwise "
+ "entirely the portfolio allocator's responsibility, not this "
+ "parameter's -- continuous only reshapes the SIGNAL handed to it, "
+ "and only actually changes sizing under an allocator that reads "
+ "signal magnitude (e.g. 'signal_proportional'); config validation "
+ "rejects pairing non-binary scaling with 'equal_weight', which "
+ "would otherwise silently discard it back down to binary sizing.",
+ ),
+ ParameterDoc(
+ name="price_type",
+ what="Which price series ('adjusted_close' or 'close') "
+ "generate_signals() reads. Execution/costs always use the raw "
+ "close regardless.",
+ where="Feeds the momentum score in step 1, for every universe member.",
+ why="A split or large dividend on any one symbol shows up as a "
+ "price jump in raw close but not in adjusted close -- unadjusted, "
+ "it would distort that symbol's momentum score and its ranking "
+ "against the rest of the universe.",
+ default="adjusted_close",
+ typical_range="adjusted_close (recommended) or close.",
+ effect_increase="N/A -- a choice, not a magnitude.",
+ effect_decrease="N/A -- a choice, not a magnitude.",
+ tradeoffs="adjusted_close keeps corporate actions from distorting "
+ "relative rankings across the universe; close matches what was "
+ "literally quoted, useful mainly for auditing.",
+ interactions="Matters more here than for a single-asset strategy: "
+ "one mis-adjusted symbol distorts not just its own score but its "
+ "relative RANK against every other universe member.",
+ ),
+ ParameterDoc(
+ name="stop_loss_pct",
+ what="Fractional (e.g. 0.10 = 10%) gross-return threshold that "
+ "force-flattens ONE symbol's REAL executed position -- ranking "
+ "membership itself has no persistent state, but this operates on "
+ "the actual position held after the allocator/constraints/"
+ "rebalancing/execution, which can span several rebalances.",
+ where="Applied downstream of generate_signals() entirely -- see "
+ "`quantlab.backtesting.accounting._detect_stop_loss_take_profit`. "
+ "generate_signals() itself is unchanged by this parameter.",
+ why="A symbol can stay selected across several rebalances while "
+ "its own price moves sharply against the position -- this bounds "
+ "the realized loss on that specific holding, independent of "
+ "whether it is still ranked in the top/bottom fraction.",
+ default="None (disabled) -- enabling it changes no existing "
+ "behavior unless explicitly set.",
+ typical_range="0.05-0.20, or None to disable.",
+ effect_increase="More room before a forced exit -- fewer stop-outs "
+ "on ordinary volatility, larger potential realized loss per trade.",
+ effect_decrease="Tighter monetary risk control, more prone to "
+ "being stopped out by a temporary adverse move.",
+ tradeoffs="Realized-loss protection vs. premature exits. Evaluated "
+ "on GROSS (pre-cost) return -- QuantLab's execution cost model is "
+ "portfolio-level only, so an exact net-of-cost trigger is not "
+ "presently computable; this is a disclosed design convention, not "
+ "a universal definition.",
+ interactions="Applies independently per symbol (no position_"
+ "groups declared). Once triggered, no immediate re-entry at a "
+ "rebased price -- flat until that symbol's next real entry (a "
+ "fresh flat-to-non-flat transition of the executed weight), even "
+ "if it re-qualifies for the top/bottom fraction sooner.",
+ ),
+ ParameterDoc(
+ name="take_profit_pct",
+ what="Fractional (e.g. 0.10 = 10%) gross-return threshold that "
+ "force-flattens one symbol's REAL executed position on the "
+ "favorable side -- locks in a gain directly rather than waiting "
+ "for it to drop out of the ranking.",
+ where="Same mechanism as stop_loss_pct, opposite direction.",
+ why="Realizes a gain directly once a target is reached, instead "
+ "of depending on the symbol eventually falling out of the top/"
+ "bottom fraction.",
+ default="None (disabled) -- enabling it changes no existing "
+ "behavior unless explicitly set.",
+ typical_range="0.05-0.20, or None to disable.",
+ effect_increase="Lets more of a favorable move run before locking it in.",
+ effect_decrease="Locks in gains earlier, potentially forfeiting "
+ "further outperformance.",
+ tradeoffs="Locking in gains early vs. capturing more outperformance.",
+ interactions="Independent of stop_loss_pct; see its own doc for "
+ "the shared gross-return/re-entry conventions.",
+ ),
+]
+
+
+def _lab(st: Any) -> None:
+ from quantlab.dashboard.explorer.labs.cross_sectional_momentum import render
+
+ render(st)
+
+
+#: Fixed default forward-return horizon for this diagnostic -- matching
+#: the interactive lab's own default. Deliberately INDEPENDENT of
+#: ``skip_period`` (a strategy parameter meaning "how much of the recent
+#: past to exclude from the score", not "how long to hold looking
+#: forward"): ``skip_period=0`` is a perfectly valid strategy config, but
+#: ``holding_period=0`` is rejected by ``cross_sectional_momentum_
+#: persistence`` (must be >= 1), and a `skip_period` above 252 (also
+#: valid -- only constrained to be < lookback_period) would put the
+#: Results-tab slider's default value outside its own 1-252 range. Never
+#: reuse ``skip_period`` here again.
+_DEFAULT_DIAGNOSTIC_HOLDING_PERIOD = 21
+
+
+@dataclass(frozen=True)
+class CrossSectionalMomentumDiagnostics:
+ """Cross-sectional rank-correlation/spread persistence over the sample.
+
+ ``persistence``/``mean_rank_correlation``/``mean_top_minus_bottom`` are
+ computed at ``holding_period`` (fixed to
+ ``_DEFAULT_DIAGNOSTIC_HOLDING_PERIOD`` -- see that constant's own
+ docstring for why it is independent of ``skip_period``) for the
+ exported HTML report, which has no interactivity. ``prices`` and the
+ other resolved parameters are carried alongside so the Results tab can
+ recompute this SAME diagnostic at a user-chosen holding_period on
+ demand -- a cheap, purely local recomputation, not a backtest re-run
+ (see ``_render_diagnostics``).
+ """
+
+ holding_period: int
+ lookback_period: int
+ skip_period: int
+ top_fraction: float
+ effective_bottom_fraction: float
+ long_short: bool
+ prices: pd.DataFrame
+ mean_rank_correlation: float
+ mean_top_minus_bottom: float
+ persistence: pd.DataFrame
+
+
+def _persistence_table(
+ prices: pd.DataFrame,
+ lookback_period: int,
+ skip_period: int,
+ holding_period: int,
+ *,
+ top_fraction: float,
+ bottom_fraction: float,
+) -> tuple[pd.DataFrame, float, float]:
+ from quantlab.features.momentum import cross_sectional_momentum_persistence
+
+ persistence = cross_sectional_momentum_persistence(
+ prices,
+ lookback_period,
+ skip_period,
+ holding_period,
+ top_fraction=top_fraction,
+ bottom_fraction=bottom_fraction,
+ )
+ mean_corr = (
+ float(persistence["rank_correlation"].mean())
+ if not persistence.empty
+ else float("nan")
+ )
+ mean_spread = (
+ float(persistence["top_minus_bottom"].mean())
+ if not persistence.empty
+ else float("nan")
+ )
+ return persistence, mean_corr, mean_spread
+
+
+def _compute_diagnostics(
+ data: pd.DataFrame, cfg: ExperimentConfig
+) -> CrossSectionalMomentumDiagnostics:
+ from quantlab.data.base import price_matrix
+
+ params = cfg.strategy_parameters
+ lookback = int(params.get("lookback_period", 252))
+ skip = int(params.get("skip_period", 21))
+ top_fraction = float(params.get("top_fraction", 0.25))
+ long_short = bool(params.get("long_short", False))
+ bottom_fraction = float(params.get("bottom_fraction", 0.25))
+ # When long_short=False there is no traded short book -- a comparison
+ # bottom fraction (capped to what fits alongside top_fraction) is used
+ # purely to compute the diagnostic, exactly like the interactive lab.
+ comparison_bottom_fraction = min(top_fraction, max(0.0, 1.0 - top_fraction))
+ effective_bottom = bottom_fraction if long_short else comparison_bottom_fraction
+
+ price_type = cfg.strategy.signal_price_type
+ prices = price_matrix(data, adjusted=price_type != "close")
+ # Fixed, skip_period-INDEPENDENT default (see
+ # _DEFAULT_DIAGNOSTIC_HOLDING_PERIOD's own docstring for why). The
+ # Results tab lets the user override it independently (see
+ # _render_diagnostics); the exported HTML report always uses this
+ # fixed value so the report stays stable and reproducible.
+ holding_period = _DEFAULT_DIAGNOSTIC_HOLDING_PERIOD
+ persistence, mean_corr, mean_spread = _persistence_table(
+ prices,
+ lookback,
+ skip,
+ holding_period,
+ top_fraction=top_fraction,
+ bottom_fraction=effective_bottom,
+ )
+ return CrossSectionalMomentumDiagnostics(
+ holding_period=holding_period,
+ lookback_period=lookback,
+ skip_period=skip,
+ top_fraction=top_fraction,
+ effective_bottom_fraction=effective_bottom,
+ long_short=long_short,
+ prices=prices,
+ mean_rank_correlation=mean_corr,
+ mean_top_minus_bottom=mean_spread,
+ persistence=persistence,
+ )
+
+
+def _render_diagnostics(st: Any, result: CrossSectionalMomentumDiagnostics) -> None:
+ from quantlab.dashboard.explorer.shared_components import (
+ render_price_chart,
+ strong,
+ )
+
+ st.subheader("Cross-sectional momentum persistence")
+ holding_period = st.slider(
+ "Forward-return horizon (periods) for this diagnostic",
+ 1,
+ 252,
+ result.holding_period,
+ key="csmom_results_diag_holding_period",
+ help=(
+ "Diagnostic setting only -- changing this does not rerun or "
+ "alter the backtest, only how many periods ahead this "
+ "persistence check looks."
+ ),
+ )
+ st.caption(
+ "Forward-return horizon used only for this diagnostic; it does not "
+ "change the strategy or its backtest. Longer horizons produce "
+ "overlapping forward-return windows across consecutive dates, so "
+ "the apparent number of observations overstates the independent "
+ "information actually available -- treat this as descriptive "
+ "sample evidence, not a hypothesis test."
+ )
+ if holding_period == result.holding_period:
+ persistence = result.persistence
+ mean_corr = result.mean_rank_correlation
+ mean_spread = result.mean_top_minus_bottom
+ else:
+ persistence, mean_corr, mean_spread = _persistence_table(
+ result.prices,
+ result.lookback_period,
+ result.skip_period,
+ holding_period,
+ top_fraction=result.top_fraction,
+ bottom_fraction=result.effective_bottom_fraction,
+ )
+ st.caption(
+ "The question this strategy actually trades: do higher-ranked "
+ "assets go on to earn higher subsequent returns, RELATIVE TO EACH "
+ f"OTHER, over a {holding_period}-period horizon? Mean "
+ f"rank correlation: {strong(f'{mean_corr:.3f}')}. Mean "
+ f"top-minus-bottom spread: {strong(f'{mean_spread:.3%}')}."
+ + (
+ ""
+ if result.long_short
+ else " (long_short is disabled -- the bottom group here is a "
+ "research comparison only, not a short book this backtest "
+ "actually held.)"
+ ),
+ unsafe_allow_html=True,
+ )
+ if persistence.empty:
+ st.info("Not enough dates with at least 3 scored assets in this result.")
+ return
+ render_price_chart(
+ st,
+ {"Rank correlation": persistence["rank_correlation"]},
+ title="Spearman rank correlation: momentum score vs. subsequent return",
+ yaxis_title="Rank correlation",
+ )
+ render_price_chart(
+ st,
+ {"Top - bottom spread return": persistence["top_minus_bottom"]},
+ title=f"Realized top-minus-bottom {holding_period}-period return",
+ yaxis_title="Return",
+ )
+
+
+def _report_section(result: CrossSectionalMomentumDiagnostics) -> DiagnosticsSection:
+ from quantlab.dashboard.explorer.shared_components import live_widget_value
+ from quantlab.reporting.sections import DiagnosticsSection
+
+ # Reflects the user's own live Results-tab slider choice (see
+ # _render_diagnostics), not always result.holding_period -- falls back
+ # to it when the dashboard isn't running at all (e.g. the CLI's own
+ # report generation) or that slider was never rendered this session.
+ holding_period = live_widget_value(
+ "csmom_results_diag_holding_period", result.holding_period
+ )
+ if holding_period == result.holding_period:
+ mean_corr = result.mean_rank_correlation
+ mean_spread = result.mean_top_minus_bottom
+ else:
+ _persistence, mean_corr, mean_spread = _persistence_table(
+ result.prices,
+ result.lookback_period,
+ result.skip_period,
+ holding_period,
+ top_fraction=result.top_fraction,
+ bottom_fraction=result.effective_bottom_fraction,
+ )
+ table = pd.DataFrame(
+ [
+ ("Holding period (periods)", holding_period),
+ ("Long/short", result.long_short),
+ ("Mean rank correlation", mean_corr),
+ ("Mean top-minus-bottom spread", mean_spread),
+ ],
+ columns=["Metric", "Value"],
+ )
+ return DiagnosticsSection(
+ table=table,
+ note=(
+ "Cross-sectional momentum persistence: does a higher-ranked "
+ "asset earn a higher subsequent return than a lower-ranked one, "
+ "across the universe? Descriptive sample evidence, not a "
+ "hypothesis test -- overlapping holding periods are not "
+ "independent."
+ ),
+ )
+
+
+register_profile(
+ StrategyProfile(
+ strategy_name="cross_sectional_momentum",
+ display_name="Cross-Sectional Momentum",
+ category="Trend / momentum",
+ overview_md=_OVERVIEW,
+ economic_intuition_md=_ECONOMIC_INTUITION,
+ mathematical_definition_md=_MATH,
+ assumptions_md=_ASSUMPTIONS,
+ diagnostics_md=_DIAGNOSTICS,
+ interpretation_md=_INTERPRETATION,
+ limitations_md=_LIMITATIONS,
+ references_md=_REFERENCES,
+ parameters=_PARAMETERS,
+ lab=_lab,
+ results_diagnostics=ResultsDiagnostics(
+ key="cross_sectional_momentum_diagnostics",
+ compute=_compute_diagnostics,
+ render=_render_diagnostics,
+ report_section=_report_section,
+ ),
+ )
+)
diff --git a/src/quantlab/dashboard/explorer/profiles/mean_reversion.py b/src/quantlab/dashboard/explorer/profiles/mean_reversion.py
new file mode 100644
index 0000000..89c0b25
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/profiles/mean_reversion.py
@@ -0,0 +1,603 @@
+"""Strategy Explorer profile for ``mean_reversion``."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import pandas as pd
+
+from quantlab.dashboard.explorer.profile import (
+ ParameterDoc,
+ ResultsDiagnostics,
+ StrategyProfile,
+ register_profile,
+)
+
+if TYPE_CHECKING:
+ from quantlab.config import ExperimentConfig
+ from quantlab.reporting.sections import DiagnosticsSection
+
+_OVERVIEW = """
+Mean reversion bets that a price which has moved unusually far from its
+own recent average will tend to move back toward it. Unlike pairs
+trading (which trades a *relationship* between two assets), this is a
+single-asset strategy: it watches one symbol's own trailing statistics
+and trades against short-term overreaction in that symbol alone.
+
+Typical horizon: several bars to a few dozen bars (often days to a few weeks
+on daily data), set mostly by `lookback_period` (how "recent" is measured)
+and how quickly a given instrument tends to snap back. Data needed: prices
+for the traded symbol, long enough to build a stable rolling mean/std before
+trading starts.
+"""
+
+_ECONOMIC_INTUITION = """
+Some short-term price moves may be driven by order flow, liquidity
+conditions or overreaction rather than a permanent repricing. The strategy
+bets that sufficiently unusual deviations will move back toward a recent
+statistical reference level. This premise is more plausible
+on liquid, range-bound-ish instruments; it works poorly on an asset that
+is genuinely re-rating to a new regime (see Limitations).
+"""
+
+_MATH = """
+`generate_signals()`'s pipeline, in order:
+
+1. **Centered indicator** -- one of three primary indicators
+ (`indicator`), each turned into a zero-centered series where negative
+ means "below normal" (a long candidate) and positive means "above
+ normal" (a short candidate):
+ - `zscore`: `(price - rolling_mean) / rolling_std` over `lookback_period`.
+ - `rsi`: Wilder's RSI over `lookback_period`, minus `50` (RSI's own
+ neutral midpoint) so oversold/overbought reads as negative/positive.
+ - `percentile`: the price's trailing percentile rank within
+ `lookback_period` (0 = lowest, 1 = highest), minus `0.5`.
+
+ Two further indicators, `bollinger` and `distance_ma`, are implemented
+ and fully usable (Python/YAML, robustness sweeps) but not offered in
+ this UI: `bollinger` is `(price - rolling_mean) / (bollinger_num_std *
+ rolling_std)` -- the same rolling mean/std construction as `zscore`,
+ merely rescaled, so it rarely produces a materially different
+ backtest. `distance_ma` is `(price - rolling_mean) / rolling_mean` --
+ normalized by the mean's own level rather than volatility, so unlike
+ `bollinger` it CAN diverge from `zscore` materially when the
+ volatility regime shifts, even though it is excluded from this UI for
+ the same "not offered as a primary choice" reason.
+2. **State machine**, walked one bar at a time per symbol, identical
+ regardless of which indicator fed it (it only ever compares a
+ threshold against the indicator's absolute value):
+ - If the indicator is undefined (insufficient trailing history), force
+ flat.
+ - If `|indicator| > stop_threshold` (when set), force flat regardless
+ of current state.
+ - Flat state: enter long when `indicator < -entry_threshold`; enter
+ short when `indicator > entry_threshold` AND `long_only=False` --
+ `long_only` gates the short-entry branch directly inside this state
+ machine, so a short position is never entered in the first place
+ when it is `True`.
+ - In a position: exit when the indicator crosses back through
+ `-exit_threshold` (long) or `exit_threshold` (short).
+
+`entry_threshold`/`exit_threshold`/`stop_threshold` are on the CHOSEN
+indicator's own scale -- a threshold of `2.0` means very different things
+for `zscore` (2 standard deviations) vs. `rsi` (would mean RSI 48-52,
+barely oversold at all) vs. `percentile` (meaningless above `0.5`). Left
+unset, each defaults to a value sized for that specific indicator (see
+each parameter's own doc below); switching `indicator` on an otherwise-
+unchanged config silently keeps whatever thresholds were explicitly set,
+which may no longer make sense on the new indicator's scale -- always
+re-check thresholds after changing `indicator`.
+
+The state is a strategy signal, not a final portfolio weight: allocation,
+constraints, rebalancing and execution still act downstream. Unlike pairs
+trading, this single-asset mean-reversion strategy has no hedge ratio or second
+instrument, and it does not apply a stationarity test before opening a
+position. It therefore relies on the selected indicator to identify potential
+mean-reversion opportunities without first verifying that the underlying
+series is stationary.
+"""
+
+_ASSUMPTIONS = """
+**Economic**: the price's short-term deviations are assumed to be
+temporary around a comparatively stable recent level, rather than the start of a
+sustained re-rating. **Statistical**: the price series (or at least its
+short-term behaviour) is closer to mean-reverting than to a random walk
+or a trend -- see the lab's own ADF/Hurst diagnostics for whether that
+actually holds on the chosen instrument and period. **Implementation**:
+`lookback_period` is long enough to give a stable rolling mean/std, but
+short enough that "recent average" still means something economically
+(a 5-year lookback on a stock that re-rated 2 years ago is not a useful
+reference level).
+"""
+
+_DIAGNOSTICS = """
+The lab below compares the three primary indicators (RSI, rolling z-score,
+rolling percentile rank) on the same price series so their differences on
+identical data are directly visible -- Bollinger Bands and distance to
+moving average are also implemented (see Mathematical definition) but not
+shown in this comparison: Bollinger rarely diverges materially from
+zscore (the same mean/std construction, merely rescaled), while distance
+to moving average is left out for a different reason -- it is not
+volatility-normalized, so it can diverge from zscore materially when the
+volatility regime shifts -- plus the actual backtestable state machine
+overlaid on the currently selected `indicator` and thresholds, plus
+stationarity diagnostics (ADF,
+half-life, Hurst exponent) on the actual instrument and period being
+considered -- asking whether this sample is consistent with mean
+reversion before looking at trade-level performance. These diagnostics
+are sensitive to their estimator and test specification; they do not
+validate the strategy.
+"""
+
+_INTERPRETATION = """
+With QuantLab's default constant-only ADF regression and AIC lag selection,
+a low p-value is evidence against a unit root on the tested sample; it is
+not proof of stationarity. A Hurst estimate below 0.5 is a separate,
+descriptive indication of anti-persistence, not a hypothesis test. A finite
+half-life much longer than `lookback_period` suggests that the indicator's
+reference window may be short relative to the estimated speed of reversion.
+The entry/exit thresholds do not impose a maximum holding period: a position
+can remain open until its exit, stop or missing-data condition is reached.
+"""
+
+_LIMITATIONS = """
+**Regime shift**: an asset can permanently re-rate (a real fundamental
+change) rather than mean-revert -- indicator-based entries have no way to
+tell "overreaction" apart from "the mean has genuinely moved", and
+`stop_threshold` only limits the indicator's own deviation tolerated
+before an exit is requested when that happens, not the realized monetary
+loss (gaps, execution delay, and a moving mean/volatility or costs can
+still produce a larger loss than the indicator distance alone would
+suggest).
+**Trending markets**: mean reversion structurally underperforms during
+sustained trends, since every "extreme" reading keeps getting more
+extreme instead of reverting -- see the Hurst/ADF diagnostics to assess
+whether the instrument is currently more likely to be mean-reverting or
+trending.
+**Transaction costs**: frequent small round trips (a natural consequence
+of tight `entry_threshold`/`exit_threshold` gaps) are especially
+vulnerable to transaction costs, which can quickly erode the typically
+modest edge per trade.
+**Parameter instability**: the "right" lookback/thresholds can drift over
+time as an instrument's own volatility regime changes, and thresholds
+tuned for one `indicator` are not portable to another (see Mathematical
+definition).
+"""
+
+_REFERENCES = (
+ "The [statsmodels ADF documentation](https://www.statsmodels.org/stable/"
+ "generated/statsmodels.tsa.stattools.adfuller.html) specifies the unit-"
+ "root null, constant/trend choices and AIC lag selection used by "
+ "QuantLab's wrapper. It supports interpretation of the test, not the "
+ "claim that a price-level mean-reversion strategy is profitable.\n\n"
+ "Ernest P. Chan's [*Algorithmic Trading: Winning Strategies and Their "
+ "Rationale*](https://onlinelibrary.wiley.com/doi/book/10.1002/"
+ "9781118676998) (Wiley, 2013) gives a practical treatment of mean-"
+ "reversion research and implementation, including stationarity, "
+ "half-life and trading-rule considerations. It is practical guidance, "
+ "not evidence that a particular configuration will remain profitable."
+)
+
+_PARAMETERS = [
+ ParameterDoc(
+ name="lookback_period",
+ what="Trailing window (periods) used for the rolling mean/std (or "
+ "RSI/percentile window) that defines the centered indicator.",
+ where="Step 1 -- every subsequent decision is a function of this indicator.",
+ why="Defines what 'normal' means: too short and it chases noise, "
+ "too long and it stops describing the current regime.",
+ default="20",
+ typical_range="10-60 periods.",
+ effect_increase="A smoother, more stable reference level, but "
+ "slower to adapt if the instrument's own typical range has "
+ "genuinely shifted.",
+ effect_decrease="Faster adaptation to a shifting regime, but a "
+ "noisier indicator more prone to false signals.",
+ tradeoffs="Stability of the reference level vs. responsiveness to "
+ "genuine regime change.",
+ interactions="Sets the scale the lab's ADF/half-life diagnostics "
+ "should be compared against -- a half-life much longer than "
+ "lookback_period means the window is too short to capture a full "
+ "reversion cycle.",
+ ),
+ ParameterDoc(
+ name="indicator",
+ what="Which zero-centered indicator drives the state machine. "
+ "Primary choices: 'zscore', 'rsi' or 'percentile' (see "
+ "Mathematical definition for each formula). 'bollinger' and "
+ "'distance_ma' are also accepted (Python/YAML) but not offered in "
+ "this UI: 'bollinger' is a close variant of zscore (the same "
+ "rolling mean/std construction, merely rescaled), so it rarely "
+ "diverges from it materially, but 'distance_ma' normalizes by the "
+ "rolling mean's own level rather than volatility and so CAN "
+ "diverge from zscore materially when the volatility regime shifts.",
+ where="Step 1 -- determines what feeds every subsequent decision.",
+ why="Different indicators make different bets about what 'unusual' "
+ "means: a z-score is scale-free relative to recent volatility, RSI "
+ "is a bounded oscillator based on the ratio of recent average "
+ "gains to average losses, and percentile rank is a purely "
+ "non-parametric 'how extreme relative to recent history'.",
+ default="zscore",
+ typical_range="One of 'zscore', 'rsi', 'percentile'.",
+ effect_increase="N/A -- a choice, not a magnitude.",
+ effect_decrease="N/A -- a choice, not a magnitude.",
+ tradeoffs="z-score standardizes deviations using the rolling mean "
+ "and volatility, so its interpretation is most natural when the "
+ "local distribution and volatility regime are reasonably stable. "
+ "RSI and percentile are bounded and less sensitive to the asset's "
+ "absolute price scale, but they compress information about the "
+ "magnitude of deviations; percentile, in particular, captures "
+ "rank rather than how far beyond an extreme the observation lies.",
+ interactions="entry_threshold/exit_threshold/stop_threshold are on "
+ "THIS indicator's own scale -- changing indicator without "
+ "re-checking thresholds can silently produce a state machine that "
+ "almost never trades (thresholds too wide for the new scale) or "
+ "trades constantly (too narrow). bollinger_num_std only applies "
+ "when indicator='bollinger'.",
+ ),
+ ParameterDoc(
+ name="bollinger_num_std",
+ what="Number of standard deviations the Bollinger bands extend "
+ "from the rolling mean -- only used when indicator='bollinger', "
+ "which is available (Python/YAML) but not offered in this UI's "
+ "Indicator choice; see that parameter's own doc.",
+ where="Step 1, bollinger branch only -- rescales the centered "
+ "indicator so a threshold of 1.0 means 'price outside the bands'.",
+ why="Sets how wide 'the bands' are, independent of the entry/exit/"
+ "stop thresholds themselves.",
+ default="2.0",
+ typical_range="1.5-3.0.",
+ effect_increase="Wider bands -- a given entry_threshold now "
+ "requires a larger absolute price move to trigger.",
+ effect_decrease="Narrower bands -- more sensitive entries for the "
+ "same entry_threshold.",
+ tradeoffs="Conventional Bollinger practice (2.0) vs. a "
+ "deliberately wider/narrower band for this instrument's own "
+ "volatility character.",
+ interactions="Has no effect at all unless indicator='bollinger'.",
+ ),
+ ParameterDoc(
+ name="entry_threshold",
+ what="Indicator magnitude (on the chosen indicator's own scale) "
+ "that opens a new position.",
+ where="State machine, flat-state entry condition.",
+ why="Sets how unusual a deviation has to be before it's worth trading.",
+ default="Indicator-specific: 2.0 (zscore), 1.0 (bollinger), 20.0 "
+ "(rsi, i.e. RSI below 30 or above 70), 0.05 (distance_ma, a 5% "
+ "move), 0.45 (percentile, i.e. below the 5th or above the 95th "
+ "percentile) -- applied only when left unset (None).",
+ typical_range="Depends on indicator; see default above.",
+ effect_increase="Fewer, more extreme entries -- higher conviction "
+ "per trade, lower turnover.",
+ effect_decrease="More frequent entries on smaller deviations -- "
+ "more trades, more exposure to noise.",
+ tradeoffs="Trade frequency vs. conviction per trade.",
+ interactions="Must exceed exit_threshold; must be below "
+ "stop_threshold when set. Its practical meaning changes entirely "
+ "with indicator -- see that parameter's own doc.",
+ ),
+ ParameterDoc(
+ name="exit_threshold",
+ what="Indicator magnitude (crossed on the way back toward zero) "
+ "that closes an open position.",
+ where="State machine, in-position exit condition.",
+ why="Decides how much of the reversion to capture before closing.",
+ default="Indicator-specific: 0.5 (zscore), 0.2 (bollinger), 10.0 "
+ "(rsi), 0.01 (distance_ma), 0.10 (percentile) -- applied only "
+ "when left unset (None).",
+ typical_range="Depends on indicator; see default above.",
+ effect_increase="Exits earlier, leaving more of a full reversion "
+ "uncaptured but reducing time-in-trade.",
+ effect_decrease="Holds for a more complete reversion, at the cost "
+ "of more time exposed to a reversal.",
+ tradeoffs="Captured reversion vs. time-in-trade risk.",
+ interactions="Must be strictly below entry_threshold.",
+ ),
+ ParameterDoc(
+ name="stop_threshold",
+ what="Indicator magnitude that force-closes a position regardless "
+ "of direction -- protection against a deviation that keeps "
+ "widening instead of reverting (may indicate a regime shift or "
+ "model breakdown rather than an ordinary fluctuation).",
+ where="State machine, after unavailable-data handling and before "
+ "entry or normal exit logic.",
+ why="Limits the indicator's own deviation tolerated before "
+ "requesting an exit when the mean-reversion premise itself has "
+ "broken down for this instrument -- it does not cap the realized "
+ "monetary loss (gaps, execution delay, and a moving mean/"
+ "volatility or costs can still produce a larger loss than the "
+ "indicator distance alone would suggest).",
+ default="Indicator-specific: 4.0 (zscore), 1.5 (bollinger), 45.0 "
+ "(rsi), 0.15 (distance_ma), 0.49 (percentile) -- applied when this "
+ "parameter is left out entirely. Pass an explicit stop_threshold="
+ "None to disable the stop altogether (the state machine then never "
+ "force-closes on indicator magnitude, only on the ordinary exit "
+ "condition or missing data).",
+ typical_range="Typically 1.5-2x entry_threshold; omit the "
+ "parameter to use the indicator-specific default, or pass None to "
+ "disable it.",
+ effect_increase="More room before an exit is requested -- fewer "
+ "stop-outs on noise, larger potential loss per trade.",
+ effect_decrease="Tighter risk control, more prone to being stopped "
+ "out by a temporary overshoot that would otherwise have reverted.",
+ tradeoffs="Downside protection vs. premature stop-outs.",
+ interactions="Must be strictly greater than entry_threshold.",
+ ),
+ ParameterDoc(
+ name="long_only",
+ what="Whether short entries are structurally disabled.",
+ where="Gates the short-entry branch directly inside the state "
+ "machine (step 2) -- a short position is never entered in the "
+ "first place when True, there is no separate final clip.",
+ why="Many portfolios/mandates cannot or should not short. "
+ "Enabling long_only also changes turnover and exposure because "
+ "only the negative-indicator entry side can generate positions.",
+ default="True",
+ typical_range="Boolean.",
+ effect_increase="N/A (boolean).",
+ effect_decrease="N/A (boolean).",
+ tradeoffs="True removes short-side opportunities but avoids "
+ "short-specific costs/constraints (borrow, uptick rules); False "
+ "represents reversion in both directions but adds short-side risk.",
+ interactions="With False, entry_threshold/exit_threshold/"
+ "stop_threshold apply symmetrically to both the long and short side.",
+ ),
+ ParameterDoc(
+ name="stop_loss_pct",
+ what="Fractional (e.g. 0.10 = 10%) gross-return threshold that "
+ "force-flattens this symbol's REAL executed position -- an "
+ "additional, independent risk control on top of stop_threshold "
+ "(which limits the INDICATOR's own deviation, not a monetary "
+ "loss).",
+ where="Applied after the backtest allocator/constraints/"
+ "rebalancing/execution -- on the position actually held, not on "
+ "this strategy's raw signal (a signal is not necessarily a "
+ "realized position). See `quantlab.backtesting.accounting."
+ "_detect_stop_loss_take_profit`.",
+ why="stop_threshold protects against the mean-reversion premise "
+ "itself breaking down (the indicator keeps widening); stop_loss_pct "
+ "protects against realized monetary loss regardless of what the "
+ "indicator says, e.g. from gaps, execution delay or costs.",
+ default="None (disabled) -- enabling it changes no existing "
+ "behavior unless explicitly set.",
+ typical_range="0.05-0.20, or None to disable.",
+ effect_increase="More room before a forced exit -- fewer stop-outs "
+ "on ordinary volatility, larger potential realized loss per trade.",
+ effect_decrease="Tighter monetary risk control, more prone to "
+ "being stopped out by a temporary adverse move.",
+ tradeoffs="Realized-loss protection vs. premature exits. Evaluated "
+ "on GROSS (pre-cost) return -- QuantLab's execution cost model is "
+ "portfolio-level only, so an exact net-of-cost trigger is not "
+ "presently computable; this is a disclosed design convention, not "
+ "a universal definition.",
+ interactions="Independent of entry_threshold/exit_threshold/"
+ "stop_threshold -- both mechanisms can be active at once, or "
+ "either alone. Once triggered, no immediate re-entry at a rebased "
+ "price -- flat until the position's next real entry.",
+ ),
+ ParameterDoc(
+ name="take_profit_pct",
+ what="Fractional (e.g. 0.10 = 10%) gross-return threshold that "
+ "force-flattens this symbol's REAL executed position on the "
+ "favorable side -- locks in a gain rather than waiting for "
+ "exit_threshold's ordinary mean-reversion exit.",
+ where="Same mechanism as stop_loss_pct, opposite direction.",
+ why="Realizes a gain directly once a target is reached, instead "
+ "of depending on the indicator reverting all the way back through "
+ "exit_threshold (which may give back some of the gain first).",
+ default="None (disabled) -- enabling it changes no existing "
+ "behavior unless explicitly set.",
+ typical_range="0.05-0.20, or None to disable.",
+ effect_increase="Lets more of a favorable move run before locking it in.",
+ effect_decrease="Locks in gains earlier, potentially forfeiting "
+ "further upside.",
+ tradeoffs="Locking in gains early vs. capturing a larger reversion.",
+ interactions="Independent of stop_loss_pct and the entry/exit/"
+ "stop_threshold family; see stop_loss_pct's own doc for the "
+ "shared gross-return/re-entry conventions.",
+ ),
+ ParameterDoc(
+ name="price_type",
+ what="Which price series ('adjusted_close' or 'close') "
+ "generate_signals() reads. Execution/costs always use the raw "
+ "close regardless.",
+ where="Feeds the centered indicator computation in step 1.",
+ why="A split or large dividend shows up as a price jump in raw "
+ "close but not in adjusted close -- unadjusted, it would look "
+ "exactly like an extreme z-score deviation.",
+ default="adjusted_close",
+ typical_range="adjusted_close (recommended) or close.",
+ effect_increase="N/A -- a choice, not a magnitude.",
+ effect_decrease="N/A -- a choice, not a magnitude.",
+ tradeoffs="adjusted_close avoids false signals from corporate "
+ "actions; close matches what was literally quoted, useful mainly "
+ "for auditing.",
+ interactions="A dividend/split near the current date would "
+ "otherwise register as a large, entirely spurious entry signal.",
+ ),
+]
+
+
+def _lab(st: Any) -> None:
+ from quantlab.dashboard.explorer.labs.mean_reversion import render
+
+ render(st)
+
+
+@dataclass(frozen=True)
+class MeanReversionDiagnostics:
+ """State-machine activity + stationarity, one row per traded symbol.
+
+ ``indicators``/``states`` carry the full per-symbol series for the
+ Results-tab chart -- the report's own table (``_report_section``) is a
+ snapshot only, mirroring ``PairDiagnostics``'s own table-vs-chart split.
+ """
+
+ indicator: str
+ summary: pd.DataFrame
+ indicators: dict[str, pd.Series]
+ states: dict[str, pd.Series]
+ entry_threshold: float
+ exit_threshold: float
+ stop_threshold: float | None
+
+
+def _compute_diagnostics(
+ data: pd.DataFrame, cfg: ExperimentConfig
+) -> MeanReversionDiagnostics:
+ from quantlab.data.base import price_matrix
+ from quantlab.features.mean_reversion import half_life as compute_half_life
+ from quantlab.features.stationarity import adf_test, hurst_exponent
+ from quantlab.strategies.mean_reversion import (
+ MeanReversionStrategy,
+ _centered_indicator,
+ )
+
+ # Built from the SAME constructor call the real backtest made (not a
+ # re-derivation of defaults/threshold-resolution here) -- see
+ # MeanReversionStrategy's own None-vs-unset stop_threshold semantics,
+ # easy to get subtly wrong by hand.
+ strategy = MeanReversionStrategy(**cfg.strategy_parameters)
+ # Engine-injected context (see BaseStrategy.symbol_calendars's own
+ # docstring) -- set here too, exactly as BacktestEngine.run() does,
+ # so this diagnostic's indicator/signals are computed on each symbol's
+ # own native calendar rather than silently falling back to the
+ # closure-padded combined timeline under a mixed-calendar universe.
+ strategy.symbol_calendars = {
+ instrument.symbol: instrument.calendar for instrument in cfg.data.instruments
+ }
+ price_type = cfg.strategy.signal_price_type
+ prices = price_matrix(data, adjusted=price_type != "close")
+ indicator = _centered_indicator(
+ prices,
+ strategy.indicator,
+ strategy.lookback_period,
+ strategy.bollinger_num_std,
+ strategy.symbol_calendars,
+ )
+ state = strategy.generate_signals(data)
+ reasons = strategy.explain_signals(data)
+
+ rows = []
+ indicators: dict[str, pd.Series] = {}
+ states: dict[str, pd.Series] = {}
+ for symbol in prices.columns:
+ symbol_state = state[symbol]
+ symbol_detail = reasons.detail_code[symbol]
+ tested = prices[symbol].dropna()
+ adf = adf_test(tested) if len(tested) >= 2 else None
+ rows.append(
+ {
+ "Symbol": symbol,
+ "Time in position": float((symbol_state != 0.0).mean()),
+ "Entries": int(
+ symbol_detail.isin(["oversold_entry", "overbought_entry"]).sum()
+ ),
+ "Stop exits": int((symbol_detail == "stop_loss_exit").sum()),
+ "ADF p-value": adf.pvalue if adf is not None else float("nan"),
+ # Same wording/threshold as render_stationarity_card's own
+ # verdict, using ADFResult's own reject_null (never a second,
+ # independently-chosen significance level).
+ "Verdict": (
+ ("Reject H0" if adf.reject_null else "Cannot reject H0")
+ if adf is not None
+ else "n/a"
+ ),
+ "Half-life": compute_half_life(tested)
+ if len(tested) >= 2
+ else float("inf"),
+ "Hurst": hurst_exponent(tested) if len(tested) >= 2 else float("nan"),
+ }
+ )
+ indicators[symbol] = indicator[symbol]
+ states[symbol] = symbol_state
+ summary = pd.DataFrame(rows).set_index("Symbol")
+ return MeanReversionDiagnostics(
+ indicator=strategy.indicator,
+ summary=summary,
+ indicators=indicators,
+ states=states,
+ entry_threshold=strategy.entry_threshold,
+ exit_threshold=strategy.exit_threshold,
+ stop_threshold=strategy.stop_threshold,
+ )
+
+
+def _render_diagnostics(st: Any, result: MeanReversionDiagnostics) -> None:
+ from quantlab.dashboard.explorer.shared_components import (
+ centered_indicator_threshold_overlay,
+ render_price_chart,
+ )
+
+ st.subheader("Stationarity diagnostics & State machine")
+ st.caption(
+ f"indicator = **{result.indicator}**. Full-sample ADF/half-life/Hurst "
+ "per symbol -- descriptive, not a validated backtest result on "
+ "their own. Verdict uses the same H0 (unit root) rejection rule as "
+ "the interactive lab's own stationarity card."
+ )
+ st.dataframe(result.summary, width="stretch")
+ symbol = st.selectbox(
+ "Symbol", list(result.indicators), key="mr_results_diag_symbol"
+ )
+ threshold_series, line_colors = centered_indicator_threshold_overlay(
+ result.indicators[symbol],
+ f"{result.indicator} indicator",
+ entry_threshold=result.entry_threshold,
+ exit_threshold=result.exit_threshold,
+ stop_threshold=result.stop_threshold,
+ )
+ render_price_chart(
+ st,
+ threshold_series,
+ title=f"{symbol}: Centered '{result.indicator}' indicator with "
+ "entry/exit/stop thresholds",
+ yaxis_title="Centered indicator",
+ colors=line_colors,
+ )
+ render_price_chart(
+ st,
+ {"Position (state)": result.states[symbol]},
+ title=f"{symbol}: state signal",
+ yaxis_title="Signal state",
+ )
+
+
+def _report_section(result: MeanReversionDiagnostics) -> DiagnosticsSection:
+ from quantlab.reporting.sections import DiagnosticsSection
+
+ table = result.summary.reset_index()
+ return DiagnosticsSection(
+ table=table,
+ note=(
+ f"Mean reversion state-machine activity (indicator={result.indicator}) "
+ "and full-sample stationarity diagnostics, one row per traded "
+ "symbol. Descriptive, not a validation of profitability."
+ ),
+ )
+
+
+register_profile(
+ StrategyProfile(
+ strategy_name="mean_reversion",
+ display_name="Mean Reversion",
+ category="Mean reversion",
+ overview_md=_OVERVIEW,
+ economic_intuition_md=_ECONOMIC_INTUITION,
+ mathematical_definition_md=_MATH,
+ assumptions_md=_ASSUMPTIONS,
+ diagnostics_md=_DIAGNOSTICS,
+ interpretation_md=_INTERPRETATION,
+ limitations_md=_LIMITATIONS,
+ references_md=_REFERENCES,
+ parameters=_PARAMETERS,
+ lab=_lab,
+ results_diagnostics=ResultsDiagnostics(
+ key="mean_reversion_diagnostics",
+ compute=_compute_diagnostics,
+ render=_render_diagnostics,
+ report_section=_report_section,
+ ),
+ )
+)
diff --git a/src/quantlab/dashboard/explorer/profiles/pairs_trading.py b/src/quantlab/dashboard/explorer/profiles/pairs_trading.py
new file mode 100644
index 0000000..645d4ec
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/profiles/pairs_trading.py
@@ -0,0 +1,635 @@
+"""Strategy Explorer profile for ``pairs_trading``."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import pandas as pd
+
+from quantlab.dashboard.explorer.profile import (
+ ParameterDoc,
+ ResultsDiagnostics,
+ StrategyProfile,
+ register_profile,
+)
+
+if TYPE_CHECKING:
+ from quantlab.config import ExperimentConfig
+ from quantlab.features.pairs_diagnostics import PairDiagnostics
+ from quantlab.reporting.sections import DiagnosticsSection
+
+_OVERVIEW = """
+Pairs trading trades the *relationship* between two related assets, not
+either asset's own direction. When the price of A drifts away from what
+B's own movement would predict, the strategy bets on that gap ("the
+spread") closing again. For the usual positive fitted beta this creates
+opposite-signed legs; with a negative beta, the leg signs need not be one
+long and one short. The signal construction encodes the fitted OLS slope
+between its legs; it is NOT necessarily dollar-neutral, and QuantLab's
+weight-based accounting does not simulate literal share quantities -- see
+Mathematical definition. It seeks statistical
+arbitrage / mean reversion in a relationship, the classic example being
+two economically linked instruments (two banks, two miners, an ETF and
+its underlying index) whose prices tend to move together.
+
+Typical horizon: several bars to a few dozen bars per trade (often days to
+weeks on daily data), depending on `indicator_window` and how quickly the
+spread mean-reverts. Data needed: aligned prices
+for both legs over a long enough history to fit a reliable relationship
+(`formation_window` periods) before trading starts.
+"""
+
+_ECONOMIC_INTUITION = """
+Two assets exposed to the same underlying economic driver (an industry,
+a currency, an index) should move together over time. When one
+temporarily overreacts -- to flow, sentiment, a stock-specific headline --
+while the underlying driver hasn't actually changed, the gap between them
+is expected to close as both assets re-anchor to that shared driver. This
+is intended as a relative-value bet: it does not directly forecast whether the
+*market* goes up or down, only that this specific pair's relationship is
+temporarily distorted and will normalize.
+"""
+
+_MATH = """
+`generate_signals()`'s pipeline, in order:
+
+1. **Hedge ratio** -- `rolling_hedge_parameters(a, b, formation_window,
+ dynamic_hedge_ratio)` fits `a = intercept + beta * b` by trailing OLS.
+ If `dynamic_hedge_ratio=False`, this fit happens once on the first
+ `formation_window` observations and is held constant afterward; if
+ `True`, it is refit on every trailing `formation_window`-length window
+ (so `beta`/`intercept` can drift as the relationship itself drifts).
+2. **Spread** -- `spread = a - intercept - beta * b`, the residual of that
+ fit: how far A actually sits from what the fitted relationship predicts.
+3. **Centered indicator** -- one of three indicators (`indicator`, same
+ choice and defaults as `mean_reversion`), applied to the spread residual
+ instead of a raw price: `zscore` (`rolling_zscore(spread,
+ indicator_window)`, the default), `rsi`, or `percentile`. Each is
+ zero-centered the same way mean_reversion's indicator is (negative =
+ spread below normal, positive = above normal).
+4. **Stationarity gate** -- every `indicator_window` bars, the full
+ trailing `formation_window` residual is ADF-tested; new entries are
+ only allowed while the resulting p-value stays `<= adf_pvalue_threshold`
+ (open positions are unaffected -- the gate blocks new entries only).
+ Set `adf_pvalue_threshold=None` to disable the gate entirely (every
+ date becomes tradable, subject only to the entry/exit/stop thresholds
+ below).
+5. **State machine** -- flat: enter long when `indicator < -entry_threshold`
+ (and the gate is open), enter short when `indicator > entry_threshold`.
+ In a position: exit when the indicator crosses back through
+ `-exit_threshold` (long) / `exit_threshold` (short); force-flat if
+ `|indicator| > stop_threshold` or the indicator becomes undefined.
+6. **OLS-scaled legs** -- the discrete state (`{-1, 0, 1}`) is applied as
+ `symbol_a: state * a`, `symbol_b: -state * beta * b`, then BOTH legs
+ are divided by whichever one is larger in absolute value, so EACH leg
+ individually is bounded to `[-1, 1]` -- this bounds each leg's own
+ signal magnitude, it does NOT force the two legs' dollar exposures to
+ be equal and opposite (that would require intercept == 0, generally
+ false); combined gross exposure before portfolio-level allocation can
+ run up to roughly 2.
+
+This formula encodes the fitted share ratio in signal space, but QuantLab
+accounts for portfolio weights and does not create or round share orders.
+These are still strategy signals. The required `signal_proportional`
+allocator converts them to target weights; constraints, volatility
+targeting, rebalancing and execution then determine the weights actually
+traded.
+"""
+
+_ASSUMPTIONS = """
+**Economic**: the strategy assumes the two assets share a durable common
+driver rather than only coincidental historical correlation. **Statistical**:
+the fitted residual is assumed to be stationary and that property is
+assumed to persist beyond each formation sample. **Implementation**: the hedge ratio
+estimated over `formation_window` (or refit on every bar, if dynamic)
+remains a useful description of the relationship;
+transaction costs on both legs are small relative to the typical spread
+move being captured.
+"""
+
+_DIAGNOSTICS = """
+The lab below (and the Results tab, for an actual backtest) reports: return
+correlation and a rolling version of it (a screening signal, not proof of
+tradability); the hedge ratio series and its own stability (std of beta --
+a relationship whose slope keeps changing makes the spread harder to
+interpret, because both the spread itself and the hedge ratio used to
+construct it are varying over time; this raw standard deviation is in
+beta's own units, which depend on the pair's price scales and which symbol
+is A vs. B -- compare it across different
+formation_window/dynamic_hedge_ratio settings for the SAME pair, not
+across different pairs); an exploratory full-sample ADF on the adaptively
+constructed spread and Engle-Granger cointegration between the raw series
+(two related but distinct questions); a *rolling* ADF
+p-value so stationarity is checked throughout the sample, not only once
+over the full history; and the spread's mean-reversion half-life.
+"""
+
+_INTERPRETATION = """
+A low p-value is sample evidence against the relevant null, not a guarantee
+that the relationship will persist. The full-sample ADF is exploratory and,
+when `dynamic_hedge_ratio=True`, tests one series assembled from many rolling
+regressions; that adaptive construction is not the same as a standard
+single-regression residual test and can make the result look more stable.
+The rolling ADF series uses the same formulas as the strategy's periodic
+entry gate, but only matches its RESULT exactly for a single-calendar
+pair -- under a mixed-calendar universe the live gate evaluates on the
+intersection of both legs' own native session dates while this diagnostic
+uses the full combined timeline, so the two can genuinely differ (see
+docs/limitations.md). A shorter finite half-life relative to
+`indicator_window` is more compatible with completing threshold round
+trips, but does not guarantee that they occur or survive costs.
+"""
+
+_LIMITATIONS = """
+**Structural break**: a merger, a regulatory change, or an index
+reconstitution can permanently sever a relationship that looked stable for
+years -- the strategy has no way to distinguish "temporarily wide spread"
+from "the relationship is gone" except waiting for the ADF gate to close
+new entries (already-open positions still follow their own exit/stop --
+`stop_threshold` limits the indicator's own deviation tolerated before an
+exit is requested, it does not cap the realized monetary loss: gaps,
+execution delay, and a moving mean/volatility/hedge-ratio or costs can
+still produce a larger loss than the indicator distance alone would
+suggest).
+**Crowding**: a well-known, liquid pair attracts other pairs traders,
+which can compress the very edge the spread is supposed to capture.
+**Costs**: two legs can mean two sets of transaction costs. QuantLab models
+its configured commission, spread and slippage, but does not separately
+model stock-borrow fees, financing rates or locate availability; those
+short-side costs and constraints remain outside the result unless the user
+approximates them in the configured costs.
+**Weight-based execution**: the OLS-scaled leg formula is converted to target
+weights; QuantLab does not maintain literal share counts or guarantee exact
+share neutrality, particularly when adjusted signal prices differ from raw
+execution reference prices.
+**Unstable hedge ratio**: with `dynamic_hedge_ratio=True`, a beta that
+swings a lot between refits makes the spread itself a moving target,
+undermining the whole premise of trading a *stable* residual.
+"""
+
+_REFERENCES = (
+ 'Engle & Granger (1987), ["Co-integration and Error Correction: '
+ 'Representation, Estimation, and Testing"](https://doi.org/10.2307/'
+ "1913236), *Econometrica* 55(2), 251-276, develops the cointegration and "
+ "error-correction framework. QuantLab specifically calls the augmented "
+ "Engle-Granger test documented by [statsmodels](https://www.statsmodels."
+ "org/stable/generated/statsmodels.tsa.stattools.coint.html). These sources "
+ "support the statistical tests, not QuantLab's thresholds or the "
+ "profitability of a pair.\n\n"
+ "Ernest P. Chan's [*Algorithmic Trading: Winning Strategies and Their "
+ "Rationale*](https://onlinelibrary.wiley.com/doi/book/10.1002/"
+ "9781118676998) (Wiley, 2013) provides a practical discussion of mean-"
+ "reverting spreads, stationarity, cointegration and hedge-ratio "
+ "construction. It complements the statistical sources above but does "
+ "not establish that a particular pair or configuration is profitable."
+)
+
+_PARAMETERS = [
+ ParameterDoc(
+ name="symbol_a",
+ what="The first leg of the pair.",
+ where="Defines `a` in every equation above.",
+ why="Pairs trading needs two named instruments to relate.",
+ default="(required)",
+ typical_range="A symbol present in the configured universe, with "
+ "timestamps compatible with symbol_b.",
+ effect_increase="N/A -- a selection, not a magnitude.",
+ effect_decrease="N/A -- a selection, not a magnitude.",
+ tradeoffs="Choosing a genuinely economically-related pair matters far "
+ "more than any other parameter here.",
+ interactions="Must differ from symbol_b; both must be present in the "
+ "configured universe.",
+ ),
+ ParameterDoc(
+ name="symbol_b",
+ what="The second leg of the pair.",
+ where="Defines `b` in every equation above.",
+ why="See symbol_a.",
+ default="(required)",
+ typical_range="A symbol present in the configured universe, with "
+ "timestamps compatible with symbol_a.",
+ effect_increase="N/A -- a selection, not a magnitude.",
+ effect_decrease="N/A -- a selection, not a magnitude.",
+ tradeoffs="See symbol_a.",
+ interactions="Must differ from symbol_a.",
+ ),
+ ParameterDoc(
+ name="formation_window",
+ what="Trailing window (periods) used to fit the hedge ratio and "
+ "run the periodic ADF stationarity test.",
+ where="Step 1 (hedge ratio fit) and step 4 (stationarity gate).",
+ why="A relationship needs enough history to estimate reliably, but "
+ "not so much that it stops describing the CURRENT relationship.",
+ default="252",
+ typical_range="~60-500 periods (roughly 3 months to 2 years of daily data).",
+ effect_increase="A smoother, more stable hedge-ratio estimate, but "
+ "slower to adapt if the relationship is genuinely changing; more "
+ "data required before the strategy can trade at all.",
+ effect_decrease="Faster adaptation to a changing relationship, but a "
+ "noisier hedge-ratio estimate and a less powerful stationarity test "
+ "(fewer observations per ADF run).",
+ tradeoffs="Stability of the estimate vs. responsiveness to genuine "
+ "regime change.",
+ interactions="Must be smaller than the available history; interacts "
+ "with dynamic_hedge_ratio (a short window refit every period reacts "
+ "fast but noisily).",
+ ),
+ ParameterDoc(
+ name="indicator_window",
+ what="Trailing window (periods) used to compute the spread's "
+ "centered indicator, and the cadence of the stationarity gate "
+ "re-check.",
+ where="Step 3 (centered indicator) and step 4 (gate re-check interval).",
+ why="The indicator needs its own recent window to be a meaningful "
+ "'how unusual is this right now' measure.",
+ default="63",
+ typical_range="~10-90 periods.",
+ effect_increase="A smoother indicator, less sensitive to short-lived "
+ "noise, but slower to flag a genuine new dislocation.",
+ effect_decrease="A twitchier indicator that reacts fast to a fresh "
+ "dislocation, but more prone to false entries from noise.",
+ tradeoffs="Signal smoothness vs. responsiveness.",
+ interactions="Should generally be smaller than formation_window (the "
+ "indicator describes short-run deviation from a longer-run "
+ "relationship, not the other way around).",
+ ),
+ ParameterDoc(
+ name="indicator",
+ what="Which zero-centered indicator of the spread residual drives "
+ "the state machine: 'zscore' (default), 'rsi' or 'percentile' -- "
+ "same three choices and defaults as mean_reversion's own "
+ "`indicator`, applied here to the spread instead of a raw price.",
+ where="Step 3 -- determines what feeds every subsequent decision.",
+ why="Same rationale as mean_reversion's `indicator`: a z-score is "
+ "scale-free relative to the spread's own recent volatility, RSI is "
+ "a bounded oscillator, percentile rank is purely non-parametric.",
+ default="zscore",
+ typical_range="One of 'zscore', 'rsi', 'percentile'.",
+ effect_increase="N/A -- a choice, not a magnitude.",
+ effect_decrease="N/A -- a choice, not a magnitude.",
+ tradeoffs="See mean_reversion's `indicator` doc for the full "
+ "zscore/rsi/percentile tradeoff discussion -- it applies "
+ "identically here.",
+ interactions="entry_threshold/exit_threshold/stop_threshold are on "
+ "THIS indicator's own scale -- changing indicator without "
+ "re-checking thresholds can silently produce a state machine that "
+ "rarely or constantly trades.",
+ ),
+ ParameterDoc(
+ name="entry_threshold",
+ what="Indicator magnitude (on the chosen indicator's own scale) "
+ "that opens a new position.",
+ where="Step 5, flat-state entry condition.",
+ why="Defines how large a dislocation has to be before it's worth "
+ "trading (net of costs and estimation noise).",
+ default="Indicator-specific: 2.0 (zscore), 20.0 (rsi), 0.45 "
+ "(percentile) -- applied only when left unset (None).",
+ typical_range="Depends on indicator; see default above.",
+ effect_increase="Fewer, larger, more extreme entries -- higher "
+ "conviction per trade, fewer trades overall (lower turnover/costs).",
+ effect_decrease="More frequent entries on smaller dislocations -- "
+ "more trades, more exposure to noise being mistaken for a genuine "
+ "opportunity.",
+ tradeoffs="Trade frequency and turnover vs. conviction per trade.",
+ interactions="Must exceed exit_threshold (validated); if "
+ "stop_threshold is set, must be below it.",
+ ),
+ ParameterDoc(
+ name="exit_threshold",
+ what="Indicator magnitude that closes an open position as the spread reverts.",
+ where="Step 5, in-position exit condition.",
+ why="Decides how much of the reversion to actually capture before "
+ "closing, versus how long to stay exposed hoping for more.",
+ default="Indicator-specific: 0.5 (zscore), 10.0 (rsi), 0.10 "
+ "(percentile) -- applied only when left unset (None).",
+ typical_range="Depends on indicator; see default above.",
+ effect_increase="Exits earlier, leaving more of a full reversion "
+ "uncaptured but reducing time-in-trade and reversal risk.",
+ effect_decrease="Holds longer for a more complete reversion, at the "
+ "cost of more time exposed to the spread reversing direction again "
+ "before exit.",
+ tradeoffs="Captured reversion vs. time-in-trade risk.",
+ interactions="Must be strictly below entry_threshold.",
+ ),
+ ParameterDoc(
+ name="stop_threshold",
+ what="Indicator magnitude that force-closes a position regardless "
+ "of direction -- a circuit breaker for a spread that keeps widening "
+ "instead of reverting.",
+ where="Step 5, checked before every other branch.",
+ why="Limits the indicator's own deviation tolerated before "
+ "requesting an exit on a relationship that may have broken down "
+ "rather than merely dislocated; it does not cap the realized "
+ "monetary loss -- gaps, execution delay, and a moving mean/"
+ "volatility/hedge-ratio or costs can still produce a larger loss "
+ "than the indicator distance alone would suggest.",
+ default="Indicator-specific: 4.0 (zscore), 45.0 (rsi), 0.49 "
+ "(percentile) -- applied when this parameter is left out entirely. "
+ "Pass an explicit stop_threshold=None to disable the stop "
+ "altogether.",
+ typical_range="Typically 1.5-2x entry_threshold; omit the "
+ "parameter to use the indicator-specific default, or pass None to "
+ "disable it.",
+ effect_increase="More room for the spread to widen before an exit "
+ "is requested -- fewer stop-outs on noise, but larger potential "
+ "loss per trade.",
+ effect_decrease="Tighter risk control, but more prone to being "
+ "stopped out by a temporary overshoot that would otherwise have "
+ "reverted.",
+ tradeoffs="Downside protection vs. premature stop-outs.",
+ interactions="Must exceed entry_threshold when set; an explicit "
+ "None disables the stop entirely (positions then only exit via "
+ "exit_threshold).",
+ ),
+ ParameterDoc(
+ name="dynamic_hedge_ratio",
+ what="Whether the hedge ratio is refit every period (True) or fit "
+ "once at formation and held constant (False).",
+ where="Step 1.",
+ why="A relationship's slope can itself drift over time; this "
+ "decides whether the strategy tracks that drift or assumes it "
+ "away.",
+ default="True",
+ typical_range="Boolean.",
+ effect_increase="N/A (boolean).",
+ effect_decrease="N/A (boolean).",
+ tradeoffs="True adapts to a genuinely drifting relationship but "
+ "makes the spread noisier (a moving hedge ratio adds its own "
+ "variance); False is a simpler, more stable spread definition but "
+ "can go stale if the true relationship shifts materially after "
+ "formation.",
+ interactions="The stationarity gate keeps the same recheck cadence; "
+ "this parameter changes whether its coefficients are refit or held static.",
+ ),
+ ParameterDoc(
+ name="adf_pvalue_threshold",
+ what="Maximum ADF p-value (from the periodic stationarity gate) at "
+ "which a NEW entry is still allowed -- optional: pass None to "
+ "disable the gate entirely (every date becomes tradable, subject "
+ "only to the entry/exit/stop thresholds).",
+ where="Step 4, compared against the gate's own p-value.",
+ why="Refuses to open a fresh position on a relationship the data no "
+ "longer supports as stationary, even if the indicator looks "
+ "attractive.",
+ default="0.10 (gate enabled by default).",
+ typical_range="0.05-0.10, or None to disable.",
+ effect_increase="Looser gate -- more candidate entries pass, "
+ "including weaker statistical evidence of stationarity.",
+ effect_decrease="Stricter gate -- fewer entries pass, but each one "
+ "clears a higher statistical bar.",
+ tradeoffs="Opportunity (more entries) vs. statistical rigor (fewer, "
+ "better-supported entries). Never affects an already-open "
+ "position's own exit/stop.",
+ interactions="Interacts with formation_window (more observations can "
+ "improve test precision but do not guarantee greater power) and "
+ "indicator_window (sets how often the gate is re-evaluated).",
+ ),
+ ParameterDoc(
+ name="price_type",
+ what="Which price series ('adjusted_close' or 'close') "
+ "generate_signals() reads to compute the hedge ratio, spread and "
+ "z-score. Execution/costs always use the raw close regardless.",
+ where="Every step above operates on whichever price series this selects.",
+ why="A split or large dividend on either leg shows up as a price "
+ "jump in raw close but not in adjusted close -- unadjusted, it "
+ "would look exactly like a spread dislocation.",
+ default="adjusted_close",
+ typical_range="adjusted_close (recommended) or close.",
+ effect_increase="N/A -- a choice, not a magnitude.",
+ effect_decrease="N/A -- a choice, not a magnitude.",
+ tradeoffs="adjusted_close avoids false dislocations from corporate "
+ "actions; close matches what was literally quoted at the time, "
+ "useful mainly for auditing against raw market data.",
+ interactions="A split on only one leg while using close would "
+ "corrupt the hedge ratio and spread for a long stretch after the "
+ "split -- adjusted_close is the safer default for exactly this "
+ "reason.",
+ ),
+ ParameterDoc(
+ name="stop_loss_pct",
+ what="Fractional (e.g. 0.10 = 10%) gross-return threshold that "
+ "force-flattens BOTH legs together, based on the PAIR's combined "
+ "economic P&L -- not either leg's own return in isolation.",
+ where="Applied after the backtest allocator/constraints/"
+ "rebalancing/execution, on the position actually held -- see "
+ "`PairsTradingStrategy.position_groups()` (declares the two legs "
+ "as one group) and `quantlab.backtesting.accounting.`"
+ "`_detect_stop_loss_take_profit`.",
+ why="A hedge leg's own gain can OFFSET the pair's real loss (or "
+ "vice versa) -- a per-leg stop would misjudge risk entirely; the "
+ "pair's own combined return, per unit of ITS OWN gross exposure "
+ "at each date, is correct regardless of a static or dynamic "
+ "hedge ratio, rebalancing, or partial fills.",
+ default="None (disabled) -- enabling it changes no existing "
+ "behavior unless explicitly set.",
+ typical_range="0.05-0.20, or None to disable.",
+ effect_increase="More room before the pair is forced flat -- "
+ "fewer stop-outs on ordinary spread noise, larger potential "
+ "realized loss.",
+ effect_decrease="Tighter monetary risk control on the pair as a "
+ "whole, more prone to being stopped out by a temporary spread move.",
+ tradeoffs="Realized-loss protection vs. premature exits. Evaluated "
+ "on GROSS (pre-cost) return -- QuantLab's execution cost model is "
+ "portfolio-level only, so an exact net-of-cost trigger is not "
+ "presently computable; this is a disclosed design convention, not "
+ "a universal definition.",
+ interactions="Independent of entry_threshold/exit_threshold/"
+ "stop_threshold (the spread's own indicator-based stop). Once "
+ "triggered, no immediate re-entry at a rebased price -- both legs "
+ "stay flat until the pair's next real entry.",
+ ),
+ ParameterDoc(
+ name="take_profit_pct",
+ what="Fractional (e.g. 0.10 = 10%) gross-return threshold that "
+ "force-flattens both legs together on the favorable side, based "
+ "on the pair's combined P&L.",
+ where="Same mechanism as stop_loss_pct, opposite direction.",
+ why="Realizes a gain directly once the pair's own combined return "
+ "target is reached, instead of depending on the spread reverting "
+ "all the way back through exit_threshold.",
+ default="None (disabled) -- enabling it changes no existing "
+ "behavior unless explicitly set.",
+ typical_range="0.05-0.20, or None to disable.",
+ effect_increase="Lets more of a favorable spread move run before "
+ "locking it in.",
+ effect_decrease="Locks in gains earlier, potentially forfeiting "
+ "further convergence.",
+ tradeoffs="Locking in gains early vs. capturing a larger reversion.",
+ interactions="Independent of stop_loss_pct and the entry/exit/"
+ "stop indicator-threshold family; see stop_loss_pct's own doc for "
+ "the shared combined-P&L/gross-return/re-entry conventions.",
+ ),
+]
+
+
+@dataclass(frozen=True)
+class PairsTradingDiagnostics:
+ """Pair relationship diagnostics, plus trading-threshold breach counts.
+
+ ``diagnostics`` is the existing correlation/hedge-ratio/ADF/
+ cointegration/half-life view (unchanged). The trading-threshold fields
+ add the "which BARS satisfy the entry condition under the ADF filter"
+ view from the interactive lab's own Trading thresholds section,
+ computed on the SAME centered indicator the real strategy trades
+ (`indicator`, resolved exactly as `PairsTradingStrategy` itself
+ resolves it). ``viable_bars`` counts every bar the threshold+gate
+ condition holds, NOT distinct trade-entry events -- the live
+ strategy's own state machine (`_walk_pairs_positions_with_reasons`)
+ only opens a position on the FIRST such bar after being flat, so this
+ count is generally larger than the real number of entries a backtest
+ would make.
+ """
+
+ diagnostics: PairDiagnostics
+ indicator: str
+ entry_threshold: float
+ exit_threshold: float
+ stop_threshold: float | None
+ adf_pvalue_threshold: float | None
+ entry_breaches: int
+ viable_bars: int
+ stop_breaches: int
+
+
+def _compute_diagnostics(
+ data: pd.DataFrame, cfg: ExperimentConfig
+) -> PairsTradingDiagnostics:
+ from quantlab.data.base import price_matrix
+ from quantlab.features.pairs_diagnostics import compute_pair_diagnostics
+ from quantlab.strategies.pairs_trading import PairsTradingStrategy
+
+ params = cfg.strategy_parameters
+ # `strategy.parameters.price_type` is rejected at config validation
+ # (see `StrategyConfig._reject_price_type_in_parameters`) -- the
+ # strategy's own price series is always `strategy.signal_price_type`.
+ # Diagnostics computed on the wrong price series would show a
+ # different hedge ratio/spread than the one actually traded.
+ price_type = cfg.strategy.signal_price_type
+ prices = price_matrix(data, adjusted=price_type != "close")
+ # Built from the SAME constructor call the real backtest made -- see
+ # MeanReversionStrategy's identical rationale for stop_threshold's
+ # None-vs-unset semantics, shared by this strategy.
+ strategy = PairsTradingStrategy(**params)
+ diagnostics = compute_pair_diagnostics(
+ prices,
+ strategy.symbol_a,
+ strategy.symbol_b,
+ formation_window=strategy.formation_window,
+ indicator_window=strategy.indicator_window,
+ dynamic_hedge_ratio=strategy.dynamic_hedge_ratio,
+ indicator=strategy.indicator,
+ )
+ indicator = diagnostics.spread_indicator
+ entry = strategy.entry_threshold
+ stop = strategy.stop_threshold
+ adf_threshold = strategy.adf_pvalue_threshold
+ crosses_entry = (indicator > entry) | (indicator < -entry)
+ if adf_threshold is not None:
+ gate_open = (
+ diagnostics.rolling_adf_pvalue.reindex(indicator.index) <= adf_threshold
+ ).fillna(False)
+ else:
+ gate_open = pd.Series(True, index=indicator.index)
+ viable = crosses_entry & gate_open
+ stop_breaches = (
+ int(((indicator > stop) | (indicator < -stop)).sum()) if stop is not None else 0
+ )
+ return PairsTradingDiagnostics(
+ diagnostics=diagnostics,
+ indicator=strategy.indicator,
+ entry_threshold=entry,
+ exit_threshold=strategy.exit_threshold,
+ stop_threshold=stop,
+ adf_pvalue_threshold=adf_threshold,
+ entry_breaches=int(crosses_entry.sum()),
+ viable_bars=int(viable.sum()),
+ stop_breaches=stop_breaches,
+ )
+
+
+def _render_diagnostics(st: Any, result: PairsTradingDiagnostics) -> None:
+ from quantlab.dashboard.components import render_pair_diagnostics
+
+ render_pair_diagnostics(
+ st,
+ result.diagnostics,
+ entry_threshold=result.entry_threshold,
+ exit_threshold=result.exit_threshold,
+ stop_threshold=result.stop_threshold,
+ adf_pvalue_threshold=result.adf_pvalue_threshold,
+ )
+
+
+def _report_section(result: PairsTradingDiagnostics) -> DiagnosticsSection:
+ from quantlab.reporting.charts import fig_to_base64, pair_spread_chart
+ from quantlab.reporting.sections import DiagnosticsSection
+ from quantlab.reporting.tables import pair_diagnostics_summary_table
+
+ table = pair_diagnostics_summary_table(result.diagnostics)
+ threshold_rows = pd.DataFrame(
+ [
+ ("Indicator", result.indicator),
+ ("Entry threshold", result.entry_threshold),
+ ("Exit threshold", result.exit_threshold),
+ (
+ "Stop threshold",
+ result.stop_threshold
+ if result.stop_threshold is not None
+ else "disabled",
+ ),
+ (
+ "ADF p-value threshold",
+ result.adf_pvalue_threshold
+ if result.adf_pvalue_threshold is not None
+ else "disabled",
+ ),
+ ("Entry threshold breaches (bar count)", result.entry_breaches),
+ ("Viable bars (threshold + ADF gate)", result.viable_bars),
+ ("Stop threshold breaches", result.stop_breaches),
+ ],
+ columns=["Metric", "Value"],
+ )
+ table = pd.concat([table, threshold_rows], ignore_index=True)
+ return DiagnosticsSection(
+ table=table,
+ chart_data_uri=fig_to_base64(pair_spread_chart(result.diagnostics)),
+ note=(
+ "Pair relationship diagnostics (correlation, hedge ratio, "
+ "spread stationarity, cointegration) plus trading-threshold "
+ "breach counts on the configured indicator's centered series, "
+ "including how many crossings were also viable under the ADF "
+ "stationarity gate."
+ ),
+ )
+
+
+def _lab(st: Any) -> None:
+ from quantlab.dashboard.explorer.labs.pairs_trading import render
+
+ render(st)
+
+
+register_profile(
+ StrategyProfile(
+ strategy_name="pairs_trading",
+ display_name="Pairs Trading",
+ category="Relative value",
+ overview_md=_OVERVIEW,
+ economic_intuition_md=_ECONOMIC_INTUITION,
+ mathematical_definition_md=_MATH,
+ assumptions_md=_ASSUMPTIONS,
+ diagnostics_md=_DIAGNOSTICS,
+ interpretation_md=_INTERPRETATION,
+ limitations_md=_LIMITATIONS,
+ references_md=_REFERENCES,
+ parameters=_PARAMETERS,
+ lab=_lab,
+ results_diagnostics=ResultsDiagnostics(
+ key="pair_diagnostics",
+ compute=_compute_diagnostics,
+ render=_render_diagnostics,
+ report_section=_report_section,
+ ),
+ )
+)
diff --git a/src/quantlab/dashboard/explorer/profiles/time_series_momentum.py b/src/quantlab/dashboard/explorer/profiles/time_series_momentum.py
new file mode 100644
index 0000000..a0d416c
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/profiles/time_series_momentum.py
@@ -0,0 +1,516 @@
+"""Strategy Explorer profile for ``time_series_momentum``."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import pandas as pd
+
+from quantlab.dashboard.explorer.profile import (
+ ParameterDoc,
+ ResultsDiagnostics,
+ StrategyProfile,
+ register_profile,
+)
+
+if TYPE_CHECKING:
+ from quantlab.config import ExperimentConfig
+ from quantlab.reporting.sections import DiagnosticsSection
+
+_OVERVIEW = """
+Time-series momentum emits a directional signal from each asset's own
+trend: positive momentum produces a long signal; negative momentum
+produces a flat or short signal. Unlike cross-sectional momentum, there is
+no universe ranking -- each symbol is judged purely against its own past,
+independent of how any other asset is doing. Also called absolute momentum or trend
+following on returns.
+
+Typical horizon: tens to hundreds of bars (often weeks to months on daily
+data), set by `lookback_period`. Data needed: one asset's own price history,
+long enough to build a stable score before trading starts.
+"""
+
+_ECONOMIC_INTUITION = """
+Some asset-price trends have historically persisted for a while before
+reversing. Proposed explanations include gradual information diffusion,
+institutional flows that take time to execute, and behavioural
+trend-following or herding. Trading in the direction of an established trend is
+a bet that whatever is driving it (an improving/deteriorating
+fundamental picture, sustained buying/selling pressure) has not yet fully
+played out.
+"""
+
+_MATH = """
+`generate_signals()`'s pipeline, in order:
+
+1. **Score** -- `score = momentum(prices, lookback_period, skip_period)` =
+ `P_{t-skip} / P_{t-lookback} - 1`, exactly as in cross-sectional
+ momentum, but evaluated for one asset in isolation (no ranking against
+ others).
+2. **Scaling** (`signal_scaling`) turns that score into a signal in
+ `[-1, 1]`:
+ - `binary`: `sign(score)` -- emits `+1` or `-1` (or `0` if exactly
+ zero), regardless of how strong the trend is.
+ - `continuous`: `clip(score / rolling_std(score, lookback_period), -1,
+ 1)` -- scales the signal by how unusual the current score is relative
+ to its own recent dispersion.
+ - `volatility_adjusted`: `clip(score / realized_volatility(returns,
+ volatility_window, periods_per_year), -1, 1)` -- scales down in
+ high-volatility regimes and up in low-volatility ones, for a given
+ raw score.
+3. **`long_only`** clips the result to `>= 0` when set, removing short
+ signals entirely.
+
+These values are strategy signals, not final portfolio weights. The
+allocator decides how signal magnitude is translated into target weights;
+portfolio constraints, volatility targeting, rebalancing and execution can
+then modify or delay those targets further.
+"""
+
+_ASSUMPTIONS = """
+**Economic**: this specific asset's own recent trend is assumed to contain
+information about its near-term future direction (trend persistence), not merely
+backward-looking noise. **Statistical**: the chosen `signal_scaling` mode
+matches how the underlying trend actually behaves -- e.g.
+`volatility_adjusted` assumes recent realised volatility is a reasonable
+guide to near-term risk, which can fail sharply around a volatility
+regime change. **Implementation**: `lookback_period` should be long enough
+to reduce noise but short enough to react to a change in direction; the
+data cannot guarantee either property.
+"""
+
+_DIAGNOSTICS = """
+The lab below plots the raw momentum score, then all three
+`signal_scaling` modes side by side on that SAME score (the clearest way
+to see what changing this one parameter actually does), the realised
+volatility series that drives the `volatility_adjusted` mode specifically,
+and a past-score-vs-future-return persistence scatter for the chosen
+asset.
+"""
+
+_INTERPRETATION = """
+Compare the three scaling-mode lines: `binary` is a step function, while
+`continuous` and `volatility_adjusted` vary the signal magnitude with the
+score (and, for the latter, with trailing volatility). The latter mode is a
+heuristic signal-scaling rule, not a portfolio-level volatility target: it
+divides a lookback return by an annualised trailing volatility estimate and
+clips the result. If the modes diverge,
+`signal_scaling` would have changed the input supplied to the allocator;
+the final exposure also depends on all downstream portfolio and execution
+settings. A flat or negative persistence correlation on the chosen
+asset is evidence this strategy's core premise does not hold well for it,
+regardless of what a specific historical backtest shows.
+"""
+
+_LIMITATIONS = """
+**Whipsaws**: in a choppy, range-bound market with no sustained
+direction, trend-following signals can repeatedly flip. Those flips create
+costs only when they change executed weights at rebalance dates -- see the
+Trend Following strategy's own `efficiency_ratio` diagnostic for a direct
+measure of this failure mode, equally applicable here. **Sharp reversals**:
+a fast trend reversal (a market correction or a shock) can hurt before the
+signal has time to catch up, since it is inherently backward-looking over
+`lookback_period`.
+**Regime dependence for `volatility_adjusted`**: trailing-volatility
+sizing can react slowly to sudden volatility spikes, leaving positions
+temporarily sized for a calmer regime than the one in which risk is
+actually realized.
+"""
+
+_REFERENCES = (
+ 'Moskowitz, Ooi & Pedersen (2012), ["Time Series Momentum"]('
+ "https://doi.org/10.1016/j.jfineco.2011.11.003), *Journal of Financial "
+ "Economics* 104(2), 228-250, documents time-series return predictability "
+ "across 58 liquid futures contracts. It supports the broad "
+ "time-series-momentum premise, not QuantLab's specific asset universe, "
+ "parameter choices, or signal-scaling implementations."
+)
+
+_PARAMETERS = [
+ ParameterDoc(
+ name="lookback_period",
+ what="Total look-back window (periods) the momentum score is measured over.",
+ where="Step 1.",
+ why="Sets the horizon over which 'the trend' is defined for this one asset.",
+ default="252",
+ typical_range="63-252 periods.",
+ effect_increase="Captures a longer, smoother trend; slower to "
+ "react to a genuine new trend.",
+ effect_decrease="More responsive to a recent shift, but noisier.",
+ tradeoffs="Horizon length vs. responsiveness.",
+ interactions="Must exceed skip_period; also the window "
+ "`continuous` scaling uses for the score's own rolling dispersion.",
+ ),
+ ParameterDoc(
+ name="skip_period",
+ what="Most recent periods excluded from the lookback window.",
+ where="Step 1.",
+ why="Very recent short-term returns have sometimes shown reversal "
+ "rather than continuation.",
+ default="21",
+ typical_range="0-21 periods.",
+ effect_increase="Cleaner separation from short-term reversal, "
+ "slower reaction to a genuinely very recent shift.",
+ effect_decrease="More responsive, more exposed to short-term reversal.",
+ tradeoffs="Signal purity vs. responsiveness.",
+ interactions="Must be strictly less than lookback_period.",
+ ),
+ ParameterDoc(
+ name="signal_scaling",
+ what="How the raw momentum score is mapped to a signal magnitude "
+ "-- binary/continuous/volatility_adjusted.",
+ where="Step 2 -- determines how the value supplied to the allocator "
+ "varies with signal strength.",
+ why="Different scaling modes make very different bets: full signal "
+ "magnitude on any nonzero score (binary) vs. graded sizing by "
+ "conviction (continuous) vs. graded sizing by conviction AND "
+ "current risk (volatility_adjusted).",
+ default="binary",
+ typical_range="One of the three modes.",
+ effect_increase="N/A -- a choice, not a magnitude.",
+ effect_decrease="N/A -- a choice, not a magnitude.",
+ tradeoffs="binary is simplest and ignores trend magnitude; "
+ "continuous scales with the score but needs a stable "
+ "rolling dispersion estimate; volatility_adjusted additionally "
+ "adjusts the signal using a risk estimate but depends on volatility_window/"
+ "periods_per_year being well-chosen.",
+ interactions="continuous and volatility_adjusted both need "
+ "reliable rolling statistics -- noisy with too little history. "
+ "The latter is separate from any portfolio-level "
+ "target_volatility setting.",
+ ),
+ ParameterDoc(
+ name="volatility_window",
+ what="Trailing window (periods) for realised volatility, used "
+ "only by the volatility_adjusted scaling mode.",
+ where="Step 2, volatility_adjusted branch only.",
+ why="Defines what 'current risk' means for sizing purposes.",
+ default="63",
+ typical_range="21-126 periods.",
+ effect_increase="Smoother, slower-changing risk estimate.",
+ effect_decrease="Faster-reacting risk estimate, noisier.",
+ tradeoffs="Stability vs. responsiveness of the risk estimate.",
+ interactions="Only matters when signal_scaling='volatility_"
+ "adjusted'; interacts with periods_per_year (annualisation).",
+ ),
+ ParameterDoc(
+ name="long_only",
+ what="Whether short signals are structurally disabled.",
+ where="Applied as a final clip to >= 0.",
+ why="Many portfolios/mandates cannot or should not short.",
+ default="True",
+ typical_range="Boolean.",
+ effect_increase="N/A (boolean).",
+ effect_decrease="N/A (boolean).",
+ tradeoffs="True avoids short-specific costs/constraints but "
+ "forfeits potential gains from downtrends; False represents both "
+ "directions but adds short-side and whipsaw risk.",
+ interactions="When False the signal is symmetric around zero, but "
+ "the allocator and constraints still determine final long/short weights.",
+ ),
+ ParameterDoc(
+ name="periods_per_year",
+ what="Annualisation factor used to convert per-period volatility "
+ "into annualised volatility for the volatility_adjusted scaling "
+ "mode.",
+ where="Step 2, volatility_adjusted branch only (via realized_volatility).",
+ why="Volatility is naturally a per-period quantity; annualising "
+ "it makes the number comparable across different bar frequencies "
+ "and to conventional risk figures.",
+ default="252 (injected from the experiment's own data frequency; "
+ "not usually set explicitly per-strategy).",
+ typical_range="252 for daily equities, 365 for daily crypto (24/7 "
+ "markets), or the bars-per-year implied by the configured "
+ "frequency.",
+ effect_increase="Scales the reported/used volatility level up for "
+ "the same raw return dispersion -- shifts how aggressively "
+ "volatility_adjusted sizes down in a given regime.",
+ effect_decrease="Scales it down -- less aggressive de-risking for "
+ "the same raw dispersion.",
+ tradeoffs="Getting this wrong for the data's actual frequency "
+ "silently mis-scales every volatility_adjusted signal; it should "
+ "match the experiment's own annualisation, not be tuned as a free "
+ "parameter.",
+ interactions="Has no effect at all unless signal_scaling="
+ "'volatility_adjusted'.",
+ ),
+ ParameterDoc(
+ name="price_type",
+ what="Which price series ('adjusted_close' or 'close') "
+ "generate_signals() reads. Execution/costs always use the raw "
+ "close regardless.",
+ where="Feeds the momentum score in step 1.",
+ why="A split or large dividend shows up as a price jump in raw "
+ "close but not in adjusted close -- unadjusted, it would look "
+ "exactly like a genuine trend move.",
+ default="adjusted_close",
+ typical_range="adjusted_close (recommended) or close.",
+ effect_increase="N/A -- a choice, not a magnitude.",
+ effect_decrease="N/A -- a choice, not a magnitude.",
+ tradeoffs="adjusted_close avoids false trend signals from "
+ "corporate actions; close matches what was literally quoted.",
+ interactions="A split near the current date would otherwise "
+ "register as a large, entirely spurious trend signal.",
+ ),
+ ParameterDoc(
+ name="stop_loss_pct",
+ what="Fractional (e.g. 0.10 = 10%) gross-return threshold that "
+ "force-flattens this symbol's REAL executed position -- unlike "
+ "this strategy's own momentum score, which is memoryless (no "
+ "notion of 'since entry'), this operates on the actual position "
+ "held after the allocator/constraints/rebalancing/execution.",
+ where="Applied downstream of generate_signals() entirely -- see "
+ "`quantlab.backtesting.accounting._detect_stop_loss_take_profit`. "
+ "generate_signals() itself is unchanged by this parameter.",
+ why="A trend can reverse sharply before the trailing momentum "
+ "score itself catches up (it is backward-looking over "
+ "lookback_period) -- this bounds the realized loss directly, "
+ "independent of the score.",
+ default="None (disabled) -- enabling it changes no existing "
+ "behavior unless explicitly set.",
+ typical_range="0.05-0.20, or None to disable.",
+ effect_increase="More room before a forced exit -- fewer stop-outs "
+ "on ordinary volatility, larger potential realized loss per trade.",
+ effect_decrease="Tighter monetary risk control, more prone to "
+ "being stopped out by a temporary adverse move before the trend "
+ "score itself reverses.",
+ tradeoffs="Realized-loss protection vs. premature exits. Evaluated "
+ "on GROSS (pre-cost) return -- QuantLab's execution cost model is "
+ "portfolio-level only, so an exact net-of-cost trigger is not "
+ "presently computable; this is a disclosed design convention, not "
+ "a universal definition.",
+ interactions="Once triggered, no immediate re-entry at a rebased "
+ "price -- flat until the position's next real entry (a fresh "
+ "flat-to-non-flat transition of the executed weight).",
+ ),
+ ParameterDoc(
+ name="take_profit_pct",
+ what="Fractional (e.g. 0.10 = 10%) gross-return threshold that "
+ "force-flattens this symbol's REAL executed position on the "
+ "favorable side -- locks in a gain directly rather than waiting "
+ "for the momentum score to fade.",
+ where="Same mechanism as stop_loss_pct, opposite direction.",
+ why="Realizes a gain directly once a target is reached, instead "
+ "of depending on the trend persisting (and then reversing) before "
+ "the score itself signals an exit.",
+ default="None (disabled) -- enabling it changes no existing "
+ "behavior unless explicitly set.",
+ typical_range="0.05-0.20, or None to disable.",
+ effect_increase="Lets more of a favorable trend run before locking it in.",
+ effect_decrease="Locks in gains earlier, potentially forfeiting "
+ "further trend continuation.",
+ tradeoffs="Locking in gains early vs. capturing a longer trend.",
+ interactions="Independent of stop_loss_pct; see its own doc for "
+ "the shared gross-return/re-entry conventions.",
+ ),
+]
+
+
+def _lab(st: Any) -> None:
+ from quantlab.dashboard.explorer.labs.time_series_momentum import render
+
+ render(st)
+
+
+#: Fixed default forward-return horizon for this diagnostic -- matching
+#: the interactive lab's own default. Deliberately INDEPENDENT of
+#: ``skip_period`` (a strategy parameter meaning "how much of the recent
+#: past to exclude from the score", not "how long to hold looking
+#: forward"): ``skip_period=0`` is a perfectly valid strategy config, but
+#: ``holding_period=0`` is rejected by ``momentum_persistence`` (must be
+#: >= 1), and a `skip_period` above 252 (also valid -- only constrained to
+#: be < lookback_period) would put the Results-tab slider's default value
+#: outside its own 1-252 range. Never reuse ``skip_period`` here again.
+_DEFAULT_DIAGNOSTIC_HOLDING_PERIOD = 21
+
+
+@dataclass(frozen=True)
+class TimeSeriesMomentumDiagnostics:
+ """Past-momentum-vs-future-return persistence, one row per symbol.
+
+ ``summary``/``paired`` are computed at ``holding_period`` (fixed to
+ ``_DEFAULT_DIAGNOSTIC_HOLDING_PERIOD`` -- a stable, documented default
+ independent of any UI widget AND of ``skip_period``, see that
+ constant's own docstring) for the exported HTML report, which has no
+ interactivity. ``prices``/``lookback_period``/``skip_period`` are
+ carried alongside so the Results tab can recompute this SAME
+ diagnostic at a user-chosen holding_period on demand -- a cheap,
+ purely local recomputation, not a backtest re-run (see
+ ``_render_diagnostics``).
+ """
+
+ holding_period: int
+ lookback_period: int
+ skip_period: int
+ prices: dict[str, pd.Series]
+ summary: pd.DataFrame
+ paired: dict[str, pd.DataFrame]
+
+
+def _persistence_tables(
+ prices: dict[str, pd.Series],
+ lookback_period: int,
+ skip_period: int,
+ holding_period: int,
+) -> tuple[pd.DataFrame, dict[str, pd.DataFrame]]:
+ from quantlab.features.momentum import momentum_persistence
+
+ rows = []
+ paired_by_symbol: dict[str, pd.DataFrame] = {}
+ for symbol, series in prices.items():
+ paired = momentum_persistence(
+ series, lookback_period, skip_period, holding_period
+ )
+ correlation = (
+ float(paired["past_momentum"].corr(paired["future_return"]))
+ if not paired.empty
+ else float("nan")
+ )
+ rows.append(
+ {"Symbol": symbol, "Correlation": correlation, "Observations": len(paired)}
+ )
+ paired_by_symbol[symbol] = paired
+ summary = pd.DataFrame(rows).set_index("Symbol")
+ return summary, paired_by_symbol
+
+
+def _compute_diagnostics(
+ data: pd.DataFrame, cfg: ExperimentConfig
+) -> TimeSeriesMomentumDiagnostics:
+ from quantlab.data.base import price_matrix
+
+ params = cfg.strategy_parameters
+ lookback = int(params.get("lookback_period", 252))
+ skip = int(params.get("skip_period", 21))
+ price_type = cfg.strategy.signal_price_type
+ price_frame = price_matrix(data, adjusted=price_type != "close")
+ prices = {symbol: price_frame[symbol] for symbol in price_frame.columns}
+ # Fixed, skip_period-INDEPENDENT default (see
+ # _DEFAULT_DIAGNOSTIC_HOLDING_PERIOD's own docstring for why). The
+ # Results tab lets the user override it independently (see
+ # _render_diagnostics); the exported HTML report always uses this
+ # fixed value so the report stays stable and reproducible.
+ holding_period = _DEFAULT_DIAGNOSTIC_HOLDING_PERIOD
+ summary, paired = _persistence_tables(prices, lookback, skip, holding_period)
+ return TimeSeriesMomentumDiagnostics(
+ holding_period=holding_period,
+ lookback_period=lookback,
+ skip_period=skip,
+ prices=prices,
+ summary=summary,
+ paired=paired,
+ )
+
+
+def _render_diagnostics(st: Any, result: TimeSeriesMomentumDiagnostics) -> None:
+ st.subheader("Momentum persistence diagnostics")
+ holding_period = st.slider(
+ "Forward-return horizon (periods) for this diagnostic",
+ 1,
+ 252,
+ result.holding_period,
+ key="tsmom_results_diag_holding_period",
+ help=(
+ "Diagnostic setting only -- changing this does not rerun or "
+ "alter the backtest, only how many periods ahead this "
+ "persistence check looks."
+ ),
+ )
+ st.caption(
+ "Forward-return horizon used only for this diagnostic; it does not "
+ "change the strategy or its backtest. Longer horizons produce "
+ "overlapping forward-return windows across consecutive dates, so "
+ "the apparent number of observations overstates the independent "
+ "information actually available -- treat this as descriptive "
+ "sample evidence, not a hypothesis test."
+ )
+ if holding_period == result.holding_period:
+ summary, paired_by_symbol = result.summary, result.paired
+ else:
+ summary, paired_by_symbol = _persistence_tables(
+ result.prices, result.lookback_period, result.skip_period, holding_period
+ )
+ st.caption(
+ f"Does past momentum score predict the subsequent "
+ f"{holding_period}-period return, per symbol?"
+ )
+ st.dataframe(summary, width="stretch")
+ symbol = st.selectbox(
+ "Symbol", list(paired_by_symbol), key="tsmom_results_diag_symbol"
+ )
+ paired = paired_by_symbol[symbol]
+ if paired.empty:
+ st.info("Not enough history to pair momentum with a future return yet.")
+ return
+ import plotly.graph_objects as go
+
+ fig = go.Figure(
+ go.Scatter(
+ x=paired["past_momentum"],
+ y=paired["future_return"],
+ mode="markers",
+ marker={"size": 5, "opacity": 0.5},
+ )
+ )
+ fig.update_layout(
+ title=f"{symbol}: past momentum vs. subsequent {holding_period}-period return",
+ xaxis_title="Past momentum score",
+ yaxis_title="Future return",
+ height=380,
+ )
+ st.plotly_chart(fig, width="stretch")
+
+
+def _report_section(result: TimeSeriesMomentumDiagnostics) -> DiagnosticsSection:
+ from quantlab.dashboard.explorer.shared_components import live_widget_value
+ from quantlab.reporting.sections import DiagnosticsSection
+
+ # Reflects the user's own live Results-tab slider choice (see
+ # _render_diagnostics), not always result.holding_period -- falls back
+ # to it when the dashboard isn't running at all (e.g. the CLI's own
+ # report generation) or that slider was never rendered this session.
+ holding_period = live_widget_value(
+ "tsmom_results_diag_holding_period", result.holding_period
+ )
+ if holding_period == result.holding_period:
+ summary = result.summary
+ else:
+ summary, _ = _persistence_tables(
+ result.prices, result.lookback_period, result.skip_period, holding_period
+ )
+ table = summary.reset_index()
+ return DiagnosticsSection(
+ table=table,
+ note=(
+ "Momentum persistence: correlation between each symbol's past "
+ f"momentum score and its subsequent {holding_period}-"
+ "period return. Descriptive sample evidence, not a hypothesis "
+ "test -- overlapping holding periods are not independent."
+ ),
+ )
+
+
+register_profile(
+ StrategyProfile(
+ strategy_name="time_series_momentum",
+ display_name="Time-Series Momentum",
+ category="Trend / momentum",
+ overview_md=_OVERVIEW,
+ economic_intuition_md=_ECONOMIC_INTUITION,
+ mathematical_definition_md=_MATH,
+ assumptions_md=_ASSUMPTIONS,
+ diagnostics_md=_DIAGNOSTICS,
+ interpretation_md=_INTERPRETATION,
+ limitations_md=_LIMITATIONS,
+ references_md=_REFERENCES,
+ parameters=_PARAMETERS,
+ lab=_lab,
+ results_diagnostics=ResultsDiagnostics(
+ key="time_series_momentum_diagnostics",
+ compute=_compute_diagnostics,
+ render=_render_diagnostics,
+ report_section=_report_section,
+ ),
+ )
+)
diff --git a/src/quantlab/dashboard/explorer/profiles/trend_following.py b/src/quantlab/dashboard/explorer/profiles/trend_following.py
new file mode 100644
index 0000000..b44c0df
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/profiles/trend_following.py
@@ -0,0 +1,509 @@
+"""Strategy Explorer profile for ``trend_following``."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import pandas as pd
+
+from quantlab.dashboard.explorer.profile import (
+ ParameterDoc,
+ ResultsDiagnostics,
+ StrategyProfile,
+ register_profile,
+)
+
+if TYPE_CHECKING:
+ from quantlab.config import ExperimentConfig
+ from quantlab.reporting.sections import DiagnosticsSection
+
+_OVERVIEW = """
+The simplest possible trend signal: two moving averages of the same
+price, a fast one and a slow one. When the fast average is above the
+slow one, the recent price action is running "hot" relative to the
+longer-term level -- treated as an uptrend, and vice versa. This is a
+classic technical trend-following construction, distinct from
+time-series momentum's return-based score (though both aim to capture
+the same underlying trend-persistence effect via a different lens).
+
+Typical horizon: set by `slow_window` -- tens to hundreds of bars (often
+weeks to months on daily data) for conventional 20/100 or 50/200-style
+pairings. Data needed: one asset's own price history, at least
+`slow_window` long before a signal exists.
+"""
+
+_ECONOMIC_INTUITION = """
+Same underlying premise as time-series momentum -- some price trends may
+persist for a while, potentially because of gradual information diffusion
+and trend-following flows --
+expressed through a moving-average crossover instead of a raw return
+score. A crossover is a simpler, more interpretable trend detector: it
+directly asks "is the recent average price above or below the
+longer-term average", which is easy to reason about and has a long
+history in technical analysis.
+"""
+
+_MATH = """
+`generate_signals()`'s pipeline:
+
+1. **Two moving averages** -- `fast = moving_average(prices,
+ fast_window)`, `slow = moving_average(prices, slow_window)`, each a
+ simple trailing mean.
+2. **Crossover sign** -- `signal = sign(fast - slow)`: `+1` when the fast
+ average sits above the slow one, `-1` when below, `0` on an exact tie,
+ `NaN` during warm-up (before `slow_window` observations exist) --
+ `_validate_signals()` then converts that warm-up `NaN` into an actual
+ flat (`0.0`) position before this becomes the strategy's real output,
+ exactly like every other built-in strategy's warm-up period.
+3. **`long_only`** clips the result to `>= 0` when set, so a downtrend
+ simply goes flat instead of short.
+
+That's the entire strategy signal -- no smoothing beyond the two moving
+averages themselves and no separate confirmation step. It is not yet a
+portfolio weight: the allocator, constraints, rebalancing schedule and
+execution model determine the weight that is ultimately traded.
+"""
+
+_ASSUMPTIONS = """
+**Economic**: the asset is assumed to exhibit trend persistence that a
+moving-average crossover can detect, rather than only noise around a stable level.
+**Statistical**: the price series has enough directional
+persistence (see the lab's Efficiency Ratio diagnostic) that a crossover
+signal isn't dominated by whipsaws. **Implementation**: `fast_window`/
+`slow_window` are set to a horizon where a real trend, once established,
+lasts noticeably longer than the lag the crossover itself introduces
+(a slow-moving average by construction reacts to a trend change well
+after it has already started).
+"""
+
+_DIAGNOSTICS = """
+The lab below shows the crossover itself, a whipsaw diagnostic (how many
+times the raw crossover direction changes within a trailing window -- an
+upper-bound source of potential turnover when `long_only=True`, and a source
+of actual turnover only when sampled targets change at rebalance dates),
+Kaufman's Efficiency Ratio (a 0-1 measure of how
+"clean" vs. "choppy" the recent price path has been, independent of
+direction), and a side-by-side comparison of a few conventional
+fast/slow window pairings. A perfectly flat window makes the usual ratio
+mathematically undefined (`0 / 0`); QuantLab displays `0.5` for that special
+case, so interpret it as a neutral implementation convention rather than
+evidence of a moderately efficient trend.
+"""
+
+_INTERPRETATION = """
+A high, stable Efficiency Ratio alongside few raw crossover changes describes a
+market this strategy is well-suited to (clean, sustained trends). A low
+Efficiency Ratio alongside frequent raw crossover changes describes a choppy,
+range-bound
+market -- exactly where trend following can struggle. Signal changes only
+create trades and transaction costs when they alter the executed target at
+a rebalance after downstream allocation, constraints and execution. Compare
+these two diagnostics across different periods on the same instrument to
+see how much the strategy's own suitability changes over time, not just
+across instruments.
+"""
+
+_LIMITATIONS = """
+**Whipsaws**: the strategy's single biggest failure mode -- a choppy,
+range-bound market repeatedly triggers crossovers in both directions
+without ever capturing a sustained move. Crossovers that change executed
+weights can generate turnover and transaction costs. **Lag**: a
+moving-average crossover only confirms a trend change after it has partly
+already happened (more so for a slower `slow_window`)
+-- some of the early, most profitable part of a new trend is structurally
+missed. **No magnitude information**: the signal only knows "above" or
+"below", not "by how much" -- a fast average barely above the slow one
+and a fast average far above it produce the identical `+1` signal.
+"""
+
+_REFERENCES = (
+ "Perry J. Kaufman, [*Trading Systems and Methods*, 5th ed.](https://doi."
+ "org/10.1002/9781119202561), is the specific source used here for "
+ "Kaufman's Efficiency Ratio and broader trend-system context. Zakamulin "
+ '& Giner (2023), ["Optimal trend-following with transaction costs"]('
+ "https://doi.org/10.1016/j.irfa.2023.102928), studies the relationship "
+ "between trend models, transaction costs and simple moving-average "
+ "crossover rules. Neither source establishes that QuantLab's specific "
+ "parameter choices or lookback windows will remain profitable."
+)
+
+_PARAMETERS = [
+ ParameterDoc(
+ name="fast_window",
+ what="Trailing window (periods) for the fast moving average.",
+ where="Step 1.",
+ why="Sets how quickly the 'current' side of the crossover reacts "
+ "to new prices.",
+ default="20",
+ typical_range="5-50 periods.",
+ effect_increase="A smoother fast average, closer to the slow one "
+ "-- fewer, later crossovers.",
+ effect_decrease="A twitchier fast average -- more, earlier "
+ "crossovers, more whipsaw risk in a choppy market.",
+ tradeoffs="Responsiveness vs. whipsaw frequency.",
+ interactions="Must be strictly less than slow_window; the gap "
+ "between the two mainly sets the pair's relative smoothing/lag, "
+ "not a price-move-size threshold -- both a small sustained drift "
+ "and a single sharp move can flip the crossover, depending on how "
+ "the two averages evolve.",
+ ),
+ ParameterDoc(
+ name="slow_window",
+ what="Trailing window (periods) for the slow moving average.",
+ where="Step 1.",
+ why="Defines the longer-term reference level the fast average is "
+ "compared against.",
+ default="100",
+ typical_range="50-200 periods.",
+ effect_increase="A more stable long-term reference, but a slower, "
+ "later-confirming signal -- more of an established trend is "
+ "missed before the crossover fires.",
+ effect_decrease="A faster-reacting reference, closer to "
+ "fast_window -- less lag, but a noisier, whipsaw-prone signal.",
+ tradeoffs="Confirmation lag vs. responsiveness.",
+ interactions="Must be strictly greater than fast_window.",
+ ),
+ ParameterDoc(
+ name="long_only",
+ what="Whether a downtrend (fast below slow) emits a flat signal "
+ "(True) or a short signal (False).",
+ where="Final clip to >= 0.",
+ why="Many portfolios/mandates cannot or should not short.",
+ default="True",
+ typical_range="Boolean.",
+ effect_increase="N/A (boolean).",
+ effect_decrease="N/A (boolean).",
+ tradeoffs="True avoids short-specific costs/constraints but "
+ "forfeits potential gains from downtrends; False represents both "
+ "directions but introduces short-side risk and additional ways for "
+ "a noisy crossover to create an adverse target.",
+ interactions="The allocator, portfolio constraints and rebalance "
+ "schedule decide whether that short signal becomes an executed short.",
+ ),
+ ParameterDoc(
+ name="price_type",
+ what="Which price series ('adjusted_close' or 'close') "
+ "generate_signals() reads. Execution/costs always use the raw "
+ "close regardless.",
+ where="Feeds both moving averages in step 1.",
+ why="A split or large dividend shows up as a price jump in raw "
+ "close but not in adjusted close -- unadjusted, it would look "
+ "exactly like a real crossover.",
+ default="adjusted_close",
+ typical_range="adjusted_close (recommended) or close.",
+ effect_increase="N/A -- a choice, not a magnitude.",
+ effect_decrease="N/A -- a choice, not a magnitude.",
+ tradeoffs="adjusted_close avoids false crossovers from corporate "
+ "actions; close matches what was literally quoted.",
+ interactions="A split near the current date would otherwise "
+ "trigger a spurious crossover in both moving averages at once.",
+ ),
+ ParameterDoc(
+ name="stop_loss_pct",
+ what="Fractional (e.g. 0.10 = 10%) gross-return threshold that "
+ "force-flattens this symbol's REAL executed position -- unlike "
+ "the crossover itself, which is memoryless (no notion of 'since "
+ "entry'), this operates on the actual position held after the "
+ "allocator/constraints/rebalancing/execution.",
+ where="Applied downstream of generate_signals() entirely -- see "
+ "`quantlab.backtesting.accounting._detect_stop_loss_take_profit`. "
+ "generate_signals() itself is unchanged by this parameter.",
+ why="A crossover only confirms a trend change after it has partly "
+ "already happened (more so for a slower slow_window) -- this "
+ "bounds the realized loss directly while waiting for the slower "
+ "crossover to catch up.",
+ default="None (disabled) -- enabling it changes no existing "
+ "behavior unless explicitly set.",
+ typical_range="0.05-0.20, or None to disable.",
+ effect_increase="More room before a forced exit -- fewer stop-outs "
+ "on ordinary whipsaw, larger potential realized loss per trade.",
+ effect_decrease="Tighter monetary risk control, more prone to "
+ "being stopped out by a temporary adverse move before the "
+ "crossover itself reverses.",
+ tradeoffs="Realized-loss protection vs. premature exits. Evaluated "
+ "on GROSS (pre-cost) return -- QuantLab's execution cost model is "
+ "portfolio-level only, so an exact net-of-cost trigger is not "
+ "presently computable; this is a disclosed design convention, not "
+ "a universal definition.",
+ interactions="Once triggered, no immediate re-entry at a rebased "
+ "price -- flat until the position's next real entry (a fresh "
+ "flat-to-non-flat transition of the executed weight), even if the "
+ "raw crossover itself has not flipped back.",
+ ),
+ ParameterDoc(
+ name="take_profit_pct",
+ what="Fractional (e.g. 0.10 = 10%) gross-return threshold that "
+ "force-flattens this symbol's REAL executed position on the "
+ "favorable side -- locks in a gain directly rather than waiting "
+ "for the crossover to reverse.",
+ where="Same mechanism as stop_loss_pct, opposite direction.",
+ why="Realizes a gain directly once a target is reached, instead "
+ "of depending on the trend persisting (and then reversing) before "
+ "the crossover itself signals an exit.",
+ default="None (disabled) -- enabling it changes no existing "
+ "behavior unless explicitly set.",
+ typical_range="0.05-0.20, or None to disable.",
+ effect_increase="Lets more of a favorable trend run before locking it in.",
+ effect_decrease="Locks in gains earlier, potentially forfeiting "
+ "further trend continuation.",
+ tradeoffs="Locking in gains early vs. capturing a longer trend.",
+ interactions="Independent of stop_loss_pct; see its own doc for "
+ "the shared gross-return/re-entry conventions.",
+ ),
+]
+
+
+def _lab(st: Any) -> None:
+ from quantlab.dashboard.explorer.labs.trend_following import render
+
+ render(st)
+
+
+#: Fixed windows for the two diagnostics below, matching the interactive
+#: lab's own defaults -- see time_series_momentum's identically-motivated
+#: constants for why these are fixed rather than strategy parameters.
+_WHIPSAW_WINDOW = 126
+_EFFICIENCY_RATIO_WINDOW = 200
+#: The Results-tab Efficiency Ratio slider's own declared bounds -- must
+#: match the literal min/max passed to st.slider() below exactly.
+_ER_SLIDER_MIN = 5
+_ER_SLIDER_MAX = _EFFICIENCY_RATIO_WINDOW
+
+
+def _default_er_window(slow_window: int) -> int:
+ """Clamp the ER-window default into the slider's own declared range.
+
+ `slow_window` can legitimately be as low as 2 (`fast_window=1,
+ slow_window=2` is a valid strategy config -- only `fast_window <
+ slow_window` and both `>= 1` are enforced), which would otherwise put
+ `min(slow_window, _EFFICIENCY_RATIO_WINDOW)` below the slider's
+ declared minimum of 5.
+ """
+ return min(_ER_SLIDER_MAX, max(_ER_SLIDER_MIN, slow_window))
+
+
+@dataclass(frozen=True)
+class TrendFollowingDiagnostics:
+ """Crossover/whipsaw/trend-strength diagnostics, one row per symbol.
+
+ ``summary`` is computed at the default whipsaw/Efficiency Ratio
+ windows (``_WHIPSAW_WINDOW``, ``min(slow_window,
+ _EFFICIENCY_RATIO_WINDOW)``). ``prices``/``signal``/``slow_window``
+ are carried alongside so the Results tab -- and the exported HTML
+ report, which reflects the same live widget choice (see
+ ``_report_section``) -- can recompute both diagnostics at a
+ user-chosen window on demand, a cheap, purely local recomputation,
+ not a backtest re-run (see ``_render_diagnostics``).
+ """
+
+ summary: pd.DataFrame
+ prices: dict[str, pd.Series]
+ fast_ma: dict[str, pd.Series]
+ slow_ma: dict[str, pd.Series]
+ signal: dict[str, pd.Series]
+ slow_window: int
+
+
+def _whipsaw_and_efficiency_ratio_table(
+ prices: dict[str, pd.Series],
+ signal: dict[str, pd.Series],
+ whipsaw_window: int,
+ er_window: int,
+) -> pd.DataFrame:
+ import numpy as np
+
+ from quantlab.features.technical import efficiency_ratio
+
+ rows = []
+ for symbol, series in prices.items():
+ flips = signal[symbol].diff().fillna(0.0).ne(0.0)
+ rolling_flips = flips.rolling(whipsaw_window, min_periods=1).sum()
+ er = efficiency_ratio(series, er_window)
+ rows.append(
+ {
+ "Symbol": symbol,
+ f"Whipsaw (flips / {whipsaw_window}p, latest)": float(
+ rolling_flips.iloc[-1]
+ )
+ if len(rolling_flips)
+ else float("nan"),
+ "Median Efficiency Ratio": float(np.nanmedian(er.to_numpy()))
+ if len(er)
+ else float("nan"),
+ }
+ )
+ return pd.DataFrame(rows).set_index("Symbol")
+
+
+def _compute_diagnostics(
+ data: pd.DataFrame, cfg: ExperimentConfig
+) -> TrendFollowingDiagnostics:
+ from quantlab.data.base import price_matrix
+ from quantlab.features.momentum import ma_crossover_signal, moving_average
+
+ params = cfg.strategy_parameters
+ fast_window = int(params.get("fast_window", 20))
+ slow_window = int(params.get("slow_window", 100))
+ er_window = _default_er_window(slow_window)
+ price_type = cfg.strategy.signal_price_type
+ price_frame = price_matrix(data, adjusted=price_type != "close")
+
+ prices: dict[str, pd.Series] = {}
+ fast_ma: dict[str, pd.Series] = {}
+ slow_ma: dict[str, pd.Series] = {}
+ signal: dict[str, pd.Series] = {}
+ for symbol in price_frame.columns:
+ series = price_frame[symbol]
+ prices[symbol] = series
+ fast_ma[symbol] = moving_average(series, fast_window)
+ slow_ma[symbol] = moving_average(series, slow_window)
+ signal[symbol] = ma_crossover_signal(series, fast_window, slow_window)
+ summary = _whipsaw_and_efficiency_ratio_table(
+ prices, signal, _WHIPSAW_WINDOW, er_window
+ )
+ return TrendFollowingDiagnostics(
+ summary=summary,
+ prices=prices,
+ fast_ma=fast_ma,
+ slow_ma=slow_ma,
+ signal=signal,
+ slow_window=slow_window,
+ )
+
+
+def _render_diagnostics(st: Any, result: TrendFollowingDiagnostics) -> None:
+ from quantlab.dashboard.explorer.shared_components import (
+ ENTRY_LINE_COLOR,
+ EXIT_LINE_COLOR,
+ render_price_chart,
+ )
+
+ st.subheader("Crossover / whipsaw / trend-strength diagnostics")
+ st.caption(
+ "Near 1 Efficiency Ratio: a clean, sustained trend (favourable for "
+ "this strategy). Near 0: a choppy path (noise dominating). A high "
+ "whipsaw count alongside a low Efficiency Ratio describes a market "
+ "this strategy struggles with."
+ )
+ col_w, col_er = st.columns(2)
+ whipsaw_window = col_w.slider(
+ "Count flips over the trailing N periods",
+ 20,
+ 504,
+ _WHIPSAW_WINDOW,
+ key="tf_results_whipsaw_window",
+ )
+ default_er_window = _default_er_window(result.slow_window)
+ er_window = col_er.slider(
+ "Efficiency Ratio window",
+ _ER_SLIDER_MIN,
+ _ER_SLIDER_MAX,
+ default_er_window,
+ key="tf_results_er_window",
+ )
+ st.caption(
+ "Diagnostic settings only -- changing these values does not rerun "
+ "or alter the backtest; they only change how the already-computed "
+ "signal is analyzed. These are NOT strategy parameters: they affect "
+ "neither the signal nor the executed trades."
+ )
+ if whipsaw_window == _WHIPSAW_WINDOW and er_window == default_er_window:
+ summary = result.summary
+ else:
+ summary = _whipsaw_and_efficiency_ratio_table(
+ result.prices, result.signal, whipsaw_window, er_window
+ )
+ st.dataframe(summary, width="stretch")
+ symbol = st.selectbox("Symbol", list(result.signal), key="tf_results_diag_symbol")
+ render_price_chart(
+ st,
+ {
+ "Price": result.prices[symbol],
+ "Fast MA": result.fast_ma[symbol],
+ "Slow MA": result.slow_ma[symbol],
+ },
+ title=f"{symbol}: fast/slow moving-average crossover",
+ # Price is left at Plotly's own default first-trace color; Fast/
+ # Slow MA get explicit, visibly distinct colors so neither is ever
+ # mistaken for the price line itself.
+ colors={"Fast MA": ENTRY_LINE_COLOR, "Slow MA": EXIT_LINE_COLOR},
+ )
+ render_price_chart(
+ st,
+ {"Crossover signal": result.signal[symbol]},
+ title=f"{symbol}: raw crossover signal",
+ yaxis_title="Signal",
+ )
+ flips = result.signal[symbol].diff().fillna(0.0).ne(0.0)
+ rolling_flips = flips.rolling(whipsaw_window, min_periods=1).sum()
+ render_price_chart(
+ st,
+ {f"Raw crossover changes in trailing {whipsaw_window} periods": rolling_flips},
+ title=f"{symbol}: raw crossover-change frequency over time",
+ yaxis_title="Flip count",
+ )
+ from quantlab.features.technical import efficiency_ratio
+
+ er_series = efficiency_ratio(result.prices[symbol], er_window)
+ render_price_chart(
+ st,
+ {"Efficiency Ratio": er_series},
+ title=f"{symbol}: Kaufman's Efficiency Ratio",
+ yaxis_title="ER",
+ )
+
+
+def _report_section(result: TrendFollowingDiagnostics) -> DiagnosticsSection:
+ from quantlab.dashboard.explorer.shared_components import live_widget_value
+ from quantlab.reporting.sections import DiagnosticsSection
+
+ default_er_window = _default_er_window(result.slow_window)
+ # Reflects the user's own live Results-tab slider choices (see
+ # _render_diagnostics), not always the fixed defaults -- falls back to
+ # them when the dashboard isn't running at all (e.g. the CLI's own
+ # report generation) or those sliders were never rendered this session.
+ whipsaw_window = live_widget_value("tf_results_whipsaw_window", _WHIPSAW_WINDOW)
+ er_window = live_widget_value("tf_results_er_window", default_er_window)
+ if whipsaw_window == _WHIPSAW_WINDOW and er_window == default_er_window:
+ summary = result.summary
+ else:
+ summary = _whipsaw_and_efficiency_ratio_table(
+ result.prices, result.signal, whipsaw_window, er_window
+ )
+ table = summary.reset_index()
+ return DiagnosticsSection(
+ table=table,
+ note=(
+ "Trend-following diagnostics per symbol: the most recent "
+ f"whipsaw (raw crossover flip) count over a trailing "
+ f"{whipsaw_window}-period window, and the median Kaufman "
+ f"Efficiency Ratio over a trailing {er_window}-period window "
+ "(near 1 = clean trend, near 0 = choppy noise)."
+ ),
+ )
+
+
+register_profile(
+ StrategyProfile(
+ strategy_name="trend_following",
+ display_name="Trend Following",
+ category="Trend / momentum",
+ overview_md=_OVERVIEW,
+ economic_intuition_md=_ECONOMIC_INTUITION,
+ mathematical_definition_md=_MATH,
+ assumptions_md=_ASSUMPTIONS,
+ diagnostics_md=_DIAGNOSTICS,
+ interpretation_md=_INTERPRETATION,
+ limitations_md=_LIMITATIONS,
+ references_md=_REFERENCES,
+ parameters=_PARAMETERS,
+ lab=_lab,
+ results_diagnostics=ResultsDiagnostics(
+ key="trend_following_diagnostics",
+ compute=_compute_diagnostics,
+ render=_render_diagnostics,
+ report_section=_report_section,
+ ),
+ )
+)
diff --git a/src/quantlab/dashboard/explorer/shared_components.py b/src/quantlab/dashboard/explorer/shared_components.py
new file mode 100644
index 0000000..20242f1
--- /dev/null
+++ b/src/quantlab/dashboard/explorer/shared_components.py
@@ -0,0 +1,593 @@
+"""Reusable Strategy Explorer UI components -- presentation only, no calculation.
+
+Every function takes ``st`` (the Streamlit module, or a compatible fake for
+tests) as its first argument, mirroring ``quantlab.dashboard.components``'s
+own convention. The actual numbers always come from ``quantlab.features.*``
+or a lab's own recomputation -- nothing here fits a statistic or builds a
+signal; it only draws what it is given.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from datetime import date
+from typing import Any, cast
+
+import pandas as pd
+
+from quantlab.constants import EPSILON
+from quantlab.features.stationarity import ADFResult, CointegrationResult
+from quantlab.logging_config import get_logger
+
+logger = get_logger(__name__)
+
+#: Caps the shared lab price cache below -- a long dashboard session
+#: trying many symbol/date/source combinations across every lab must not
+#: grow it without bound.
+_PRICE_CACHE_MAX_ENTRIES = 32
+
+#: Shared "one color per role" palette for entry/exit/stop threshold lines
+#: across labs -- both the positive and negative side of a threshold get
+#: the SAME color (they are the same concept, mirrored), and different
+#: roles get visibly different colors. Deliberately avoids blue: the
+#: underlying price/indicator series these overlay is typically blue
+#: (Plotly's own default first trace color).
+ENTRY_LINE_COLOR = "#FF8C00" # orange
+EXIT_LINE_COLOR = "#2CA02C" # green
+STOP_LINE_COLOR = "#D62728" # red
+VIABLE_ENTRY_MARKER_COLOR = "#9467BD" # purple, distinct from the 3 roles above
+
+
+def live_widget_value(key: str, default: Any) -> Any:
+ """Read a widget's CURRENT value from ``st.session_state``, if any.
+
+ Used so a Results-tab report (generated separately from the
+ interactive render pass -- see ``quantlab.dashboard.explorer.profile.
+ ResultsDiagnostics``) can reflect a user's live diagnostic-only widget
+ choice (e.g. a forward-return horizon slider) without threading that
+ value through the ``compute``/``render``/``report_section`` pipeline
+ explicitly. Falls back to ``default`` whenever that widget's value
+ isn't available -- not just when Streamlit itself isn't installed, but
+ also the ordinary case of the CLI's own report generation (``quantlab.
+ cli``), which calls ``report_section`` directly with no Streamlit
+ runtime/session at all, and the case where this particular widget was
+ never rendered this session (e.g. its section was never opened).
+
+ ``streamlit`` missing entirely is the one case narrowed to a silent
+ fallback (documented, expected -- e.g. a stripped-down test
+ environment). Reading ``st.session_state`` itself failing for any
+ OTHER reason is unexpected (empirically, it degrades gracefully with
+ no exception even with no active script run context) -- logged rather
+ than swallowed silently, so an unrelated bug degrading a dashboard
+ value still leaves a trace instead of vanishing without one.
+ """
+ try:
+ import streamlit as st
+ except ImportError:
+ return default
+ try:
+ return st.session_state.get(key, default)
+ except Exception:
+ logger.exception(
+ "live_widget_value(%r) could not read st.session_state -- "
+ "falling back to default %r",
+ key,
+ default,
+ )
+ return default
+
+
+def strong(value: str) -> str:
+ """Wrap a standout value in a heavier-than-markdown-bold inline span.
+
+ For use inside an ``st.caption``/``st.markdown`` string passed with
+ ``unsafe_allow_html=True``. Plain markdown ``**bold**`` renders at a
+ single fixed weight that a caption's own small, muted styling can
+ wash out -- this pushes the weight further so a key computed number
+ still reads as emphasized against the surrounding caption text.
+ """
+ return f'{value}'
+
+
+def centered_indicator_threshold_overlay(
+ indicator: pd.Series,
+ indicator_name: str,
+ *,
+ entry_threshold: float,
+ exit_threshold: float,
+ stop_threshold: float | None,
+) -> tuple[dict[str, pd.Series], dict[str, str]]:
+ """Build the named +/-entry/+/-exit/+/-stop series and their line colors.
+
+ Shared by every "centered indicator with entry/exit/stop thresholds"
+ chart (mean_reversion, pairs_trading -- both their interactive labs and
+ their Results-tab diagnostics), so the naming/coloring convention stays
+ identical everywhere this chart appears. Returns ``(series, colors)``,
+ both keyed by the same threshold labels, ready to pass straight into
+ :func:`render_price_chart` (``series`` merged with the caller's own
+ indicator entry, ``colors`` merged with any of the caller's own, e.g.
+ a viable-entry marker color).
+ """
+ series: dict[str, pd.Series] = {
+ indicator_name: indicator,
+ f"+entry {entry_threshold:g}": pd.Series(
+ entry_threshold, index=indicator.index
+ ),
+ f"-entry {entry_threshold:g}": pd.Series(
+ -entry_threshold, index=indicator.index
+ ),
+ f"+exit {exit_threshold:g}": pd.Series(exit_threshold, index=indicator.index),
+ f"-exit {exit_threshold:g}": pd.Series(-exit_threshold, index=indicator.index),
+ }
+ colors: dict[str, str] = {
+ f"+entry {entry_threshold:g}": ENTRY_LINE_COLOR,
+ f"-entry {entry_threshold:g}": ENTRY_LINE_COLOR,
+ f"+exit {exit_threshold:g}": EXIT_LINE_COLOR,
+ f"-exit {exit_threshold:g}": EXIT_LINE_COLOR,
+ }
+ if stop_threshold is not None:
+ series[f"+stop {stop_threshold:g}"] = pd.Series(
+ stop_threshold, index=indicator.index
+ )
+ series[f"-stop {stop_threshold:g}"] = pd.Series(
+ -stop_threshold, index=indicator.index
+ )
+ colors[f"+stop {stop_threshold:g}"] = STOP_LINE_COLOR
+ colors[f"-stop {stop_threshold:g}"] = STOP_LINE_COLOR
+ return series, colors
+
+
+def load_explorer_prices_cached(
+ st: Any,
+ symbols: Sequence[str],
+ *,
+ source: str,
+ calendar: str,
+ start_date: date,
+ end_date: date,
+ use_bundled_demo_data: bool = False,
+) -> pd.DataFrame:
+ """Load Strategy Explorer lab price data through one shared, bounded cache.
+
+ Every lab shares this one ``@st.cache_data`` cache, keyed the same way
+ and bounded with ``max_entries`` so a long session trying many symbol/
+ date/source combinations cannot grow it without limit. ``source``/
+ ``calendar``/``use_bundled_demo_data`` are real cache-key arguments
+ (not closed-over constants) so two calls that differ only in one of
+ them can never collide.
+ """
+ from quantlab.dashboard.state import load_explorer_prices
+
+ @st.cache_data(
+ show_spinner="Loading price data...", max_entries=_PRICE_CACHE_MAX_ENTRIES
+ )
+ def _load(
+ symbols: tuple[str, ...],
+ source: str,
+ calendar: str,
+ start: date,
+ end: date,
+ use_bundled_demo_data: bool,
+ ) -> pd.DataFrame:
+ return load_explorer_prices(
+ list(symbols),
+ source=source,
+ calendar=calendar,
+ start_date=start,
+ end_date=end,
+ use_bundled_demo_data=use_bundled_demo_data,
+ )
+
+ return cast(
+ pd.DataFrame,
+ _load(
+ tuple(sorted(symbols)),
+ source,
+ calendar,
+ start_date,
+ end_date,
+ use_bundled_demo_data,
+ ),
+ )
+
+
+def render_symbol_and_source_picker(
+ st: Any,
+ *,
+ key_prefix: str,
+ default_symbols: Sequence[str],
+ default_calendar: str = "XNYS",
+) -> tuple[list[str], str, str, bool] | None:
+ """Let a lab pick real data via the same data sources backtest/walk-forward use.
+
+ Unlike the main dashboard's own per-instrument ``InstrumentConfig``
+ table, every symbol here shares ONE calendar, since a lab computes on
+ a single flat price matrix, not individually-configured instruments.
+
+ Returns ``(symbols, source, calendar, use_bundled_demo_data)``, or
+ ``None`` when nothing is selected yet (the caller should show a message
+ rather than proceed) OR when the selected symbols would need more than
+ one calendar (a lab cannot represent that -- the caller shows an error
+ instead of silently picking one calendar for all of them). Every widget
+ key is prefixed by ``key_prefix`` so two labs -- or a lab and the main
+ sidebar -- never collide over the same ``session_state`` entry, notably
+ the Binance "Load Binance symbols" gate, whose flag would otherwise be a
+ single dashboard-wide switch shared by everything that uses it.
+ """
+ from quantlab.config import DataSourceName
+ from quantlab.dashboard.components import (
+ binance_universe_labels,
+ parse_symbols,
+ symbols_picker,
+ yahoo_universe_labels,
+ )
+ from quantlab.data.resolution import detect_calendar
+
+ source = st.radio(
+ "Data source",
+ ["csv", "yahoo", "binance"],
+ key=f"{key_prefix}_source",
+ horizontal=True,
+ help=(
+ "csv: local files under data/raw (bundled synthetic demo "
+ "data available as an offline fallback below). yahoo/"
+ "binance: the same data sources backtest/walk-forward use."
+ ),
+ )
+ if source == "yahoo":
+ symbols = symbols_picker(
+ st,
+ yahoo_universe_labels(),
+ f"{key_prefix}_yahoo_symbols",
+ tuple(default_symbols),
+ accept_new_options=True,
+ )
+ if not symbols:
+ return None
+ # Best-effort per-symbol guess (e.g. "1211.HK" -> XHKG) -- a bare
+ # US ticker with no recognized suffix falls back to XNYS, exactly
+ # like the main dashboard's own per-instrument table default.
+ detected = {
+ detect_calendar(symbol, DataSourceName.YAHOO) or default_calendar
+ for symbol in symbols
+ }
+ if len(detected) > 1:
+ st.error(
+ "Selected symbols need different calendars "
+ f"({', '.join(sorted(detected))}) -- this lab computes on a "
+ "single shared price matrix and cannot represent more than "
+ "one calendar at once. Pick symbols on the same market, or "
+ "use the main dashboard's Backtest mode (each instrument "
+ "gets its own calendar there)."
+ )
+ return None
+ # A keyed st.text_input only honours its `value=` argument the
+ # FIRST time that key is created -- once session_state holds a
+ # value for it, passing a freshly-detected default on a later
+ # rerun is silently ignored by Streamlit itself, leaving the field
+ # stuck on a stale guess after the symbol selection changes (e.g.
+ # AAPL -> XNYS auto-filled, then swapped for 1211.HK, still
+ # showing XNYS). Detected here explicitly: only when the symbol
+ # SET actually changed since the guess was last made is the
+ # session_state value overwritten -- an unrelated rerun (a
+ # different widget elsewhere) never clobbers the user's own
+ # manual edit.
+ calendar_key = f"{key_prefix}_yahoo_calendar"
+ symbols_for_key = f"{calendar_key}_for_symbols"
+ symbols_tuple = tuple(symbols)
+ if st.session_state.get(symbols_for_key) != symbols_tuple:
+ st.session_state[calendar_key] = next(iter(detected))
+ st.session_state[symbols_for_key] = symbols_tuple
+ calendar = st.text_input(
+ "Calendar",
+ key=calendar_key,
+ help="Auto-detected from the symbol suffix where possible "
+ "(e.g. '.HK' -> XHKG) -- edit if the guess is wrong. '24/7' "
+ "for a continuous market, or a pandas_market_calendars name "
+ "such as XNYS, XHKG, XLON.",
+ ).strip()
+ if not calendar:
+ return None
+ return symbols, source, calendar, False
+ if source == "binance":
+ load_flag_key = f"{key_prefix}_binance_universe_load_requested"
+ if not st.session_state.get(load_flag_key, False):
+ st.caption(
+ "Loading the tradable symbol list calls Binance's public "
+ "API. Click below to fetch it (cached for an hour after "
+ "that)."
+ )
+ if st.button(
+ "Load Binance symbols",
+ key=f"{key_prefix}_binance_universe_load_button",
+ ):
+ # See app.py's `_binance_symbols_picker` for why this
+ # deliberately does NOT call st.rerun(): that would abort
+ # this run right here, before finishing, dropping this
+ # widget's own keyed session-state value back to its
+ # default on the very next rerun.
+ st.session_state[load_flag_key] = True
+ else:
+ return None
+ symbols = symbols_picker(
+ st, binance_universe_labels(st), f"{key_prefix}_binance_symbols", ()
+ )
+ if not symbols:
+ return None
+ return symbols, source, "24/7", False
+ # source == "csv"
+ raw = st.text_input(
+ "CSV symbols (comma-separated)",
+ ", ".join(default_symbols),
+ key=f"{key_prefix}_csv_symbols_input",
+ help=(
+ "Local files under data/raw, one CSV per symbol. Falls back "
+ "to QuantLab's bundled synthetic demo data below when every "
+ "requested local file is absent."
+ ),
+ )
+ symbols = parse_symbols(raw)
+ use_bundled_demo_data = st.toggle(
+ "Allow bundled synthetic demo data",
+ value=True,
+ key=f"{key_prefix}_use_bundled_demo_data",
+ help=(
+ "On by default here (unlike the main dashboard) so this lab "
+ "keeps working offline with no setup. Turn off once you have "
+ "real local files under data/raw for these symbols."
+ ),
+ )
+ # A bare local filename carries no calendar information at all --
+ # defaults to XNYS (matching the main dashboard's own per-instrument
+ # table default) but is always editable, since these could just as
+ # well be futures, a non-XNYS index, or anything else.
+ calendar = st.text_input(
+ "Calendar",
+ default_calendar,
+ key=f"{key_prefix}_csv_calendar",
+ help="'24/7' for a continuous market, or a pandas_market_calendars "
+ "name such as XNYS, XHKG, XLON -- CSV data carries no calendar "
+ "information, so this cannot be auto-detected.",
+ ).strip()
+ if not symbols or not calendar:
+ return None
+ return symbols, source, calendar, use_bundled_demo_data
+
+
+def render_price_chart(
+ st: Any,
+ series: Mapping[str, pd.Series],
+ *,
+ title: str,
+ height: int = 360,
+ yaxis_title: str = "Price",
+ markers: Mapping[str, pd.Series] | None = None,
+ marker_size: int = 9,
+ colors: Mapping[str, str] | None = None,
+) -> None:
+ """Plot one or more named series on a shared time axis.
+
+ The generic building block every lab uses for "show me this price
+ series plus whatever indicator/threshold/overlay is currently
+ selected" -- when a widget changes a parameter, the lab recomputes
+ the relevant series and calls this again with the new values, which
+ is how Streamlit's own rerun-on-interaction model makes a parameter's
+ effect immediately visible without a bespoke "impact" widget.
+
+ ``markers``, if given, are drawn as discrete point markers (not
+ connected lines) layered on top of ``series`` -- for a sparse,
+ date-indexed callout (e.g. "entries actually viable under a filter")
+ that would be unreadable as its own connected line. Each series may
+ contain ``NaN``/be missing dates freely; Plotly simply skips them.
+
+ ``colors``, if given, maps a series/marker name to an explicit CSS
+ color, overriding Plotly's default per-trace color cycling -- lets a
+ caller give two differently-named series (e.g. a positive and a
+ negative threshold line) the SAME color deliberately, which the
+ default cycling (assigns by trace order, not by intent) cannot express.
+ """
+ import plotly.graph_objects as go
+
+ color_map = colors or {}
+ fig = go.Figure()
+ for name, values in series.items():
+ line = {"color": color_map[name]} if name in color_map else None
+ fig.add_trace(
+ go.Scatter(x=values.index, y=values, mode="lines", name=name, line=line)
+ )
+ for name, values in (markers or {}).items():
+ marker = {"size": marker_size, "symbol": "circle"}
+ if name in color_map:
+ marker["color"] = color_map[name]
+ fig.add_trace(
+ go.Scatter(
+ x=values.index, y=values, mode="markers", name=name, marker=marker
+ )
+ )
+ fig.update_layout(
+ title=title, height=height, xaxis_title="Date", yaxis_title=yaxis_title
+ )
+ st.plotly_chart(fig, width="stretch")
+
+
+def render_price_explorer(st: Any, prices: pd.Series, symbol: str) -> None:
+ """Plot one symbol's own price series."""
+ render_price_chart(st, {symbol: prices}, title=f"{symbol} price")
+
+
+def render_correlation_matrix(st: Any, matrix: pd.DataFrame) -> None:
+ """Render a symbol x symbol correlation matrix as a heatmap + table.
+
+ Shared by the Pairs Trading lab and the Results/report pair-diagnostics
+ section -- never reimplemented separately in either place.
+ """
+ import plotly.graph_objects as go
+
+ values = matrix.to_numpy()
+ fig = go.Figure(
+ go.Heatmap(
+ z=values,
+ x=[str(c) for c in matrix.columns],
+ y=[str(r) for r in matrix.index],
+ colorscale="RdBu",
+ zmid=0.0,
+ zmin=-1.0,
+ zmax=1.0,
+ colorbar={"title": "corr"},
+ text=[[f"{v:.2f}" for v in row] for row in values],
+ texttemplate="%{text}",
+ )
+ )
+ fig.update_layout(title="Correlation matrix (of returns)", height=380)
+ st.plotly_chart(fig, width="stretch")
+ st.dataframe(matrix.round(3), width="stretch")
+
+
+def render_stationarity_card(
+ st: Any,
+ result: ADFResult | CointegrationResult | None,
+ *,
+ label: str,
+) -> None:
+ """Display one stationarity/cointegration test result in plain language.
+
+ ``None`` (an inconclusive test -- too little data, or a numerical
+ failure) is rendered as an explicit message, never silently skipped,
+ so a missing result always reads as "inconclusive", not as "the
+ section vanished".
+ """
+ if result is None:
+ st.info(f"{label}: inconclusive (too little data, or a numerical failure).")
+ return
+ verdict = "Reject H0" if result.reject_null else "Cannot reject H0"
+ columns = st.columns(3)
+ columns[0].metric("Statistic", f"{result.statistic:.4f}")
+ columns[1].metric("p-value", f"{result.pvalue:.4f}")
+ columns[2].metric("Verdict", verdict)
+ critical = ", ".join(f"{k}={v:.3f}" for k, v in result.critical_values.items())
+ st.caption(f"**{label}** -- critical values: {critical}. {result.interpretation}")
+
+
+def render_stop_loss_take_profit_illustration(
+ st: Any,
+ positions: pd.Series | Mapping[str, pd.Series],
+ prices: pd.Series | Mapping[str, pd.Series],
+ *,
+ key_prefix: str,
+ position_groups: Sequence[tuple[str, ...]] | None = None,
+) -> None:
+ """Illustrate stop-loss/take-profit on the position(s) ALREADY shown above.
+
+ A simplified illustration only, clearly labeled as such: QuantLab's
+ real mechanism (``quantlab.backtesting.accounting.
+ _detect_stop_loss_take_profit``) operates on the REAL executed
+ position(s) after the allocator/constraints/rebalancing/execution --
+ this lab has no access to that pipeline. It instead applies the EXACT
+ SAME function directly to the position(s)/price(s) already displayed
+ above, which is mathematically identical to the real formula's own
+ reduction for that case -- not a second, approximate implementation.
+
+ A single ``pd.Series`` pair (the common case: one symbol) is treated
+ as one independent group. A ``Mapping[str, pd.Series]`` (e.g.
+ pairs_trading's two legs) is treated as one COMBINED group unless
+ ``position_groups`` says otherwise -- mirroring
+ ``BaseStrategy.position_groups()``'s own default/override convention.
+ """
+ from quantlab.backtesting.accounting import _detect_stop_loss_take_profit
+
+ if isinstance(positions, pd.Series):
+ assert isinstance(prices, pd.Series)
+ position_map = {"asset": positions}
+ price_map: Mapping[str, pd.Series] = {"asset": prices}
+ default_groups = None
+ else:
+ assert not isinstance(prices, pd.Series)
+ position_map = dict(positions)
+ price_map = prices
+ default_groups = (tuple(position_map),) if len(position_map) > 1 else None
+
+ st.markdown("#### Stop-loss / take-profit illustration")
+ st.caption(
+ "Simplified: applies QuantLab's exact stop-loss/take-profit "
+ "formula directly to the position(s)/price(s) shown above. "
+ "QuantLab's real backtest instead operates on the actual "
+ "EXECUTED position after the allocator/constraints/rebalancing/"
+ "execution, which this lab does not model -- treat this as "
+ "illustrative, not a preview of real backtest numbers."
+ )
+ col_sl, col_tp = st.columns(2)
+ stop_loss_pct = col_sl.slider(
+ "stop_loss_pct (0 = disabled)",
+ 0.0,
+ 0.5,
+ 0.0,
+ 0.01,
+ key=f"{key_prefix}_illustration_stop_loss",
+ )
+ take_profit_pct = col_tp.slider(
+ "take_profit_pct (0 = disabled)",
+ 0.0,
+ 0.5,
+ 0.0,
+ 0.01,
+ key=f"{key_prefix}_illustration_take_profit",
+ )
+ if stop_loss_pct <= 0.0 and take_profit_pct <= 0.0:
+ return
+
+ aligned_positions = {
+ symbol: series.reindex(price_map[symbol].index).fillna(0.0)
+ for symbol, series in position_map.items()
+ }
+ # `fill_method=None`: never let pandas' own version-dependent default
+ # forward-fill a gap before computing the return -- a genuine internal
+ # missing price must surface as NaN here, not silently vanish before
+ # this function even sees it. Only the very FIRST observation (no
+ # prior price to compare against at all -- not a "missing" price, a
+ # structurally absent one) is explicitly zeroed; every OTHER NaN is a
+ # real gap, reported below when it coincides with a held position
+ # rather than silently treated as a flat 0% return.
+ returns: dict[str, pd.Series] = {}
+ missing_while_held: list[str] = []
+ for symbol, series in price_map.items():
+ pct = series.pct_change(fill_method=None)
+ if len(pct):
+ pct.iloc[0] = 0.0
+ held = aligned_positions[symbol].abs() > EPSILON
+ gap_dates = pct.index[pct.isna() & held]
+ if len(gap_dates):
+ shown = ", ".join(str(d.date()) for d in gap_dates[:5])
+ if len(gap_dates) > 5:
+ shown += f", +{len(gap_dates) - 5} more"
+ missing_while_held.append(f"{symbol}: {shown}")
+ returns[symbol] = pct.fillna(0.0)
+ if missing_while_held:
+ st.warning(
+ "Missing price return(s) while a position was held -- treated "
+ "as 0% for this illustration only, which can hide a real "
+ "stop-loss/take-profit trigger on that date: "
+ + "; ".join(missing_while_held)
+ )
+ stop_loss_result = _detect_stop_loss_take_profit(
+ pd.DataFrame(aligned_positions),
+ pd.DataFrame(returns),
+ position_groups if position_groups is not None else default_groups,
+ stop_loss_pct if stop_loss_pct > 0.0 else None,
+ take_profit_pct if take_profit_pct > 0.0 else None,
+ )
+ gated, stop_loss_triggered, take_profit_triggered = stop_loss_result[:3]
+ chart_series = {}
+ for symbol in position_map:
+ chart_series[f"{symbol}: position (as displayed above)"] = aligned_positions[
+ symbol
+ ]
+ chart_series[f"{symbol}: position after stop-loss/take-profit"] = gated[symbol]
+ render_price_chart(
+ st,
+ chart_series,
+ title="Illustrative effect of stop-loss/take-profit on this position",
+ yaxis_title="Position",
+ )
+ n_stop = int(stop_loss_triggered.any(axis=1).sum())
+ n_take = int(take_profit_triggered.any(axis=1).sum())
+ st.caption(f"Stop-loss fired on {n_stop} date(s); take-profit on {n_take} date(s).")
diff --git a/src/quantlab/dashboard/state.py b/src/quantlab/dashboard/state.py
index dade34a..2a1d7e5 100644
--- a/src/quantlab/dashboard/state.py
+++ b/src/quantlab/dashboard/state.py
@@ -14,7 +14,6 @@
from quantlab.backtesting.result import BacktestResult
from quantlab.backtesting.runner import run_backtest_from_config
from quantlab.config import ExperimentConfig
-from quantlab.constants import GENERATED_REPORTS_DIR
from quantlab.data.base import SymbolSuggestion
from quantlab.data.binance import BinanceDataSource
from quantlab.data.loader import DataLoader
@@ -29,7 +28,9 @@
"detect_calendar",
"detect_source",
"estimate_walk_forward_backtest_count",
+ "load_explorer_prices",
"run_dashboard_backtest",
+ "run_dashboard_backtest_with_data",
"run_dashboard_bootstrap",
"run_dashboard_permutation_test",
"run_dashboard_sensitivity",
@@ -138,19 +139,37 @@ def run_dashboard_backtest(
return result, report.warnings
+def run_dashboard_backtest_with_data(
+ config: ExperimentConfig,
+) -> tuple[BacktestResult, list[str], pd.DataFrame]:
+ """Like :func:`run_dashboard_backtest`, but also returns the loaded frame.
+
+ A Strategy Explorer results diagnostic needing raw prices (e.g. pairs
+ trading) reuses this EXACT frame rather than reloading separately -- a
+ second, independent load could observe different data for a remote
+ source (Yahoo/Binance) if it changed, or a cache refreshed, between the
+ two calls, making the diagnostics silently describe different data than
+ the displayed result.
+ """
+ data, report = DataLoader().load(config)
+ result = run_backtest_from_config(data, config, data_quality_report=report)
+ return result, report.warnings, data
+
+
def _checkpoint_path(config: ExperimentConfig, technique: str) -> Path:
"""Return the on-disk checkpoint path for one technique of ``config``.
- Same convention the CLI uses (``GENERATED_REPORTS_DIR /
- experiment_name / ".checkpoint_.pkl"``), so an interrupted
- dashboard run (e.g. the Streamlit server process itself restarting) and
- a same-named CLI run can resume each other's progress. Two different
- dashboard configs that happen to share a default ``experiment_name``
- don't collide unsafely: ``compute_provenance``'s config/data/code match
- still gates whether a checkpoint is actually reused.
+ Same convention the CLI uses (``resolve_experiment_directory(config) /
+ ".checkpoint_.pkl"``), so an interrupted dashboard run (e.g.
+ the Streamlit server process itself restarting) and a same-named CLI
+ run can resume each other's progress. Two different dashboard configs
+ that happen to share a default ``experiment_name`` don't collide
+ unsafely: ``compute_provenance``'s config/data/code match still gates
+ whether a checkpoint is actually reused.
"""
- experiment_dir = GENERATED_REPORTS_DIR / config.experiment_name
- return experiment_dir / f".checkpoint_{technique}.pkl"
+ from quantlab.backtesting.result import resolve_experiment_directory
+
+ return resolve_experiment_directory(config) / f".checkpoint_{technique}.pkl"
def run_dashboard_walk_forward(
@@ -180,7 +199,9 @@ def run_dashboard_walk_forward(
data, report = DataLoader().load(config)
validator = WalkForwardValidator(config)
- train_window, validation_window, test_window = resolve_walk_forward_windows(config)
+ train_window, validation_window, test_window, step = resolve_walk_forward_windows(
+ config
+ )
result = validator.run(
data,
parameter_grid=parameter_grid_for_config(config),
@@ -188,6 +209,7 @@ def run_dashboard_walk_forward(
validation_window=validation_window,
test_window=test_window,
expanding=config.validation.expanding,
+ step=step,
on_progress=on_progress,
checkpoint_path=_checkpoint_path(config, "walk_forward"),
)
@@ -316,7 +338,7 @@ def run_dashboard_bootstrap(
periods_per_year=config.periods_per_year,
initial_capital=config.initial_capital,
risk_free_rate=config.risk_free_rate,
- ).summary()
+ ).summary(confidence_level=config.robustness.bootstrap.confidence_level)
def run_dashboard_permutation_test(
@@ -404,8 +426,61 @@ def run_dashboard_walk_forward_sensitivity(
def default_end_date() -> date:
- """A safe default end date that does not depend on wall-clock time."""
- return date(2024, 12, 31)
+ """A safe default end date that does not depend on wall-clock time.
+
+ Matches the bundled synthetic demo CSVs' own last observation (also
+ ``demo_offline.yaml``'s ``end_date``) -- the sidebar's Start/End date
+ widgets default to this range regardless of which instrument source is
+ picked, and the CSV picker's own default symbols (SPY/QQQ/TLT/GLD) are
+ exactly the bundled demo data. A later date here would make the very
+ first "just click Run" experience -- untouched defaults, no instrument
+ source changed -- immediately warn that data ends hundreds of days
+ before the requested end, for every one of those four symbols.
+ """
+ return date(2023, 10, 30)
+
+
+def load_explorer_prices(
+ symbols: list[str],
+ *,
+ source: str,
+ calendar: str,
+ start_date: date,
+ end_date: date,
+ use_bundled_demo_data: bool = False,
+) -> pd.DataFrame:
+ """Load canonical OHLCV data for a Strategy Explorer interactive lab.
+
+ A minimal, throwaway config -- ``data:`` is the only section that
+ matters for loading; ``strategy`` is a fixed placeholder ``DataLoader.
+ load()`` never inspects. Callers wrap this in ``st.cache_data`` (see
+ the labs) since every widget interaction reruns the whole script.
+ Exactly the same ``DataLoader``/``ExperimentConfig`` path backtest and
+ walk-forward use -- Yahoo/Binance/CSV all behave identically here.
+ ``use_bundled_demo_data`` mirrors the main dashboard's own toggle (see
+ ``app.py``'s "Allow bundled synthetic demo data"): only consulted for
+ ``source="csv"``, and only as a fallback when every requested local
+ file under ``data/raw`` is absent.
+ """
+ data_config: dict[str, object] = {
+ "instruments": [
+ {"symbol": symbol, "source": source, "calendar": calendar}
+ for symbol in symbols
+ ],
+ "start_date": start_date,
+ "end_date": end_date,
+ }
+ if use_bundled_demo_data:
+ data_config["use_bundled_demo_data"] = True
+ config = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "strategy_explorer_lab",
+ "data": data_config,
+ "strategy": {"name": "buy_and_hold"},
+ }
+ )
+ data, _ = DataLoader().load(config)
+ return data
@lru_cache(maxsize=1)
diff --git a/src/quantlab/features/__init__.py b/src/quantlab/features/__init__.py
index 08ed47a..e3e71a0 100644
--- a/src/quantlab/features/__init__.py
+++ b/src/quantlab/features/__init__.py
@@ -6,6 +6,7 @@
from __future__ import annotations
+from quantlab.features.correlation import correlation_matrix
from quantlab.features.cross_sectional import (
cross_sectional_demean,
cross_sectional_percentile,
@@ -18,17 +19,24 @@
distance_to_moving_average,
half_life,
normalized_distance_to_mean,
+ rolling_percentile_rank,
rolling_zscore,
rsi,
)
from quantlab.features.momentum import (
+ cross_sectional_momentum_persistence,
ma_crossover_signal,
momentum,
+ momentum_persistence,
moving_average,
price_above_ma,
rate_of_change,
volatility_adjusted_momentum,
)
+from quantlab.features.pairs_diagnostics import (
+ PairDiagnostics,
+ compute_pair_diagnostics,
+)
from quantlab.features.pipeline import FeaturePipeline, FeatureSpec
from quantlab.features.returns import (
cumulative_returns,
@@ -37,8 +45,16 @@
log_returns,
simple_returns,
)
+from quantlab.features.stationarity import (
+ ADFResult,
+ CointegrationResult,
+ adf_test,
+ cointegration_test,
+ hurst_exponent,
+)
from quantlab.features.technical import (
donchian_position,
+ efficiency_ratio,
exponential_moving_average,
macd,
rolling_max,
@@ -69,6 +85,8 @@
"ma_crossover_signal",
"price_above_ma",
"volatility_adjusted_momentum",
+ "momentum_persistence",
+ "cross_sectional_momentum_persistence",
# volatility
"historical_volatility",
"realized_volatility",
@@ -82,6 +100,7 @@
"rolling_zscore",
"distance_to_moving_average",
"normalized_distance_to_mean",
+ "rolling_percentile_rank",
"rsi",
"bollinger_bands",
"half_life",
@@ -97,6 +116,18 @@
"rolling_max",
"rolling_min",
"donchian_position",
+ "efficiency_ratio",
+ # correlation
+ "correlation_matrix",
+ # stationarity / cointegration
+ "ADFResult",
+ "CointegrationResult",
+ "adf_test",
+ "cointegration_test",
+ "hurst_exponent",
+ # pairs diagnostics
+ "PairDiagnostics",
+ "compute_pair_diagnostics",
# pipeline
"FeaturePipeline",
"FeatureSpec",
diff --git a/src/quantlab/features/_validation.py b/src/quantlab/features/_validation.py
index bb5090d..cce77d0 100644
--- a/src/quantlab/features/_validation.py
+++ b/src/quantlab/features/_validation.py
@@ -61,6 +61,13 @@ def boolean(value: object, *, name: str) -> bool:
return bool(value)
+def choice(value: object, *, name: str, options: frozenset[str]) -> str:
+ """Return a string restricted to a fixed set of accepted values."""
+ if not isinstance(value, str) or value not in options:
+ raise ValueError(f"{name} must be one of {sorted(options)}, got {value!r}.")
+ return value
+
+
def numeric_pandas(
data: PandasT,
*,
diff --git a/src/quantlab/features/correlation.py b/src/quantlab/features/correlation.py
new file mode 100644
index 0000000..18adb07
--- /dev/null
+++ b/src/quantlab/features/correlation.py
@@ -0,0 +1,32 @@
+"""Multi-asset correlation diagnostics."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+import pandas as pd
+
+from quantlab.features._validation import numeric_pandas
+
+_CorrelationMethod = Literal["pearson", "kendall", "spearman"]
+
+
+def correlation_matrix(
+ prices: pd.DataFrame, *, method: _CorrelationMethod = "pearson"
+) -> pd.DataFrame:
+ """Return the symbol x symbol correlation matrix of simple returns.
+
+ Computed on returns, not raw price levels -- price-level correlation is
+ routinely inflated by a shared trend even between economically
+ unrelated assets, while return correlation reflects actual co-movement.
+ ``method`` is passed straight through to ``DataFrame.corr``.
+ """
+ if not isinstance(prices, pd.DataFrame):
+ raise TypeError("prices must be a pandas DataFrame.")
+ validated = numeric_pandas(prices, name="prices", strictly_positive=True)
+ if method not in {"pearson", "kendall", "spearman"}:
+ raise ValueError(
+ f"method must be one of 'pearson'/'kendall'/'spearman', got {method!r}."
+ )
+ returns = validated.pct_change(fill_method=None)
+ return returns.corr(method=method)
diff --git a/src/quantlab/features/mean_reversion.py b/src/quantlab/features/mean_reversion.py
index 6b97e9e..773fe67 100644
--- a/src/quantlab/features/mean_reversion.py
+++ b/src/quantlab/features/mean_reversion.py
@@ -38,12 +38,47 @@ def normalized_distance_to_mean(prices: PandasT, window: int) -> PandasT:
return (validated - ma) / ma
-def rsi(prices: PandasT, window: int = 14) -> PandasT:
+def rolling_percentile_rank(
+ prices: PandasT, window: int, *, strictly_positive: bool = True
+) -> PandasT:
+ """Trailing percentile rank of the current price within its own window.
+
+ ``[0, 1]``: ``0`` when the current price is the lowest in the trailing
+ window, ``1`` when it is the highest, ``0.5`` in the middle. Ties are
+ averaged (pandas' default rank behavior). ``strictly_positive`` defaults
+ to ``True`` (the usual price-series case); pass ``False`` for a series
+ that can legitimately be zero or negative (e.g. a pairs-trading spread
+ residual) -- the rank computation itself is sign-agnostic, only the
+ input validation differs.
+ """
+ validated = numeric_pandas(
+ prices, name="prices", strictly_positive=strictly_positive
+ )
+ length = positive_int(window, name="window", minimum=2)
+
+ def _percentile_of_last(window_values: np.ndarray) -> float:
+ return float(pd.Series(window_values).rank(pct=True).iloc[-1])
+
+ return validated.rolling(length, min_periods=length).apply(
+ _percentile_of_last, raw=True
+ )
+
+
+def rsi(
+ prices: PandasT, window: int = 14, *, strictly_positive: bool = True
+) -> PandasT:
"""Relative Strength Index using Wilder-style exponential smoothing.
Returns values in ``[0, 100]``; a flat window is neutral at ``50``.
+ ``strictly_positive`` defaults to ``True`` (the usual price-series
+ case); pass ``False`` for a series that can legitimately be zero or
+ negative (e.g. a pairs-trading spread residual) -- RSI is computed
+ from period-over-period changes, which are sign-agnostic; only the
+ input validation differs.
"""
- validated = numeric_pandas(prices, name="prices", strictly_positive=True)
+ validated = numeric_pandas(
+ prices, name="prices", strictly_positive=strictly_positive
+ )
length = positive_int(window, name="window", minimum=2)
delta = validated.diff()
gain = delta.clip(lower=0.0)
diff --git a/src/quantlab/features/momentum.py b/src/quantlab/features/momentum.py
index 5c13707..eeb6ed0 100644
--- a/src/quantlab/features/momentum.py
+++ b/src/quantlab/features/momentum.py
@@ -18,7 +18,8 @@
numeric_pandas,
positive_int,
)
-from quantlab.features.returns import simple_returns
+from quantlab.features.cross_sectional import select_top_bottom
+from quantlab.features.returns import forward_returns, simple_returns
from quantlab.features.volatility import realized_volatility
PandasT = TypeVar("PandasT", pd.Series, pd.DataFrame)
@@ -114,3 +115,100 @@ def volatility_adjusted_momentum(
raise TypeError("Momentum and volatility have incompatible pandas types.")
result = raw / vol.where(vol > 0.0, np.nan)
return cast(PandasT, result) # type: ignore[redundant-cast]
+
+
+def momentum_persistence(
+ prices: pd.Series,
+ lookback_period: int,
+ skip_period: int,
+ holding_period: int,
+) -> pd.DataFrame:
+ """Pair each date's trailing momentum score with its subsequent return.
+
+ Returns a two-column ``(past_momentum, future_return)`` DataFrame, one
+ row per date where both are defined -- the basic building block for
+ checking whether momentum actually persists on a given series (does a
+ high past score tend to be followed by a high subsequent return, on
+ this data): a positive relationship is descriptive-sample evidence FOR
+ the strategy's premise, a flat or negative one against it -- not a
+ hypothesis test (rows from overlapping holding periods are not
+ independent observations, so this is not a significance claim).
+ ``future_return`` looks strictly ahead of each row's own date -- these
+ pairs describe the data, they are never a tradable signal themselves
+ (see ``forward_returns``). This asks the TIME-SERIES question (does
+ THIS asset's own past predict its own future); for the cross-sectional
+ question (do higher-RANKED assets outperform lower-ranked ones), see
+ :func:`cross_sectional_momentum_persistence`.
+ """
+ if not isinstance(prices, pd.Series):
+ raise TypeError("prices must be a pandas Series.")
+ past = momentum(prices, lookback_period, skip_period)
+ horizon = positive_int(holding_period, name="holding_period")
+ future = forward_returns(prices, horizon)
+ return pd.concat({"past_momentum": past, "future_return": future}, axis=1).dropna()
+
+
+def cross_sectional_momentum_persistence(
+ prices: pd.DataFrame,
+ lookback_period: int,
+ skip_period: int,
+ holding_period: int,
+ *,
+ top_fraction: float = 0.25,
+ bottom_fraction: float | None = None,
+) -> pd.DataFrame:
+ """Date-by-date evidence for CROSS-SECTIONAL momentum persistence.
+
+ Unlike :func:`momentum_persistence` (a single asset's own past-vs-
+ future relationship -- the TIME-SERIES momentum question), this asks
+ the question cross-sectional momentum actually trades: on each date,
+ do assets ranked higher on trailing momentum go on to earn higher
+ subsequent returns than assets ranked lower, RELATIVE TO EACH OTHER?
+ An asset's own serial autocorrelation is neither necessary nor
+ sufficient for that.
+
+ Returns one row per date with at least 3 assets scored, containing the
+ Spearman rank correlation between that date's momentum scores and
+ subsequent ``holding_period``-period returns across the universe
+ (``rank_correlation``), plus the realized ``top_return``/
+ ``bottom_return``/``top_minus_bottom`` spread for the
+ ``top_fraction``/``bottom_fraction`` selection (mirroring
+ ``CrossSectionalMomentumStrategy``'s own selection via
+ :func:`~quantlab.features.cross_sectional.select_top_bottom`) over the
+ same horizon. Descriptive sample evidence, not a hypothesis test --
+ overlapping holding periods across consecutive dates are not
+ independent observations.
+ """
+ if not isinstance(prices, pd.DataFrame):
+ raise TypeError("prices must be a pandas DataFrame.")
+ bottom = bottom_fraction if bottom_fraction is not None else top_fraction
+ scores = momentum(prices, lookback_period, skip_period)
+ horizon = positive_int(holding_period, name="holding_period")
+ future = forward_returns(prices, horizon)
+ selection = select_top_bottom(scores, top_fraction, bottom)
+
+ rows: list[dict[str, object]] = []
+ for date in scores.index:
+ score_row = scores.loc[date]
+ future_row = future.loc[date]
+ valid = score_row.notna() & future_row.notna()
+ if int(valid.sum()) < 3:
+ continue
+ rank_correlation = score_row[valid].corr(future_row[valid], method="spearman")
+ top_mask = valid & (selection.loc[date] == 1.0)
+ bottom_mask = valid & (selection.loc[date] == -1.0)
+ top_return = future_row[top_mask].mean() if top_mask.any() else np.nan
+ bottom_return = future_row[bottom_mask].mean() if bottom_mask.any() else np.nan
+ rows.append(
+ {
+ "date": date,
+ "rank_correlation": rank_correlation,
+ "top_return": top_return,
+ "bottom_return": bottom_return,
+ "top_minus_bottom": top_return - bottom_return,
+ }
+ )
+ columns = ["rank_correlation", "top_return", "bottom_return", "top_minus_bottom"]
+ if not rows:
+ return pd.DataFrame(columns=columns).rename_axis("date")
+ return pd.DataFrame(rows).set_index("date")[columns]
diff --git a/src/quantlab/features/native_calendar.py b/src/quantlab/features/native_calendar.py
new file mode 100644
index 0000000..2fb2c10
--- /dev/null
+++ b/src/quantlab/features/native_calendar.py
@@ -0,0 +1,87 @@
+"""Compute a rolling-window feature on each symbol's own native calendar.
+
+A closure-bar-padded multi-calendar universe (see
+``quantlab.data.closures``) shares one combined timeline across every
+symbol, including days a given symbol's own calendar has no session for
+(e.g. a 24/7 crypto instrument's weekend rows appearing alongside a
+session-bound equity's own calendar). A rolling window computed directly
+on that padded timeline therefore spans more real calendar days than
+periods for any session-bound symbol sharing it with an always-open one,
+diluting the estimate. :func:`compute_native_then_align` removes this:
+each symbol is sliced to its own verified native session rows before the
+feature is computed, and the result is reindexed/forward-filled back onto
+the combined timeline afterward -- the same "nothing changed while closed"
+convention the raw OHLCV closure padding itself already uses.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping
+
+import pandas as pd
+
+from quantlab.data.calendar import is_session_day, uniform_calendar
+from quantlab.data.closures import verified_closure_mask
+
+
+def compute_native_then_align(
+ compute_fn: Callable[[pd.DataFrame], pd.DataFrame],
+ prices: pd.DataFrame,
+ symbol_calendars: Mapping[str, str] | None,
+ combined_index: pd.DatetimeIndex,
+) -> pd.DataFrame:
+ """Run ``compute_fn`` per symbol on its own native calendar, then align.
+
+ ``compute_fn`` must be a plain, calendar-agnostic rolling-window
+ feature (e.g. ``momentum``, ``rolling_zscore``) that treats each of
+ ``prices``'s columns independently -- it is called once per symbol on
+ a single-column frame, never on the full multi-symbol matrix, so any
+ function with genuine cross-column interaction is out of scope here.
+
+ Dilution only exists when the universe genuinely spans more than one
+ calendar (a symbol's own window is only stretched by a closure that
+ exists BECAUSE a differently-scheduled symbol shares its timeline) --
+ so ``symbol_calendars=None``, or every ``prices`` column sharing the
+ exact same one calendar (even one with its own ordinary closures,
+ e.g. a single-calendar equity universe's weekends/holidays -- nothing
+ dilutes there, since every column is equally subject to the same
+ closures and `prices.index` already reflects exactly that calendar's
+ own sessions), short-circuits straight to a single vectorized
+ ``compute_fn(prices)`` call. A column with no
+ verified closure at all in ``prices.index`` for its own calendar
+ short-circuits individually to ``compute_fn`` on its own untouched
+ column.
+ """
+ if not symbol_calendars:
+ return compute_fn(prices)
+ calendars = {
+ symbol: symbol_calendars[symbol]
+ for symbol in prices.columns
+ if symbol in symbol_calendars
+ }
+ if not calendars:
+ return compute_fn(prices)
+ if len(calendars) == len(prices.columns) and (
+ uniform_calendar(calendars.values()) is not None
+ ):
+ return compute_fn(prices)
+ closure = verified_closure_mask(
+ pd.DatetimeIndex(prices.index), list(calendars), calendars
+ )
+ if not bool(closure.to_numpy().any()):
+ return compute_fn(prices)
+
+ columns: dict[str, pd.Series] = {}
+ for symbol in prices.columns:
+ calendar = calendars.get(symbol)
+ if calendar is None or not bool(closure[symbol].any()):
+ columns[symbol] = compute_fn(prices[[symbol]]).iloc[:, 0]
+ continue
+ native_index = prices.index[~closure[symbol].to_numpy()]
+ native_result = compute_fn(prices.loc[native_index, [symbol]]).iloc[:, 0]
+ aligned = native_result.reindex(combined_index)
+ combined_closure = ~is_session_day(calendar, pd.DatetimeIndex(combined_index))
+ fillable = aligned.isna() & combined_closure
+ aligned = aligned.mask(fillable, aligned.ffill())
+ columns[symbol] = aligned
+ return pd.DataFrame(columns, index=combined_index)[list(prices.columns)]
diff --git a/src/quantlab/features/pairs_diagnostics.py b/src/quantlab/features/pairs_diagnostics.py
new file mode 100644
index 0000000..892eb7f
--- /dev/null
+++ b/src/quantlab/features/pairs_diagnostics.py
@@ -0,0 +1,231 @@
+"""Shared diagnostics for a two-asset relationship (pairs trading).
+
+The single source of truth for "is this pair a good candidate, and does its
+relationship still hold" -- reused identically by the Strategy Explorer's
+Pairs Trading lab, the dashboard Results tab, and the generated HTML report,
+so all three agree on the same pair's hedge ratio, spread and stationarity
+whenever the data range, symbols, price type and parameters they're each
+given are identical (the lab lets a user explore different ones by design,
+so its numbers can legitimately differ from a specific backtest's).
+This extends to the PERIODIC stationarity check
+(``PairDiagnostics.rolling_adf_pvalue``): it calls
+``quantlab.strategies.pairs_trading.periodic_stationarity_pvalues``, the
+exact same FUNCTION the live strategy's own entry gate uses, rather than a
+separately-computed approximation of it -- but calling the same function is
+not the same as reproducing the same result: under a mixed-calendar
+universe the live gate feeds it each leg sliced to the INTERSECTION of both
+legs' own native session dates, while this module (see
+:func:`compute_pair_diagnostics`'s own docstring) feeds it the full
+combined, closure-padded timeline. The two match exactly for a
+single-calendar pair (the intersection IS the combined timeline there), and
+can genuinely diverge for a mixed-calendar one -- disclosed in
+docs/limitations.md, never silently assumed equivalent.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import pandas as pd
+
+from quantlab.features._validation import (
+ choice,
+ numeric_pandas,
+ positive_int,
+ same_axes,
+)
+from quantlab.features.mean_reversion import half_life
+from quantlab.features.returns import simple_returns
+from quantlab.features.stationarity import (
+ ADFResult,
+ CointegrationResult,
+ adf_test,
+ cointegration_test,
+)
+from quantlab.features.volatility import rolling_correlation
+
+
+def spread(
+ a: pd.Series, b: pd.Series, intercept: pd.Series, beta: pd.Series
+) -> pd.Series:
+ """Residual of the trailing-OLS relationship ``a = intercept + beta * b``.
+
+ The strategy and every diagnostic consumer share this one formula.
+ """
+ validated_a = numeric_pandas(a, name="a")
+ validated_b = numeric_pandas(b, name="b")
+ validated_intercept = numeric_pandas(intercept, name="intercept")
+ validated_beta = numeric_pandas(beta, name="beta")
+ same_axes(
+ validated_a,
+ validated_b,
+ validated_intercept,
+ validated_beta,
+ names=("b", "intercept", "beta"),
+ )
+ return validated_a - validated_intercept - validated_beta * validated_b
+
+
+@dataclass(frozen=True)
+class PairDiagnostics:
+ """A snapshot AND a time-history of a two-asset relationship's quality.
+
+ ``adf_result``/``cointegration_result``/``half_life`` are EXPLORATORY:
+ a single ADF/Engle-Granger test over the whole sample's spread/series,
+ useful for an initial read on the relationship but not what the live
+ strategy actually gates trading decisions on. ``rolling_adf_pvalue`` is
+ the CAUSAL one: it reproduces, bar for bar,
+ :meth:`~quantlab.strategies.pairs_trading.PairsTradingStrategy.
+ _stationarity_gate`'s own periodic recheck (a fresh single-window
+ regression fit every ``rolling_adf_stride`` periods, exactly like the
+ strategy) via the shared
+ :func:`~quantlab.strategies.pairs_trading.periodic_stationarity_pvalues`
+ -- so this field can never show a different picture than what actually
+ gated (or would have gated) new entries in a real backtest of this
+ pair. ``hedge_ratio_stability`` likewise describes stability over time
+ rather than a one-off snapshot.
+ """
+
+ symbol_a: str
+ symbol_b: str
+ correlation: float
+ rolling_correlation: pd.Series
+ hedge_ratio: pd.Series
+ intercept: pd.Series
+ spread: pd.Series
+ # Which indicator `spread_indicator` was actually computed with --
+ # zscore, rsi or percentile (see
+ # `quantlab.strategies.pairs_trading.INDICATORS`), NOT always zscore.
+ # A consumer must label charts/tables from this field rather than
+ # hardcoding "Z-score", or the display would misrepresent an
+ # rsi/percentile-configured pair as a zscore-driven one.
+ indicator: str
+ spread_indicator: pd.Series
+ adf_result: ADFResult | None
+ cointegration_result: CointegrationResult | None
+ half_life: float
+ hedge_ratio_stability: float
+ rolling_adf_pvalue: pd.Series
+
+
+def compute_pair_diagnostics(
+ prices: pd.DataFrame,
+ symbol_a: str,
+ symbol_b: str,
+ *,
+ formation_window: int,
+ indicator_window: int,
+ dynamic_hedge_ratio: bool,
+ indicator: str = "zscore",
+ correlation_window: int | None = None,
+ rolling_adf_stride: int | None = None,
+) -> PairDiagnostics:
+ """Compute the full diagnostic picture for one candidate pair.
+
+ ``indicator`` selects the SAME zscore/rsi/percentile series
+ :class:`~quantlab.strategies.pairs_trading.PairsTradingStrategy` itself
+ can be configured with (via
+ :func:`~quantlab.strategies.pairs_trading._centered_spread_indicator`,
+ the exact function the live strategy uses) -- defaults to ``"zscore"``
+ only for a caller that has no strategy instance to read a configured
+ indicator from (e.g. exploring a candidate pair before choosing one).
+ ``correlation_window``/``rolling_adf_stride`` default to
+ ``indicator_window`` -- the same cadence the strategy itself already
+ uses for its own periodic stationarity gate.
+
+ Under a mixed-calendar universe, ``hedge_ratio``/``spread``/
+ ``spread_indicator``/``rolling_adf_pvalue`` here are ALL computed on
+ the FULL combined, closure-padded timeline, NOT the intersection of
+ both legs' own native session dates
+ :meth:`~quantlab.strategies.pairs_trading.PairsTradingStrategy.
+ _native_pair_context` uses for the live strategy -- disclosed in
+ docs/limitations.md rather than silently assumed equivalent.
+ ``rolling_adf_pvalue`` calls the exact same gated
+ ``periodic_stationarity_pvalues`` FUNCTION the live entry gate uses
+ (see :class:`PairDiagnostics`'s own docstring), but that alone does
+ not make its RESULT match: the live gate feeds that function the
+ native-intersection-sliced series, this function feeds it the
+ combined-timeline ``a``/``b`` above -- the two match exactly for a
+ single-calendar pair and can genuinely diverge for a mixed-calendar
+ one, same as every other diagnostic here.
+ """
+ from quantlab.strategies.pairs_trading import (
+ INDICATORS,
+ _centered_spread_indicator,
+ periodic_stationarity_pvalues,
+ rolling_hedge_parameters,
+ )
+
+ if not isinstance(prices, pd.DataFrame):
+ raise TypeError("prices must be a pandas DataFrame.")
+ for symbol in (symbol_a, symbol_b):
+ if symbol not in prices.columns:
+ raise ValueError(f"prices is missing symbol {symbol!r}.")
+ validated_indicator = choice(
+ indicator, name="indicator", options=frozenset(INDICATORS)
+ )
+ a = numeric_pandas(
+ prices[symbol_a], name="prices[symbol_a]", strictly_positive=True
+ )
+ b = numeric_pandas(
+ prices[symbol_b], name="prices[symbol_b]", strictly_positive=True
+ )
+ same_axes(a, b, names=("prices[symbol_b]",))
+ corr_window = positive_int(
+ correlation_window if correlation_window is not None else indicator_window,
+ name="correlation_window",
+ minimum=2,
+ )
+ stride = positive_int(
+ rolling_adf_stride if rolling_adf_stride is not None else indicator_window,
+ name="rolling_adf_stride",
+ minimum=1,
+ )
+
+ returns_a = simple_returns(a)
+ returns_b = simple_returns(b)
+ correlation = float(returns_a.corr(returns_b))
+ rolling_corr = rolling_correlation(returns_a, returns_b, corr_window)
+
+ intercept, beta = rolling_hedge_parameters(
+ a, b, formation_window, dynamic_hedge_ratio
+ )
+ spread_series = spread(a, b, intercept, beta)
+ indicator_series = _centered_spread_indicator(
+ spread_series, validated_indicator, indicator_window
+ )
+
+ clean_spread = spread_series.dropna()
+ adf_result = adf_test(clean_spread)
+ cointegration_result = cointegration_test(a, b)
+ half_life_estimate = half_life(clean_spread)
+
+ clean_beta = beta.dropna()
+ hedge_ratio_stability = (
+ float(clean_beta.std(ddof=1)) if len(clean_beta) > 1 else float("nan")
+ )
+
+ rolling_pvalue = periodic_stationarity_pvalues(
+ a,
+ b,
+ formation_window=formation_window,
+ stride=stride,
+ dynamic_hedge_ratio=dynamic_hedge_ratio,
+ )
+
+ return PairDiagnostics(
+ symbol_a=symbol_a,
+ symbol_b=symbol_b,
+ correlation=correlation,
+ rolling_correlation=rolling_corr,
+ hedge_ratio=beta,
+ intercept=intercept,
+ spread=spread_series,
+ indicator=validated_indicator,
+ spread_indicator=indicator_series,
+ adf_result=adf_result,
+ cointegration_result=cointegration_result,
+ half_life=half_life_estimate,
+ hedge_ratio_stability=hedge_ratio_stability,
+ rolling_adf_pvalue=rolling_pvalue,
+ )
diff --git a/src/quantlab/features/stationarity.py b/src/quantlab/features/stationarity.py
new file mode 100644
index 0000000..0134a6c
--- /dev/null
+++ b/src/quantlab/features/stationarity.py
@@ -0,0 +1,229 @@
+"""Stationarity, long-run-relationship and persistence diagnostics.
+
+Structured results (never a bare float) so a caller always has the
+statistic, the null/alternative hypotheses and a plain-language
+interpretation available, not just a pass/fail number.
+"""
+
+from __future__ import annotations
+
+import warnings
+from dataclasses import dataclass
+
+import numpy as np
+import pandas as pd
+
+from quantlab.features._validation import (
+ finite_real,
+ numeric_pandas,
+ positive_int,
+ same_axes,
+)
+from quantlab.logging_config import get_logger
+
+logger = get_logger(__name__)
+
+#: Below this many non-missing observations, both ADF and Engle-Granger are
+#: considered inconclusive rather than numerically unstable.
+_MIN_TEST_OBSERVATIONS = 20
+
+
+@dataclass(frozen=True)
+class ADFResult:
+ """Augmented Dickey-Fuller stationarity test outcome for one series.
+
+ H0: the series has a unit root (is non-stationary). H1: the series is
+ stationary. A low ``pvalue`` is evidence against H0 for the sample
+ tested -- it does not prove stationarity, and says nothing about
+ whether that property will hold going forward.
+ """
+
+ statistic: float
+ pvalue: float
+ n_lags: int
+ n_obs: int
+ critical_values: dict[str, float]
+ significance: float
+ reject_null: bool
+ interpretation: str
+
+
+def adf_test(series: pd.Series, *, significance: float = 0.05) -> ADFResult | None:
+ """Run an Augmented Dickey-Fuller test; ``None`` when inconclusive.
+
+ Wraps ``statsmodels.tsa.stattools.adfuller`` with ``autolag="AIC"``.
+ Returns ``None`` (never a raised error) for fewer than 20 observations,
+ a constant series, or a numerical failure inside statsmodels -- these
+ are "cannot conclude anything" cases, not test failures to propagate.
+ """
+ if not isinstance(series, pd.Series):
+ raise TypeError("series must be a pandas Series.")
+ validated = numeric_pandas(series, name="series")
+ level = _significance(significance)
+ values = validated.dropna().to_numpy(dtype=float)
+ if len(values) < _MIN_TEST_OBSERVATIONS or np.allclose(values, values[0]):
+ return None
+ try:
+ from statsmodels.tsa.stattools import adfuller
+
+ statistic, pvalue, n_lags, n_obs, critical_values, _ = adfuller(
+ values, autolag="AIC"
+ )
+ except Exception as exc: # pragma: no cover - third-party numerical failures
+ logger.warning("ADF test failed: %s", exc)
+ return None
+ if not np.isfinite(pvalue) or not np.isfinite(statistic):
+ return None
+ finite_critical_values = {
+ key: float(value) for key, value in critical_values.items()
+ }
+ if not all(np.isfinite(value) for value in finite_critical_values.values()):
+ return None
+ reject_null = bool(pvalue <= level)
+ return ADFResult(
+ statistic=float(statistic),
+ pvalue=float(pvalue),
+ n_lags=int(n_lags),
+ n_obs=int(n_obs),
+ critical_values=finite_critical_values,
+ significance=level,
+ reject_null=reject_null,
+ interpretation=_adf_interpretation(pvalue, level, reject_null),
+ )
+
+
+def _adf_interpretation(pvalue: float, level: float, reject_null: bool) -> str:
+ verdict = (
+ "reject the unit-root null -- evidence of stationarity"
+ if reject_null
+ else "cannot reject the unit-root null -- no evidence of stationarity"
+ )
+ return f"ADF p-value {pvalue:.4f} at the {level:g} level: {verdict}."
+
+
+@dataclass(frozen=True)
+class CointegrationResult:
+ """Engle-Granger cointegration test outcome for two price series.
+
+ H0: the two series are not cointegrated (no stable long-run linear
+ relationship). H1: they are cointegrated. Distinct from correlation
+ (a short-run co-movement measure) and from running ADF on a spread
+ built from an already-fitted hedge ratio -- this test fits and checks
+ the relationship in one step.
+ """
+
+ statistic: float
+ pvalue: float
+ critical_values: dict[str, float]
+ significance: float
+ reject_null: bool
+ interpretation: str
+
+
+def cointegration_test(
+ a: pd.Series, b: pd.Series, *, significance: float = 0.05
+) -> CointegrationResult | None:
+ """Engle-Granger cointegration test between two price series.
+
+ Wraps ``statsmodels.tsa.stattools.coint`` (Engle & Granger 1987's
+ two-step method; regresses ``a`` on ``b`` and tests the residual for a
+ unit root -- asymmetric in principle, though the two directions rarely
+ disagree in practice). Assumes both series are individually I(1)
+ (integrated of order one); the test is not meaningful otherwise.
+ Returns ``None`` (never a raised error) for fewer than 20 paired
+ observations, a numerical failure inside statsmodels, a non-finite
+ statistic/p-value, or when the two series are (near-)perfectly
+ collinear -- statsmodels' own ``CollinearityWarning`` flags this last
+ case as numerically unreliable (e.g. a spurious ``statistic=-inf``,
+ ``pvalue=0.0`` "confident" result for ``b = 2 * a``), so it is treated
+ as inconclusive here rather than surfaced as a confident verdict.
+ """
+ if not isinstance(a, pd.Series) or not isinstance(b, pd.Series):
+ raise TypeError("a and b must be pandas Series.")
+ validated_a = numeric_pandas(a, name="a")
+ validated_b = numeric_pandas(b, name="b")
+ same_axes(validated_a, validated_b, names=("b",))
+ level = _significance(significance)
+ paired = pd.concat({"a": validated_a, "b": validated_b}, axis=1).dropna()
+ if len(paired) < _MIN_TEST_OBSERVATIONS:
+ return None
+ try:
+ from statsmodels.tools.sm_exceptions import CollinearityWarning
+ from statsmodels.tsa.stattools import coint
+
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always", CollinearityWarning)
+ statistic, pvalue, critical_values = coint(
+ paired["a"].to_numpy(dtype=float), paired["b"].to_numpy(dtype=float)
+ )
+ if any(issubclass(w.category, CollinearityWarning) for w in caught):
+ logger.warning(
+ "Cointegration test: near-perfectly collinear series -- "
+ "result is not numerically reliable, treating as inconclusive."
+ )
+ return None
+ except Exception as exc: # pragma: no cover - third-party numerical failures
+ logger.warning("Cointegration test failed: %s", exc)
+ return None
+ if not np.isfinite(pvalue) or not np.isfinite(statistic):
+ return None
+ finite_critical_values = dict(
+ zip(
+ ("1%", "5%", "10%"),
+ (float(value) for value in critical_values),
+ strict=True,
+ )
+ )
+ if not all(np.isfinite(value) for value in finite_critical_values.values()):
+ return None
+ reject_null = bool(pvalue <= level)
+ verdict = (
+ "reject the no-cointegration null -- evidence of a stable long-run relationship"
+ if reject_null
+ else "cannot reject the no-cointegration null -- no evidence of a "
+ "stable long-run relationship"
+ )
+ return CointegrationResult(
+ statistic=float(statistic),
+ pvalue=float(pvalue),
+ critical_values=finite_critical_values,
+ significance=level,
+ reject_null=reject_null,
+ interpretation=f"Engle-Granger p-value {pvalue:.4f} at the {level:g} level: "
+ f"{verdict}.",
+ )
+
+
+def hurst_exponent(series: pd.Series, *, max_lag: int = 20) -> float:
+ """Estimate the Hurst exponent via the variance-of-differences method.
+
+ Regresses ``log(std(x[t+lag] - x[t]))`` on ``log(lag)`` for
+ ``lag in [2, max_lag]``; the slope is the estimate. ``H < 0.5``
+ suggests mean reversion, ``H ~= 0.5`` a random walk, ``H > 0.5`` a
+ trending/persistent series -- a descriptive estimate on the sample
+ given, not a hypothesis test with a p-value. Returns ``nan`` when
+ there are too few observations (``< 2 * max_lag``) or the series is
+ degenerate (e.g. constant) after dropping missing values.
+ """
+ if not isinstance(series, pd.Series):
+ raise TypeError("series must be a pandas Series.")
+ validated = numeric_pandas(series, name="series")
+ length = positive_int(max_lag, name="max_lag", minimum=2)
+ values = validated.dropna().to_numpy(dtype=float)
+ if len(values) < 2 * length:
+ return float("nan")
+ lags = np.arange(2, length + 1)
+ spreads = np.array(
+ [np.std(values[lag:] - values[:-lag]) for lag in lags], dtype=float
+ )
+ if not np.all(spreads > 0):
+ return float("nan")
+ slope, _ = np.polyfit(np.log(lags.astype(float)), np.log(spreads), 1)
+ return float(slope)
+
+
+def _significance(value: object) -> float:
+ level = finite_real(value, name="significance", minimum=0.0, strict=True)
+ if level >= 1.0:
+ raise ValueError("significance must be strictly between 0 and 1.")
+ return level
diff --git a/src/quantlab/features/technical.py b/src/quantlab/features/technical.py
index 2e31a3a..8d12d23 100644
--- a/src/quantlab/features/technical.py
+++ b/src/quantlab/features/technical.py
@@ -61,6 +61,42 @@ def rolling_min(prices: PandasT, window: int) -> PandasT:
return validated.rolling(valid_window, min_periods=valid_window).min()
+def efficiency_ratio(prices: PandasT, window: int) -> PandasT:
+ """Kaufman's Efficiency Ratio: net directional move over total path length.
+
+ ``|P_t - P_{t-window}| / sum(|ΔP_i|)`` over the trailing window, in
+ ``[0, 1]``. Near 1 means price moved efficiently in one direction (a
+ clean trend); near 0 means the same net move took a much longer,
+ choppier path (noise dominating direction -- the classic failure mode
+ for a trend-following signal). A flat window (net move and path
+ length both zero) is neutral at ``0.5`` rather than an undefined
+ division by zero.
+ """
+ validated = numeric_pandas(prices, name="prices", strictly_positive=True)
+ length = positive_int(window, name="window")
+ net_move = (validated - validated.shift(length)).abs()
+ path_length = validated.diff().abs().rolling(length, min_periods=length).sum()
+ if isinstance(validated, pd.Series):
+ if not isinstance(net_move, pd.Series) or not isinstance(
+ path_length, pd.Series
+ ):
+ raise TypeError(
+ "Efficiency ratio inputs produced incompatible pandas objects."
+ )
+ ratio = net_move / path_length.where(path_length.abs() > EPSILON)
+ result = ratio.mask(path_length.abs() <= EPSILON, 0.5)
+ else:
+ if not isinstance(net_move, pd.DataFrame) or not isinstance(
+ path_length, pd.DataFrame
+ ):
+ raise TypeError(
+ "Efficiency ratio inputs produced incompatible pandas objects."
+ )
+ ratio = net_move / path_length.where(path_length.abs() > EPSILON)
+ result = ratio.mask(path_length.abs() <= EPSILON, 0.5)
+ return cast(PandasT, result) # type: ignore[redundant-cast]
+
+
def donchian_position(prices: PandasT, window: int) -> PandasT:
"""Return the price position in its trailing channel.
diff --git a/src/quantlab/portfolio/allocator.py b/src/quantlab/portfolio/allocator.py
index 695106d..df51b07 100644
--- a/src/quantlab/portfolio/allocator.py
+++ b/src/quantlab/portfolio/allocator.py
@@ -131,7 +131,16 @@ def allocate(self, signals: pd.DataFrame, data: pd.DataFrame) -> pd.DataFrame:
@register_allocator("inverse_volatility")
class InverseVolatilityAllocator(PortfolioAllocator):
- """Allocate inversely to trailing annualised volatility."""
+ """Allocate inversely to trailing annualised volatility.
+
+ Computed on the combined, closure-padded timeline (via ``self.
+ _returns``/``price_matrix``), NOT each symbol's own native calendar --
+ unlike every built-in strategy's own signal generation (see
+ ``quantlab.features.native_calendar.compute_native_then_align``), a
+ mixed-calendar universe's realized-volatility estimate here can still
+ be diluted by an always-open instrument's extra sessions. Disclosed in
+ docs/limitations.md rather than silently assumed away.
+ """
def __init__(
self,
@@ -175,7 +184,11 @@ def allocate(self, signals: pd.DataFrame, data: pd.DataFrame) -> pd.DataFrame:
@register_allocator("volatility_targeting")
class VolatilityTargetingAllocator(PortfolioAllocator):
- """Scale inverse-volatility weights toward an annual volatility target."""
+ """Scale inverse-volatility weights toward an annual volatility target.
+
+ Same combined-timeline (not native-calendar) volatility estimate as
+ `InverseVolatilityAllocator` -- see that class's own docstring.
+ """
def __init__(
self,
diff --git a/src/quantlab/portfolio/constraints.py b/src/quantlab/portfolio/constraints.py
index a2aafb4..c6c834f 100644
--- a/src/quantlab/portfolio/constraints.py
+++ b/src/quantlab/portfolio/constraints.py
@@ -24,6 +24,86 @@
)
+@dataclass(frozen=True)
+class ConstraintTouch:
+ """Per-constraint provenance from :meth:`ConstraintSet.apply_with_provenance`.
+
+ ``touched`` is True at every ``(date, symbol)`` cell this constraint
+ changed by more than ``EPSILON`` at ANY point during constraint
+ resolution -- including repeated passes inside the dust-cleanup
+ fixed-point loop (a cumulative OR across every application).
+
+ ``before`` holds the weight immediately before the FIRST pass that
+ ever changed a given cell; ``after`` holds the weight immediately
+ after the LAST pass that actually changed it -- never a snapshot from
+ a later pass that left the cell untouched (its value may have moved
+ for an unrelated reason between two passes of THIS constraint, and
+ attributing that movement to this constraint would be wrong). Both
+ are used only to build human-readable reason text, never to
+ redetermine ``touched`` itself.
+
+ ``direct`` is a cumulative OR (same convention as ``touched``)
+ restricted to cells whose OWN value triggered this constraint's
+ clip/drop decision at some point, as opposed to a cell only
+ redimensioned as a downstream consequence (redistribution/rescaling
+ of the survivors). For constraints with no redistribution concept
+ (``maximum_gross_exposure``, ``maximum_leverage``,
+ ``maximum_net_exposure``, ``long_only`` -- uniform whole-row
+ rescales), ``direct == touched`` always.
+ """
+
+ touched: pd.DataFrame
+ before: pd.DataFrame
+ after: pd.DataFrame
+ direct: pd.DataFrame
+
+
+def _mark_touched(
+ touched: dict[str, ConstraintTouch] | None,
+ name: str,
+ before: pd.DataFrame,
+ after: pd.DataFrame,
+ *,
+ direct_this_pass: pd.DataFrame | None = None,
+) -> None:
+ """Record one constraint's effect, cumulatively, when tracking is on.
+
+ ``touched`` (the mask) is a cumulative OR across every application of
+ this constraint during the call. ``before`` is refreshed only the
+ first time a cell is ever touched; ``after`` is refreshed only on a
+ pass that actually retouches the cell -- a later no-op pass (e.g.
+ once the dust-cleanup loop has converged, or a pass where some OTHER
+ constraint moved this cell instead) must not overwrite an earlier,
+ informative before/after pair with an unrelated snapshot.
+
+ ``direct_this_pass``, when given, is the real (peek-based, no
+ reconstructed threshold) predicate for which cells THIS constraint's
+ own clip/drop decision fired on, at exactly this pass -- it is
+ combined with ``changed`` before being OR-ed into the cumulative
+ ``direct`` mask. ``None`` (the default) means every changed cell is
+ direct (constraints with no redistribution concept).
+ """
+ if touched is None:
+ return
+ changed = (after - before).abs() > EPSILON
+ direct_this_pass_mask = (
+ changed if direct_this_pass is None else (direct_this_pass & changed)
+ )
+ if name in touched:
+ existing = touched[name]
+ first_touch_this_pass = changed & ~existing.touched
+ touched[name] = ConstraintTouch(
+ touched=existing.touched | changed,
+ before=existing.before.where(~first_touch_this_pass, before),
+ after=existing.after.where(~changed, after),
+ direct=existing.direct | direct_this_pass_mask,
+ )
+ else:
+ touched[name] = ConstraintTouch(
+ touched=changed, before=before, after=after, direct=direct_this_pass_mask
+ )
+
+
@dataclass(frozen=True)
class ConstraintSet:
"""Immutable collection of optional target-portfolio constraints."""
@@ -70,27 +150,82 @@ def __post_init__(self) -> None:
def apply(self, weights: pd.DataFrame) -> pd.DataFrame:
"""Return finite weights satisfying every configured constraint."""
+ return self._apply_impl(weights, None)
+
+ def apply_with_provenance(
+ self, weights: pd.DataFrame
+ ) -> tuple[pd.DataFrame, dict[str, ConstraintTouch]]:
+ """Same computation as :meth:`apply`, plus per-constraint provenance.
+
+ For each configured constraint, records which cells it actually
+ changed. The weight computation itself is identical to
+ :meth:`apply` -- both delegate to the same ``_apply_impl``, which
+ only records provenance when asked to. This is real provenance
+ captured directly from the actual execution, not a parallel
+ reconstruction, so it can never diverge from what ``apply()``
+ itself would have produced.
+ """
+ touched: dict[str, ConstraintTouch] = {}
+ result = self._apply_impl(weights, touched)
+ return result, touched
+
+ def _apply_impl(
+ self, weights: pd.DataFrame, touched: dict[str, ConstraintTouch] | None
+ ) -> pd.DataFrame:
out = validate_frame(weights, name="weights").copy().astype(float)
if self.long_only:
+ before = out
out = out.clip(lower=0.0)
+ _mark_touched(touched, "long_only", before, out)
if self.maximum_positions is not None:
+ before = out
pre_drop_gross = gross_exposure(out)
- out = _cap_positions(out, self.maximum_positions)
- out = _redistribute_to_target(out, pre_drop_gross)
+ after_cap = _cap_positions(out, self.maximum_positions)
+ direct_this_pass = after_cap.ne(before)
+ out = _redistribute_to_target(after_cap, pre_drop_gross)
+ _mark_touched(
+ touched,
+ "maximum_positions",
+ before,
+ out,
+ direct_this_pass=direct_this_pass,
+ )
if self.minimum_weight is not None:
+ before = out
pre_drop_gross = gross_exposure(out)
- out = out.where(out.abs() >= self.minimum_weight, 0.0)
- out = _redistribute_to_target(out, pre_drop_gross)
+ after_drop = out.where(out.abs() >= self.minimum_weight, 0.0)
+ direct_this_pass = after_drop.ne(before)
+ out = _redistribute_to_target(after_drop, pre_drop_gross)
+ _mark_touched(
+ touched,
+ "minimum_weight",
+ before,
+ out,
+ direct_this_pass=direct_this_pass,
+ )
if self.maximum_weight is not None:
+ before = out
pre_cap_gross = gross_exposure(out)
+ after_clip = out.astype(float).clip(
+ -self.maximum_weight, self.maximum_weight
+ )
+ direct_this_pass = after_clip.ne(before)
out = renormalize_within_cap(
out, target_gross=pre_cap_gross, cap=self.maximum_weight
)
+ _mark_touched(
+ touched,
+ "maximum_weight",
+ before,
+ out,
+ direct_this_pass=direct_this_pass,
+ )
pre_exposure_cap = out.copy()
- out = self._apply_exposure_caps(out)
+ out = self._apply_exposure_caps(out, touched)
if self.minimum_weight is not None:
- out = self._clean_dust_to_fixed_point(out)
+ out = self._clean_dust_to_fixed_point(out, touched)
+ before = out
out = _rescue_needless_full_liquidation(
out,
pre_exposure_cap,
@@ -100,34 +235,70 @@ def apply(self, weights: pd.DataFrame) -> pd.DataFrame:
maximum_leverage=self.maximum_leverage,
maximum_net_exposure=self.maximum_net_exposure,
)
+ _mark_touched(touched, "minimum_weight", before, out)
self._assert_satisfied(out)
return out
- def _apply_exposure_caps(self, weights: pd.DataFrame) -> pd.DataFrame:
+ def _apply_exposure_caps(
+ self,
+ weights: pd.DataFrame,
+ touched: dict[str, ConstraintTouch] | None = None,
+ ) -> pd.DataFrame:
out = weights
if self.maximum_gross_exposure is not None:
+ before = out
out = _cap_gross(out, self.maximum_gross_exposure)
+ _mark_touched(touched, "maximum_gross_exposure", before, out)
if self.maximum_leverage is not None:
+ before = out
out = _cap_gross(out, self.maximum_leverage)
+ _mark_touched(touched, "maximum_leverage", before, out)
if self.maximum_net_exposure is not None:
+ before = out
out = _cap_net(out, self.maximum_net_exposure)
+ _mark_touched(touched, "maximum_net_exposure", before, out)
return out
- def _clean_dust_to_fixed_point(self, weights: pd.DataFrame) -> pd.DataFrame:
+ def _clean_dust_to_fixed_point(
+ self,
+ weights: pd.DataFrame,
+ touched: dict[str, ConstraintTouch] | None = None,
+ ) -> pd.DataFrame:
"""Repeat dust removal because exposure caps can create new dust."""
assert self.minimum_weight is not None
out = weights
for _ in range(max(out.shape[1] + 1, 1)):
before = out.copy()
+ step_before = out
pre_drop_gross = gross_exposure(out)
- out = out.where(out.abs() >= self.minimum_weight, 0.0)
- out = _redistribute_to_target(out, pre_drop_gross)
+ after_drop = out.where(out.abs() >= self.minimum_weight, 0.0)
+ direct_this_pass = after_drop.ne(step_before)
+ out = _redistribute_to_target(after_drop, pre_drop_gross)
+ _mark_touched(
+ touched,
+ "minimum_weight",
+ step_before,
+ out,
+ direct_this_pass=direct_this_pass,
+ )
if self.maximum_weight is not None:
+ step_before = out
pre_cap_gross = gross_exposure(out)
+ after_clip = out.astype(float).clip(
+ -self.maximum_weight, self.maximum_weight
+ )
+ direct_this_pass = after_clip.ne(step_before)
out = renormalize_within_cap(
out, target_gross=pre_cap_gross, cap=self.maximum_weight
)
- out = self._apply_exposure_caps(out)
+ _mark_touched(
+ touched,
+ "maximum_weight",
+ step_before,
+ out,
+ direct_this_pass=direct_this_pass,
+ )
+ out = self._apply_exposure_caps(out, touched)
if np.allclose(out.to_numpy(), before.to_numpy(), atol=1e-10, rtol=0.0):
return out
raise InvalidConfigurationError(
diff --git a/src/quantlab/portfolio/drift_compliance.py b/src/quantlab/portfolio/drift_compliance.py
new file mode 100644
index 0000000..18ff79a
--- /dev/null
+++ b/src/quantlab/portfolio/drift_compliance.py
@@ -0,0 +1,606 @@
+"""Minimal-L1-turnover restoration of a drift-breached row's compliance.
+
+Between rebalances, organic price drift (see
+:func:`quantlab.backtesting.accounting.apply_weight_drift`) can push a row
+past a hard portfolio-level risk limit (``maximum_weight``,
+``maximum_gross_exposure``/``maximum_leverage``, ``maximum_net_exposure``,
+``long_only``) even though the last REAL decision was itself fully
+compliant. Restoring compliance off-schedule is a genuine constrained
+optimization, not a heuristic: a naive "clip then scale toward 0" fix can
+move exposure in the WRONG direction whenever some of the breaching
+exposure sits in a currently-untradable column (see
+:func:`restore_drift_compliance`'s own docstring for the exact
+counterexample this module exists to avoid).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import numpy as np
+from scipy.optimize import linprog
+
+from quantlab.constants import EPSILON
+from quantlab.exceptions import BacktestError
+
+#: scipy.optimize.linprog's HiGHS status code for "solved to optimality".
+_LINPROG_OPTIMAL = 0
+#: HiGHS status code for "provably infeasible" -- the ONLY non-optimal
+#: status treated as an ordinary, expected outcome (tradability-caused).
+#: Every other non-optimal status (1: iteration limit, 3: unbounded, 4:
+#: numerical difficulties) is a genuine solver failure, never folded into
+#: the same "infeasible" bucket -- see `_solve`'s own docstring.
+_LINPROG_INFEASIBLE = 2
+
+
+@dataclass(frozen=True)
+class DriftComplianceResult:
+ """One row's outcome from :func:`restore_drift_compliance`.
+
+ ``corrected`` is the row's new weights. ``pending`` is ``True`` only
+ when full compliance was genuinely unachievable given which columns
+ are currently tradable (the slack-relaxation fallback fired) --
+ ``corrected`` is then the best achievable correction, not a fully
+ compliant row, and the caller is expected to retry this row's
+ successor once the responsible column(s) reopen.
+ """
+
+ corrected: np.ndarray
+ pending: bool
+
+
+def restore_drift_compliance(
+ drifted: np.ndarray,
+ columns: Sequence[str],
+ tradable_row: np.ndarray,
+ groups: Sequence[tuple[str, ...]],
+ *,
+ maximum_weight: float | None,
+ maximum_gross_exposure: float | None,
+ maximum_net_exposure: float | None,
+ long_only: bool,
+) -> DriftComplianceResult:
+ """Return the minimal-L1-turnover row that restores compliance.
+
+ Internal, low-level primitive: its sole caller,
+ :func:`quantlab.backtesting.accounting.apply_weight_drift`, is the
+ validated public entry point (frame shape/dtype/finiteness, tradable
+ mask, etc.). This function only asserts array shapes match ``columns``
+ below -- it trusts ``drifted``/``tradable_row`` are otherwise already
+ clean numeric/boolean data, and is not meant to be called directly on
+ unvalidated input. Full LP formulation (free variables, objective,
+ constraints, the infeasibility-diagnosis/slack-relaxation fallback,
+ and the two disclosed scope limits): see
+ docs/drift_compliance.md#compliance-restoration-lp.
+
+ Key invariants a caller relies on: a declared position group (e.g.
+ pairs_trading's two legs, via ``groups``) always moves as one
+ coherent unit via a single shared scaling factor, never one leg
+ alone; an untradable column/group is always returned bit-for-bit
+ unchanged from ``drifted``; the result never invents a brand-new
+ position (long or short) on a column/group the drifted book did not
+ already hold, even when that would be the cheapest fix. ``pending``
+ is ``True`` only when full compliance was genuinely unachievable
+ given current tradability (``corrected`` is then the best achievable
+ partial fix, not a fully compliant row) -- a constraint configuration
+ that is infeasible for any OTHER reason raises ``BacktestError``
+ instead of returning a result, since that indicates a bug (a
+ contradictory configuration ``_validate_target_row_compliant`` should
+ already have rejected upstream), never a legitimate runtime outcome.
+ """
+ n = len(columns)
+ if drifted.shape != (n,) or tradable_row.shape != (n,):
+ raise BacktestError(
+ "drifted and tradable_row must be 1-D arrays matching columns."
+ )
+ column_index = {name: i for i, name in enumerate(columns)}
+
+ indep_columns: list[str] = []
+ group_legs: list[list[int]] = [] # column indices per multi-column group
+ for group in groups:
+ if len(group) == 1:
+ indep_columns.append(group[0])
+ else:
+ group_legs.append([column_index[symbol] for symbol in group])
+
+ indep_idx = [column_index[symbol] for symbol in indep_columns]
+ indep_tradable = [bool(tradable_row[i]) for i in indep_idx]
+ group_tradable = [all(bool(tradable_row[i]) for i in legs) for legs in group_legs]
+ group_l1_norm = [float(np.sum(np.abs(drifted[legs]))) for legs in group_legs]
+ group_net = [float(np.sum(drifted[legs])) for legs in group_legs]
+
+ gross_cap = maximum_gross_exposure
+
+ solved = _solve(
+ drifted,
+ indep_idx,
+ indep_tradable,
+ group_legs,
+ group_tradable,
+ group_l1_norm,
+ group_net,
+ maximum_weight=maximum_weight,
+ gross_cap=gross_cap,
+ maximum_net_exposure=maximum_net_exposure,
+ long_only=long_only,
+ allow_slack=False,
+ )
+ if solved is not None:
+ return DriftComplianceResult(corrected=solved, pending=False)
+
+ # Diagnose: is infeasibility explained by the fixed (untradable)
+ # positions alone? Build the row that WOULD result from every fixed
+ # position at its drifted value and every free position at exactly
+ # its own drifted value too (i.e. "no correction at all") and check
+ # whether the fixed subset's own contribution already breaches a cap
+ # that no amount of free-column movement could ever repair (a
+ # portfolio-level cap breached by the fixed positions alone, or a
+ # per-asset cap breached by a fixed position's own value).
+ fixed_only_violation = _fixed_positions_alone_violate(
+ drifted,
+ indep_idx,
+ indep_tradable,
+ group_legs,
+ group_tradable,
+ maximum_weight=maximum_weight,
+ gross_cap=gross_cap,
+ maximum_net_exposure=maximum_net_exposure,
+ long_only=long_only,
+ )
+ if not fixed_only_violation: # pragma: no cover - defensive, should be unreachable
+ # 0 is always a feasible point for every free (tradable) variable
+ # under any valid, non-negative constraint configuration (see
+ # test_always_feasible_and_never_pending_when_everything_is_
+ # tradable's own reasoning) -- so a strict-LP infeasibility not
+ # explained by the fixed/untradable subset alone should never
+ # actually happen for input `_validate_drift_and_risk_options`/
+ # `_validate_target_row_compliant` have already validated. Kept as
+ # a loud, explicit guard rather than silently reaching the slack-
+ # relaxation path for a reason tradability doesn't actually explain.
+ raise BacktestError(
+ "Drift-compliance restoration is infeasible for a reason other "
+ "than tradability -- this indicates a bug in the algorithm "
+ "(e.g. a contradictory constraint configuration that "
+ "_validate_target_row_compliant should already have rejected "
+ "at the target), not a legitimate runtime condition."
+ )
+
+ relaxed = _solve(
+ drifted,
+ indep_idx,
+ indep_tradable,
+ group_legs,
+ group_tradable,
+ group_l1_norm,
+ group_net,
+ maximum_weight=maximum_weight,
+ gross_cap=gross_cap,
+ maximum_net_exposure=maximum_net_exposure,
+ long_only=long_only,
+ allow_slack=True,
+ )
+ if relaxed is None: # pragma: no cover - defensive, should be unreachable
+ raise BacktestError(
+ "Drift-compliance slack-relaxation LP unexpectedly infeasible "
+ "despite a tradability-caused diagnosis -- this indicates a "
+ "bug in the algorithm."
+ )
+ return DriftComplianceResult(corrected=relaxed, pending=True)
+
+
+def _fixed_positions_alone_violate(
+ drifted: np.ndarray,
+ indep_idx: list[int],
+ indep_tradable: list[bool],
+ group_legs: list[list[int]],
+ group_tradable: list[bool],
+ *,
+ maximum_weight: float | None,
+ gross_cap: float | None,
+ maximum_net_exposure: float | None,
+ long_only: bool,
+) -> bool:
+ """Whether fixed positions alone already violate a constraint.
+
+ True when the untradable columns'/groups' own drifted values alone
+ already violate a constraint no amount of free-column movement could
+ fix -- the signature of a tradability-caused (expected) infeasibility
+ rather than a genuine bug.
+ """
+ if maximum_weight is not None:
+ for i, tradable in zip(indep_idx, indep_tradable, strict=True):
+ if not tradable and abs(drifted[i]) > maximum_weight + EPSILON:
+ return True
+ for legs, tradable in zip(group_legs, group_tradable, strict=True):
+ if not tradable and any(
+ abs(drifted[i]) > maximum_weight + EPSILON for i in legs
+ ):
+ return True
+ if long_only:
+ for i, tradable in zip(indep_idx, indep_tradable, strict=True):
+ if not tradable and drifted[i] < -EPSILON:
+ return True
+ for legs, tradable in zip(group_legs, group_tradable, strict=True):
+ if not tradable and any(drifted[i] < -EPSILON for i in legs):
+ return True
+ fixed_gross = sum(
+ abs(drifted[i])
+ for i, tradable in zip(indep_idx, indep_tradable, strict=True)
+ if not tradable
+ ) + sum(
+ sum(abs(drifted[i]) for i in legs)
+ for legs, tradable in zip(group_legs, group_tradable, strict=True)
+ if not tradable
+ )
+ if gross_cap is not None and fixed_gross > gross_cap + EPSILON:
+ return True
+ fixed_net = sum(
+ drifted[i]
+ for i, tradable in zip(indep_idx, indep_tradable, strict=True)
+ if not tradable
+ ) + sum(
+ sum(drifted[i] for i in legs)
+ for legs, tradable in zip(group_legs, group_tradable, strict=True)
+ if not tradable
+ )
+ return (
+ maximum_net_exposure is not None
+ and abs(fixed_net) > maximum_net_exposure + EPSILON
+ )
+
+
+def _solve(
+ drifted: np.ndarray,
+ indep_idx: list[int],
+ indep_tradable: list[bool],
+ group_legs: list[list[int]],
+ group_tradable: list[bool],
+ group_l1_norm: list[float],
+ group_net: list[float],
+ *,
+ maximum_weight: float | None,
+ gross_cap: float | None,
+ maximum_net_exposure: float | None,
+ long_only: bool,
+ allow_slack: bool,
+) -> np.ndarray | None:
+ """Build and solve one LP (strict or slack-relaxed); ``None`` if infeasible.
+
+ Variable layout: ``w_i`` (independent columns), ``k_g`` (groups),
+ ``u_i``/``v_g`` (``|w_i - drifted_i|``/``|k_g - 1|`` L1-deviation
+ auxiliaries, ALWAYS present -- see below), ``p_i``/``q_g``
+ (``|w_i|``/``|k_g|`` auxiliaries, only wired into a constraint row
+ when a gross-exposure cap is configured), and, in slack mode only,
+ four shared slack variables (``s_mw``, ``s_gross``, ``s_net``,
+ ``s_long``) added to each cap's own right-hand side -- ``s_long``
+ relaxes the ``long_only`` inequality (below), the same way the other
+ three relax their own numeric cap.
+
+ Strict mode (``allow_slack=False``) minimizes the L1 deviation
+ directly in one solve -- every constraint is satisfied exactly, no
+ slack variables exist at all.
+
+ Slack mode (``allow_slack=True``) is a genuine two-stage LEXICOGRAPHIC
+ solve, not a single relaxed objective: stage 1 minimizes ONLY
+ ``s_mw + s_gross + s_net + s_long`` (the unavoidable violation, exactly
+ what the tradability-caused-infeasibility diagnosis already proved is
+ nonzero); stage 2 then FIXES those slacks at their stage-1-optimal
+ values (via tight bounds) and re-solves for the MINIMAL L1 deviation
+ among every point achieving that same minimal violation. A single-
+ stage "minimize slack only" solve would leave every OTHER free
+ column's own objective coefficient at zero, so the solver is free to
+ move an already-compliant, uninvolved column to an arbitrary value
+ (e.g. liquidating it to 0) with no penalty for doing so, since nothing
+ in that objective discourages it -- stage 2 is what rules that out.
+
+ Returns ``None`` ONLY when the solve is provably infeasible (HiGHS
+ status ``_LINPROG_INFEASIBLE``) -- the caller's own tradability
+ diagnosis treats this as the ordinary, expected outcome. Any OTHER
+ non-optimal status (an iteration limit, an unbounded problem, or a
+ numerical-difficulties report) is a genuine solver failure, never
+ folded into the same "infeasible, diagnose via tradability" bucket --
+ it raises `BacktestError` immediately with the solver's own status and
+ message, since silently treating it as an ordinary infeasibility could
+ misreport a real solver hiccup as a tradability-caused breach (or vice
+ versa), and a caller has no way to tell the two apart from `None` alone.
+ """
+ n_i = len(indep_idx)
+ n_g = len(group_legs)
+ w_at = list(range(n_i))
+ k_at = list(range(n_i, n_i + n_g))
+ u_at = list(range(n_i + n_g, 2 * n_i + n_g))
+ v_at = list(range(2 * n_i + n_g, 2 * n_i + 2 * n_g))
+ p_at = list(range(2 * n_i + 2 * n_g, 3 * n_i + 2 * n_g))
+ q_at = list(range(3 * n_i + 2 * n_g, 3 * n_i + 3 * n_g))
+ n_vars = 3 * (n_i + n_g) + (4 if allow_slack else 0)
+ # Only ever read/written inside an `if allow_slack:` guard below, so
+ # these remain valid indices even though `n_vars` excludes them when
+ # `allow_slack` is False (kept as plain ints, not `int | None`, so
+ # every use site stays a simple, unconditional index expression).
+ s_mw = 3 * (n_i + n_g)
+ s_gross = s_mw + 1
+ s_net = s_mw + 2
+ s_long = s_mw + 3
+
+ c_deviation = np.zeros(n_vars)
+ for pos in u_at:
+ c_deviation[pos] = 1.0
+ for pos, norm in zip(v_at, group_l1_norm, strict=True):
+ c_deviation[pos] = norm
+ # Allocated unconditionally (only ever READ inside `if allow_slack:`
+ # below, mirroring s_mw/s_gross/s_net's own "harmless when unused"
+ # convention above) so every use site stays a simple, unconditional
+ # expression rather than requiring a definite-assignment analysis that
+ # spans two separate `if allow_slack`/`if not allow_slack` statements.
+ c_slack = np.zeros(n_vars)
+ if allow_slack:
+ c_slack[s_mw] = 1.0
+ c_slack[s_gross] = 1.0
+ c_slack[s_net] = 1.0
+ c_slack[s_long] = 1.0
+
+ bounds: list[tuple[float, float]] = [(0.0, 0.0)] * n_vars
+ for k, (i, tradable) in enumerate(zip(indep_idx, indep_tradable, strict=True)):
+ if not tradable:
+ bounds[w_at[k]] = (float(drifted[i]), float(drifted[i]))
+ else:
+ # Sign/support-preserving: a currently-LONG column may shrink
+ # toward 0 or grow further long, a currently-SHORT column may
+ # shrink toward 0 or grow further short, but neither may CROSS
+ # zero, and a column already AT zero stays fixed there -- the
+ # LP is never allowed to invent a brand-new position (long or
+ # short) on an asset the drifted book doesn't already hold.
+ # See restore_drift_compliance's own docstring: the closed-
+ # long/tradable-short counterexample this module exists to
+ # solve correctly only ever needs an ALREADY-nonzero column
+ # free to move FURTHER in its own direction, never a zero
+ # column becoming nonzero, so this loses no real solution.
+ drifted_i = float(drifted[i])
+ if drifted_i > EPSILON:
+ lo, hi = 0.0, np.inf
+ elif drifted_i < -EPSILON:
+ lo, hi = -np.inf, 0.0
+ else:
+ lo = hi = 0.0
+ if long_only:
+ lo = max(lo, 0.0)
+ bounds[w_at[k]] = (lo, hi)
+ for k, tradable in enumerate(group_tradable):
+ bounds[k_at[k]] = (1.0, 1.0) if not tradable else (0.0, np.inf)
+ for pos in u_at:
+ bounds[pos] = (0.0, np.inf)
+ for pos in v_at:
+ bounds[pos] = (0.0, np.inf)
+ if gross_cap is not None:
+ for pos in p_at:
+ bounds[pos] = (0.0, np.inf)
+ for pos in q_at:
+ bounds[pos] = (0.0, np.inf)
+ if allow_slack:
+ bounds[s_mw] = (0.0, np.inf)
+ bounds[s_gross] = (0.0, np.inf)
+ bounds[s_net] = (0.0, np.inf)
+ bounds[s_long] = (0.0, np.inf)
+
+ a_ub: list[np.ndarray] = []
+ b_ub: list[float] = []
+
+ def _row() -> np.ndarray:
+ return np.zeros(n_vars)
+
+ # Deviation: u_i >= |w_i - drifted_i|; v_g >= |k_g - 1|. Built
+ # UNCONDITIONALLY (not just in strict mode) so slack mode's stage 2
+ # (below) can minimize this same L1 deviation once the unavoidable
+ # violation has been pinned at its stage-1-optimal value -- otherwise
+ # a free, already-compliant column has no cost keeping it near its own
+ # drifted value and the solver may move it anywhere.
+ for k, i in enumerate(indep_idx):
+ row = _row()
+ row[w_at[k]] = 1.0
+ row[u_at[k]] = -1.0
+ a_ub.append(row)
+ b_ub.append(float(drifted[i]))
+ row = _row()
+ row[w_at[k]] = -1.0
+ row[u_at[k]] = -1.0
+ a_ub.append(row)
+ b_ub.append(-float(drifted[i]))
+ for k in range(n_g):
+ row = _row()
+ row[k_at[k]] = 1.0
+ row[v_at[k]] = -1.0
+ a_ub.append(row)
+ b_ub.append(1.0)
+ row = _row()
+ row[k_at[k]] = -1.0
+ row[v_at[k]] = -1.0
+ a_ub.append(row)
+ b_ub.append(-1.0)
+
+ if long_only:
+ # `long_only` is otherwise only baked into a TRADABLE independent
+ # column's own lower bound (`lo = max(lo, 0.0)` above) -- silently
+ # a no-op for an UNTRADABLE (fixed) column or group, whose bound
+ # is pinned at its drifted value regardless of sign. Without an
+ # explicit inequality here, a fixed column/group already negative
+ # under long_only (which "shouldn't happen" upstream, per this
+ # function's own sign-preservation docstring, but is not actually
+ # enforced anywhere for a FIXED value) would let the strict LP
+ # trivially "succeed" over a value that still violates long_only --
+ # exactly the kind of formulation bug this module must never
+ # produce. Redundant (never binding) for every TRADABLE column,
+ # whose own bounds already enforce this; only a FIXED column/group
+ # can make it bind, correctly turning that into LP infeasibility
+ # so the normal tradability diagnosis + slack-relaxation path
+ # handles it like any other fixed-value violation (see
+ # `_fixed_positions_alone_violate`'s own long_only check).
+ for k in range(n_i):
+ row = _row()
+ row[w_at[k]] = -1.0
+ if allow_slack:
+ row[s_long] = -1.0
+ a_ub.append(row)
+ b_ub.append(0.0)
+ for k, legs in enumerate(group_legs):
+ for i in legs:
+ d = float(drifted[i])
+ row = _row()
+ row[k_at[k]] = -d
+ if allow_slack:
+ row[s_long] = -1.0
+ a_ub.append(row)
+ b_ub.append(0.0)
+
+ if maximum_weight is not None:
+ for k in range(n_i):
+ row = _row()
+ row[w_at[k]] = 1.0
+ if allow_slack:
+ row[s_mw] = -1.0
+ a_ub.append(row)
+ b_ub.append(maximum_weight)
+ row = _row()
+ row[w_at[k]] = -1.0
+ if allow_slack:
+ row[s_mw] = -1.0
+ a_ub.append(row)
+ b_ub.append(maximum_weight)
+ for k, legs in enumerate(group_legs):
+ for i in legs:
+ d = float(drifted[i])
+ row = _row()
+ row[k_at[k]] = d
+ if allow_slack:
+ row[s_mw] = -1.0
+ a_ub.append(row)
+ b_ub.append(maximum_weight)
+ row = _row()
+ row[k_at[k]] = -d
+ if allow_slack:
+ row[s_mw] = -1.0
+ a_ub.append(row)
+ b_ub.append(maximum_weight)
+
+ if gross_cap is not None:
+ # p_i >= |w_i|; q_g >= |k_g|.
+ for k in range(n_i):
+ row = _row()
+ row[w_at[k]] = 1.0
+ row[p_at[k]] = -1.0
+ a_ub.append(row)
+ b_ub.append(0.0)
+ row = _row()
+ row[w_at[k]] = -1.0
+ row[p_at[k]] = -1.0
+ a_ub.append(row)
+ b_ub.append(0.0)
+ for k in range(n_g):
+ row = _row()
+ row[k_at[k]] = 1.0
+ row[q_at[k]] = -1.0
+ a_ub.append(row)
+ b_ub.append(0.0)
+ row = _row()
+ row[k_at[k]] = -1.0
+ row[q_at[k]] = -1.0
+ a_ub.append(row)
+ b_ub.append(0.0)
+ row = _row()
+ for pos in p_at:
+ row[pos] = 1.0
+ for pos, norm in zip(q_at, group_l1_norm, strict=True):
+ row[pos] = norm
+ if allow_slack:
+ row[s_gross] = -1.0
+ a_ub.append(row)
+ b_ub.append(gross_cap)
+
+ if maximum_net_exposure is not None:
+ row = _row()
+ for pos in w_at:
+ row[pos] = 1.0
+ for pos, net in zip(k_at, group_net, strict=True):
+ row[pos] = net
+ row_pos = row.copy()
+ row_neg = -row.copy()
+ if allow_slack:
+ row_pos[s_net] = -1.0
+ row_neg[s_net] = -1.0
+ a_ub.append(row_pos)
+ b_ub.append(maximum_net_exposure)
+ a_ub.append(row_neg)
+ b_ub.append(maximum_net_exposure)
+
+ a_ub_arr = np.array(a_ub) if a_ub else None
+ b_ub_arr = np.array(b_ub) if b_ub else None
+
+ if not allow_slack:
+ result = linprog(
+ c_deviation, A_ub=a_ub_arr, b_ub=b_ub_arr, bounds=bounds, method="highs"
+ )
+ if result.status == _LINPROG_INFEASIBLE:
+ return None
+ if result.status != _LINPROG_OPTIMAL:
+ raise BacktestError(
+ "Drift-compliance strict LP failed with a non-infeasible, "
+ f"non-optimal HiGHS status ({result.status}: "
+ f"{result.message}) -- this is a genuine solver failure, "
+ "not a tradability-caused infeasibility, and must be "
+ "investigated directly."
+ )
+ x = result.x
+ else:
+ # Stage 1: the unavoidable violation alone.
+ stage1 = linprog(
+ c_slack, A_ub=a_ub_arr, b_ub=b_ub_arr, bounds=bounds, method="highs"
+ )
+ if stage1.status == _LINPROG_INFEASIBLE:
+ return None
+ if stage1.status != _LINPROG_OPTIMAL:
+ raise BacktestError(
+ "Drift-compliance slack-relaxation stage 1 failed with a "
+ f"non-infeasible, non-optimal HiGHS status ({stage1.status}: "
+ f"{stage1.message}) -- this is a genuine solver failure, "
+ "not a tradability-caused infeasibility, and must be "
+ "investigated directly."
+ )
+ # Stage 2: pin that violation at its stage-1-optimal value, then
+ # minimize the L1 deviation among every point achieving it -- this
+ # is what keeps an already-compliant, uninvolved free column at
+ # (or near) its own drifted value instead of moving it arbitrarily.
+ stage2_bounds = list(bounds)
+ for pos in (s_mw, s_gross, s_net, s_long):
+ pinned = float(stage1.x[pos])
+ stage2_bounds[pos] = (pinned, pinned)
+ stage2 = linprog(
+ c_deviation,
+ A_ub=a_ub_arr,
+ b_ub=b_ub_arr,
+ bounds=stage2_bounds,
+ method="highs",
+ )
+ # Stage 1's own solution is always feasible for stage 2 (same
+ # constraints, slacks pinned at the value it itself produced), so
+ # a non-optimal status here is always a genuine bug -- never
+ # silently substituted with stage 1's own solution, which has no
+ # penalty on any OTHER free column and so could be an arbitrary,
+ # needlessly destructive correction (see this function's own
+ # docstring on why stage 2 exists at all).
+ if stage2.status != _LINPROG_OPTIMAL:
+ raise BacktestError(
+ "Drift-compliance slack-relaxation stage 2 unexpectedly "
+ f"failed (HiGHS status {stage2.status}: {stage2.message}) "
+ "despite stage 1's own solution being feasible for stage 2 "
+ "by construction -- this indicates a bug in the algorithm."
+ )
+ x = stage2.x
+
+ out = drifted.copy()
+ for k, i in enumerate(indep_idx):
+ out[i] = x[w_at[k]]
+ for k, legs in enumerate(group_legs):
+ kg = x[k_at[k]]
+ for i in legs:
+ out[i] = kg * drifted[i]
+ return out
diff --git a/src/quantlab/portfolio/rebalancing.py b/src/quantlab/portfolio/rebalancing.py
index b12e0fb..e00b62e 100644
--- a/src/quantlab/portfolio/rebalancing.py
+++ b/src/quantlab/portfolio/rebalancing.py
@@ -1,8 +1,12 @@
"""Rebalancing schedules and stateful turnover limits.
Targets are sampled on rebalance dates and represented as constant portfolio
-weights between them. This vectorised approximation does not model weight
-drift caused by relative asset-price moves between rebalances.
+weights between them -- this module's own output is a decision-timeline
+step function. Real, price-driven weight drift between genuine trades is
+modeled separately and downstream, on the EXECUTED timeline, by
+:func:`quantlab.backtesting.accounting.apply_weight_drift` (gated by
+``PortfolioConfig.model_weight_drift``); this module's own output is
+identical regardless of whether that gate is on or off.
Timing convention: every function in this module produces *decided* weights,
not executed ones -- including a row where a closed symbol's pending target
@@ -20,6 +24,9 @@
from __future__ import annotations
+from dataclasses import dataclass
+from typing import Literal, overload
+
import numpy as np
import pandas as pd
from pandas.api.types import is_bool_dtype
@@ -35,6 +42,29 @@
validate_frame,
)
+
+@dataclass(frozen=True)
+class TurnoverProvenance:
+ """Cell-level, real provenance from a turnover-capped rebalance.
+
+ ``turnover_actively_limited`` is True where the turnover budget itself
+ bound this row's move for that cell. ``turnover_touched`` is the
+ broader, *episode-scoped* provenance -- also True on a later row that
+ is still catching up a debt created by an earlier turnover-limited
+ move toward the SAME upstream decision (see ``episode_id`` on
+ :func:`cap_turnover`/:func:`rebalance_and_cap_turnover`), even when
+ that later row is no longer itself actively binding. ``tradability_
+ touched``/``tradability_compliance_limited`` are always all-``False``
+ for :func:`cap_turnover` (no tradability concept); populated for
+ :func:`_rebalance_tradability_aware`.
+ """
+
+ turnover_actively_limited: pd.DataFrame
+ turnover_touched: pd.DataFrame
+ tradability_touched: pd.DataFrame
+ tradability_compliance_limited: pd.DataFrame
+
+
_PERIOD_ALIAS = {
RebalanceFrequency.WEEKLY: "W",
RebalanceFrequency.MONTHLY: "M",
@@ -128,13 +158,39 @@ def compute_turnover(held_weights: pd.DataFrame) -> pd.Series:
return (validated - previous).abs().sum(axis=1)
+@overload
def rebalance_and_cap_turnover(
target_weights: pd.DataFrame,
portfolio_config: PortfolioConfig,
*,
tradable: pd.DataFrame | None = None,
calendar: str | None = None,
-) -> pd.DataFrame:
+ episode_id: pd.DataFrame | None = None,
+ return_provenance: Literal[False] = False,
+) -> pd.DataFrame: ...
+
+
+@overload
+def rebalance_and_cap_turnover(
+ target_weights: pd.DataFrame,
+ portfolio_config: PortfolioConfig,
+ *,
+ tradable: pd.DataFrame | None = None,
+ calendar: str | None = None,
+ episode_id: pd.DataFrame | None = None,
+ return_provenance: Literal[True],
+) -> tuple[pd.DataFrame, TurnoverProvenance]: ...
+
+
+def rebalance_and_cap_turnover(
+ target_weights: pd.DataFrame,
+ portfolio_config: PortfolioConfig,
+ *,
+ tradable: pd.DataFrame | None = None,
+ calendar: str | None = None,
+ episode_id: pd.DataFrame | None = None,
+ return_provenance: bool = False,
+) -> pd.DataFrame | tuple[pd.DataFrame, TurnoverProvenance]:
"""Apply the stateful schedule and turnover cap over one continuous index.
Minimum-weight and position-count constraints apply to targets upstream.
@@ -161,17 +217,40 @@ def rebalance_and_cap_turnover(
real trading sessions instead of raw UTC dates. Only meaningful
when every instrument shares one calendar; omit for a mixed
universe (see :func:`rebalance_dates`).
+ episode_id: Forwarded to :func:`cap_turnover`/the tradability-aware
+ path -- see :func:`cap_turnover`'s own docstring.
+ return_provenance: Forwarded the same way -- see :func:`cap_turnover`.
"""
if tradable is not None:
+ # Branched (rather than forwarding the plain `bool` variable
+ # directly) so mypy can select the correct @overload -- a
+ # non-literal bool cannot match either `Literal[True]`/
+ # `Literal[False]` overload variant.
+ if return_provenance:
+ return _rebalance_tradability_aware(
+ target_weights,
+ portfolio_config,
+ tradable,
+ calendar=calendar,
+ episode_id=episode_id,
+ return_provenance=True,
+ )
return _rebalance_tradability_aware(
- target_weights, portfolio_config, tradable, calendar=calendar
+ target_weights,
+ portfolio_config,
+ tradable,
+ calendar=calendar,
+ episode_id=episode_id,
+ return_provenance=False,
)
held = apply_rebalancing(
target_weights, portfolio_config.rebalance_frequency, calendar=calendar
)
if portfolio_config.maximum_turnover is None:
- return held
+ if not return_provenance:
+ return held
+ return held, _no_provenance(held)
gross_caps = [portfolio_config.maximum_leverage]
if portfolio_config.maximum_gross_exposure is not None:
@@ -182,6 +261,18 @@ def rebalance_and_cap_turnover(
portfolio_config.rebalance_frequency,
calendar=calendar,
)
+ if return_provenance:
+ return cap_turnover(
+ held,
+ portfolio_config.maximum_turnover,
+ rebalance_index=dates,
+ maximum_weight=portfolio_config.maximum_weight,
+ maximum_gross_exposure=effective_gross_cap,
+ maximum_net_exposure=portfolio_config.maximum_net_exposure,
+ long_only=portfolio_config.long_only,
+ episode_id=episode_id,
+ return_provenance=True,
+ )
return cap_turnover(
held,
portfolio_config.maximum_turnover,
@@ -190,9 +281,12 @@ def rebalance_and_cap_turnover(
maximum_gross_exposure=effective_gross_cap,
maximum_net_exposure=portfolio_config.maximum_net_exposure,
long_only=portfolio_config.long_only,
+ episode_id=episode_id,
+ return_provenance=False,
)
+@overload
def cap_turnover(
held_weights: pd.DataFrame,
maximum_turnover: float,
@@ -202,13 +296,75 @@ def cap_turnover(
maximum_gross_exposure: float | None = None,
maximum_net_exposure: float | None = None,
long_only: bool = False,
-) -> pd.DataFrame:
+ episode_id: pd.DataFrame | None = None,
+ return_provenance: Literal[False] = False,
+) -> pd.DataFrame: ...
+
+
+@overload
+def cap_turnover(
+ held_weights: pd.DataFrame,
+ maximum_turnover: float,
+ *,
+ rebalance_index: pd.DatetimeIndex | None = None,
+ maximum_weight: float | None = None,
+ maximum_gross_exposure: float | None = None,
+ maximum_net_exposure: float | None = None,
+ long_only: bool = False,
+ episode_id: pd.DataFrame | None = None,
+ return_provenance: Literal[True],
+) -> tuple[pd.DataFrame, TurnoverProvenance]: ...
+
+
+def cap_turnover(
+ held_weights: pd.DataFrame,
+ maximum_turnover: float,
+ *,
+ rebalance_index: pd.DatetimeIndex | None = None,
+ maximum_weight: float | None = None,
+ maximum_gross_exposure: float | None = None,
+ maximum_net_exposure: float | None = None,
+ long_only: bool = False,
+ episode_id: pd.DataFrame | None = None,
+ return_provenance: bool = False,
+) -> pd.DataFrame | tuple[pd.DataFrame, TurnoverProvenance]:
"""Partially move toward each scheduled target within an L1 budget.
The result is a straight-line interpolation from the previous holding to
an already-compliant target. Per-asset, gross, net and long-only bounds are
convex, so compliant endpoints keep every intermediate point compliant.
Cardinality and minimum-position-size constraints remain target-only.
+
+ Args:
+ held_weights: Scheduled targets to move toward, one row per date
+ (constant between rebalance dates -- see :func:`apply_rebalancing`).
+ maximum_turnover: Maximum L1 weight change allowed on any single row
+ (a rebalance whose full target exceeds this lands partially and
+ keeps closing the gap over subsequent rows).
+ rebalance_index: Dates on which a new target is actually chased;
+ every other date holds the previous value. ``None`` treats
+ every date as a rebalance date.
+ maximum_weight: Optional per-asset cap, enforced on the target
+ (upstream) and therefore on every intermediate point.
+ maximum_gross_exposure: Optional gross exposure cap, same convexity
+ argument.
+ maximum_net_exposure: Optional net exposure cap, same convexity
+ argument.
+ long_only: When True, rejects a target with any negative weight.
+ episode_id: Required when ``return_provenance`` is True. A ``dates
+ x symbols`` integer frame identifying, per cell, which upstream
+ decision produced the target currently being chased -- two
+ cells sharing the same value are the SAME still-unresolved
+ decision, even if the target happens to repeat a prior numeric
+ value; a different value always means a genuinely different
+ upstream decision. Built by the caller (see ``engine.py``),
+ never reconstructed here from the target's own numeric value
+ (which cannot tell two decisions with the same target apart).
+ return_provenance: When True, also return a :class:`TurnoverProvenance`
+ with real, cell-level attribution of which trades were caused
+ (directly or as an episode-scoped catch-up) by the turnover
+ cap. Does not affect the computed weights in any way -- the
+ numeric branch below is identical whether or not this is set.
"""
validated = validate_frame(held_weights, name="held_weights")
turnover_cap = finite_real(maximum_turnover, name="maximum_turnover", minimum=0.0)
@@ -224,6 +380,27 @@ def cap_turnover(
row_count, column_count = targets.shape
output = np.zeros((row_count, column_count), dtype=float)
previous = np.zeros(column_count, dtype=float)
+
+ # Always bound with cheap placeholders, even though they are only ever
+ # read (below and by the caller) under `if return_provenance:` -- the
+ # same unchanged flag that guards their real assignment just below. A
+ # static analyzer cannot follow "guarded by the same boolean flag"
+ # across the loop in between; this changes no behaviour.
+ episode_values = np.empty((row_count, column_count), dtype=float)
+ pending_episode_id = np.full(column_count, -1.0)
+ actively_limited_out = np.zeros((row_count, column_count), dtype=bool)
+ touched_out = np.zeros((row_count, column_count), dtype=bool)
+ if return_provenance:
+ if episode_id is None:
+ raise BacktestError(
+ "episode_id is required when return_provenance is True."
+ )
+ episode_values = (
+ validate_frame(episode_id, name="episode_id")
+ .reindex(index=validated.index, columns=validated.columns)
+ .to_numpy(dtype=float)
+ )
+
for row_number in range(row_count):
if not is_rebalance_date[row_number]:
output[row_number] = previous
@@ -243,9 +420,50 @@ def cap_turnover(
current = target
else:
current = previous + (turnover_cap / requested_turnover) * change
+ if return_provenance:
+ row_actively_limited = requested_turnover > turnover_cap + EPSILON
+ changed_this_row = np.abs(change) > EPSILON
+ generation = episode_values[row_number]
+ debt_still_relevant = (pending_episode_id != -1.0) & (
+ pending_episode_id == generation
+ )
+ actively_limited_out[row_number] = changed_this_row & row_actively_limited
+ touched_out[row_number] = changed_this_row & (
+ row_actively_limited | debt_still_relevant
+ )
+ still_outstanding = np.abs(current - target) > EPSILON
+ pending_episode_id = np.where(still_outstanding, generation, -1.0)
output[row_number] = current
previous = current
- return pd.DataFrame(output, index=validated.index, columns=validated.columns)
+ result = pd.DataFrame(output, index=validated.index, columns=validated.columns)
+ if not return_provenance:
+ return result
+ provenance = TurnoverProvenance(
+ turnover_actively_limited=pd.DataFrame(
+ actively_limited_out, index=validated.index, columns=validated.columns
+ ),
+ turnover_touched=pd.DataFrame(
+ touched_out, index=validated.index, columns=validated.columns
+ ),
+ tradability_touched=pd.DataFrame(
+ False, index=validated.index, columns=validated.columns
+ ),
+ tradability_compliance_limited=pd.DataFrame(
+ False, index=validated.index, columns=validated.columns
+ ),
+ )
+ return result, provenance
+
+
+def _no_provenance(frame: pd.DataFrame) -> TurnoverProvenance:
+ """All-``False`` provenance for a path where nothing can be attributed."""
+ empty = pd.DataFrame(False, index=frame.index, columns=frame.columns)
+ return TurnoverProvenance(
+ turnover_actively_limited=empty,
+ turnover_touched=empty,
+ tradability_touched=empty,
+ tradability_compliance_limited=empty,
+ )
def _compliance_violations(
@@ -376,13 +594,39 @@ def _assert_holdings_compliant(
)
+@overload
def _rebalance_tradability_aware(
target_weights: pd.DataFrame,
portfolio_config: PortfolioConfig,
tradable: pd.DataFrame,
*,
calendar: str | None = None,
-) -> pd.DataFrame:
+ episode_id: pd.DataFrame | None = None,
+ return_provenance: Literal[False] = False,
+) -> pd.DataFrame: ...
+
+
+@overload
+def _rebalance_tradability_aware(
+ target_weights: pd.DataFrame,
+ portfolio_config: PortfolioConfig,
+ tradable: pd.DataFrame,
+ *,
+ calendar: str | None = None,
+ episode_id: pd.DataFrame | None = None,
+ return_provenance: Literal[True],
+) -> tuple[pd.DataFrame, TurnoverProvenance]: ...
+
+
+def _rebalance_tradability_aware(
+ target_weights: pd.DataFrame,
+ portfolio_config: PortfolioConfig,
+ tradable: pd.DataFrame,
+ *,
+ calendar: str | None = None,
+ episode_id: pd.DataFrame | None = None,
+ return_provenance: bool = False,
+) -> pd.DataFrame | tuple[pd.DataFrame, TurnoverProvenance]:
"""Rebalance while respecting per-symbol tradability.
A symbol that is closed on a rebalance date never trades that date; its
@@ -391,6 +635,8 @@ def _rebalance_tradability_aware(
executed — even if that takes several sessions under a turnover cap. A
symbol that is always tradable is completely unaffected: its cadence
(rebalance-date-only catch-up) is byte-identical to :func:`cap_turnover`.
+
+ See :func:`cap_turnover` for ``episode_id``/``return_provenance``.
"""
validated = validate_frame(
target_weights, name="target_weights", require_datetime_index=True
@@ -455,8 +701,33 @@ def _rebalance_tradability_aware(
pending_target = np.zeros(column_count, dtype=float)
pending_due_to_closure = np.zeros(column_count, dtype=bool)
+ # Always bound with cheap placeholders, even though they are only ever
+ # read (below and by the caller) under `if return_provenance:` -- the
+ # same unchanged flag that guards their real assignment. A static
+ # analyzer cannot follow "guarded by the same boolean flag" across the
+ # loop in between; this changes no behaviour.
+ episode_values = np.empty((row_count, column_count), dtype=float)
+ pending_turnover_episode_id = np.full(column_count, -1.0)
+ actively_limited_out = np.zeros((row_count, column_count), dtype=bool)
+ turnover_touched_out = np.zeros((row_count, column_count), dtype=bool)
+ tradability_touched_out = np.zeros((row_count, column_count), dtype=bool)
+ compliance_limited_out = np.zeros((row_count, column_count), dtype=bool)
+ pending_before = np.zeros(column_count, dtype=bool)
+ if return_provenance:
+ if episode_id is None:
+ raise BacktestError(
+ "episode_id is required when return_provenance is True."
+ )
+ episode_values = (
+ validate_frame(episode_id, name="episode_id")
+ .reindex(index=validated.index, columns=validated.columns)
+ .to_numpy(dtype=float)
+ )
+
for row_number in range(row_count):
row_tradable = tradable_np[row_number]
+ if return_provenance:
+ pending_before = pending_due_to_closure.copy()
if is_rebalance_date[row_number]:
target_row = targets[row_number]
_validate_target_row_compliant(
@@ -510,9 +781,58 @@ def _rebalance_tradability_aware(
pending_due_to_closure | (eligible & unresolved & compliance_limited)
) & unresolved
+ if return_provenance:
+ changed_this_row = np.abs(change) > EPSILON
+ # tradability: this row's move is (at least partly) a catch-up
+ # of a delta previously blocked by a closure (pending_before),
+ # or a feasibility limit reached only because another column
+ # stayed frozen by a closure (compliance_limited, proven in
+ # _max_feasible_fraction's own docstring to be a tradability
+ # artifact, never a turnover-budget one).
+ tradability_touched_out[row_number] = changed_this_row & (
+ pending_before | compliance_limited
+ )
+ compliance_limited_out[row_number] = changed_this_row & compliance_limited
+ # turnover: independent of tradability, scoped to the same
+ # episode-id convention as cap_turnover. `~pending_before`
+ # avoids double-counting a cell whose shortfall this row is
+ # already explained by tradability's own domain.
+ generation = episode_values[row_number]
+ debt_still_relevant = (
+ (pending_turnover_episode_id != -1.0)
+ & (pending_turnover_episode_id == generation)
+ & ~pending_before
+ )
+ row_turnover_limited = fraction_from_turnover < 1.0 - EPSILON
+ actively_limited_out[row_number] = changed_this_row & row_turnover_limited
+ turnover_touched_out[row_number] = changed_this_row & (
+ row_turnover_limited | debt_still_relevant
+ )
+ still_outstanding_turnover = eligible & unresolved
+ pending_turnover_episode_id = np.where(
+ still_outstanding_turnover, generation, -1.0
+ )
+
output[row_number] = current
previous = current
- return pd.DataFrame(output, index=validated.index, columns=validated.columns)
+ result = pd.DataFrame(output, index=validated.index, columns=validated.columns)
+ if not return_provenance:
+ return result
+ provenance = TurnoverProvenance(
+ turnover_actively_limited=pd.DataFrame(
+ actively_limited_out, index=validated.index, columns=validated.columns
+ ),
+ turnover_touched=pd.DataFrame(
+ turnover_touched_out, index=validated.index, columns=validated.columns
+ ),
+ tradability_touched=pd.DataFrame(
+ tradability_touched_out, index=validated.index, columns=validated.columns
+ ),
+ tradability_compliance_limited=pd.DataFrame(
+ compliance_limited_out, index=validated.index, columns=validated.columns
+ ),
+ )
+ return result, provenance
def _rebalance_mask(
diff --git a/src/quantlab/reporting/__init__.py b/src/quantlab/reporting/__init__.py
index 13f02cc..3a7eac7 100644
--- a/src/quantlab/reporting/__init__.py
+++ b/src/quantlab/reporting/__init__.py
@@ -8,6 +8,7 @@
save_figures,
)
from quantlab.reporting.html_report import render_html_report
+from quantlab.reporting.sections import DiagnosticsSection
from quantlab.reporting.tables import (
gross_net_table,
metrics_table,
@@ -17,6 +18,7 @@
)
__all__ = [
+ "DiagnosticsSection",
"equity_and_drawdown_figure",
"gross_net_table",
"metrics_table",
diff --git a/src/quantlab/reporting/charts.py b/src/quantlab/reporting/charts.py
index e0f928e..9fa862b 100644
--- a/src/quantlab/reporting/charts.py
+++ b/src/quantlab/reporting/charts.py
@@ -26,6 +26,7 @@
if TYPE_CHECKING:
from quantlab.backtesting.result import BacktestResult
+ from quantlab.features.pairs_diagnostics import PairDiagnostics
logger = get_logger(__name__)
@@ -248,6 +249,90 @@ def sensitivity_heatmap_chart(
return fig
+def correlation_heatmap_chart(matrix: pd.DataFrame) -> Figure:
+ """Plot a symbol x symbol correlation matrix as a static heatmap.
+
+ Mirrors the dashboard's interactive Plotly heatmap
+ (``dashboard.explorer.shared_components.render_correlation_matrix``)
+ in a static form for the HTML report.
+ """
+ width = max(4.0, 0.6 * len(matrix.columns) + 2.0)
+ height = max(3.0, 0.6 * len(matrix.index) + 1.0)
+ fig, ax = _new_figure((width, height))
+ values = matrix.to_numpy(dtype=float)
+ image = ax.imshow(
+ values, cmap=colormaps["RdBu"], vmin=-1.0, vmax=1.0, aspect="auto"
+ )
+ ax.set_xticks(range(len(matrix.columns)))
+ ax.set_xticklabels([str(c) for c in matrix.columns], rotation=45, ha="right")
+ ax.set_yticks(range(len(matrix.index)))
+ ax.set_yticklabels([str(r) for r in matrix.index])
+ ax.set_title("Correlation matrix (of returns)", fontsize=11, fontweight="bold")
+ fig.colorbar(image, ax=ax, fraction=0.025, pad=0.02)
+ for row in range(values.shape[0]):
+ for column in range(values.shape[1]):
+ value = values[row, column]
+ colour = "white" if abs(value) > 0.6 else "#111827"
+ ax.text(
+ column,
+ row,
+ f"{value:.2f}",
+ ha="center",
+ va="center",
+ fontsize=7,
+ color=colour,
+ )
+ return fig
+
+
+def pair_spread_chart(diagnostics: PairDiagnostics) -> Figure:
+ """Plot a pair's spread, indicator and rolling stationarity p-value.
+
+ The three panels answer, respectively: what does the residual look
+ like, how far is it currently from its own recent behaviour (per
+ ``diagnostics.indicator`` -- zscore, rsi or percentile, whichever the
+ pair was actually diagnosed with), and has the relationship stayed
+ stationary throughout the sample rather than only when tested once
+ over the whole history (see ``PairDiagnostics.rolling_adf_pvalue``).
+ """
+ fig = Figure(figsize=(9, 7.5))
+ FigureCanvasAgg(fig)
+ ax_spread, ax_indicator, ax_pvalue = fig.subplots(3, 1, sharex=True)
+
+ spread = diagnostics.spread
+ ax_spread.plot(spread.index, spread.to_numpy(), color=STRATEGY, lw=1.2)
+ ax_spread.axhline(0.0, color=_GRID, lw=1.0)
+ _style_axes(
+ ax_spread, f"{diagnostics.symbol_a}/{diagnostics.symbol_b} spread", "Spread"
+ )
+
+ indicator = diagnostics.spread_indicator
+ indicator_label = f"{diagnostics.indicator} indicator"
+ ax_indicator.plot(indicator.index, indicator.to_numpy(), color=ACCENT, lw=1.2)
+ ax_indicator.axhline(0.0, color=_GRID, lw=1.0)
+ _style_axes(ax_indicator, f"Spread {indicator_label}", indicator_label)
+
+ pvalue = diagnostics.rolling_adf_pvalue.dropna()
+ if len(pvalue):
+ ax_pvalue.plot(
+ pvalue.index, pvalue.to_numpy(), color=NEGATIVE, marker="o", ms=3, lw=1.0
+ )
+ else:
+ ax_pvalue.text(
+ 0.5,
+ 0.5,
+ "Not enough history for a rolling stationarity check.",
+ ha="center",
+ va="center",
+ transform=ax_pvalue.transAxes,
+ color=BENCHMARK,
+ )
+ ax_pvalue.axhline(0.05, color=_GRID, lw=1.0, ls="--")
+ _style_axes(ax_pvalue, "Rolling ADF p-value (stability over time)", "p-value")
+ fig.tight_layout()
+ return fig
+
+
def adaptive_rolling_window(n_observations: int) -> int:
"""Shrink the rolling Sharpe/volatility window for short samples.
diff --git a/src/quantlab/reporting/html_report.py b/src/quantlab/reporting/html_report.py
index cadf2b3..d82ca2a 100644
--- a/src/quantlab/reporting/html_report.py
+++ b/src/quantlab/reporting/html_report.py
@@ -14,9 +14,11 @@
import numpy as np
import pandas as pd
+from quantlab.backtesting.trade_log import stop_loss_take_profit_trigger_counts
from quantlab.logging_config import get_logger
from quantlab.reporting import research_summary as rs
from quantlab.reporting.charts import report_figures
+from quantlab.reporting.sections import DiagnosticsSection
from quantlab.reporting.tables import gross_net_table, metrics_table, subperiod_table
if TYPE_CHECKING:
@@ -104,7 +106,17 @@ def _finite_number(value: object) -> float | None:
def _format_cell(value: object, column: object) -> object:
if value is None or value is pd.NA or value is pd.NaT:
return "n/a"
- if isinstance(value, Real) and _finite_number(value) is None:
+ # A bool IS a `Real` (bool subclasses int in Python), but it is always
+ # finite and displayable as-is ("True"/"False") -- excluding it here
+ # from the "not finite -> n/a" fallback is a DIFFERENT concern from
+ # `_finite_number`'s own bool exclusion below (scoped to designated
+ # percent/number/integer columns only, where a stray bool genuinely
+ # should not be formatted as 0.00/1.00).
+ if (
+ isinstance(value, Real)
+ and not isinstance(value, (bool, np.bool_))
+ and _finite_number(value) is None
+ ):
return "n/a"
key = _normalise_label(column)
if key not in _PERCENT_COLUMNS | _NUMBER_COLUMNS | _INTEGER_COLUMNS:
@@ -187,7 +199,37 @@ def image(name: str, alt: str) -> str:
limitations_html = "".join(
f"
{html.escape(item)}
" for item in rs.limitations(result)
)
- robustness_html = _render_robustness(robustness, warnings)
+ # A strategy's own results diagnostics (e.g. pairs trading's
+ # correlation/spread/ADF section) describe whether the strategy's
+ # ASSUMPTIONS hold on this data -- distinct from Robustness, which
+ # tests whether the RESULT survives cost/parameter/regime perturbation.
+ # Split them out of the same merged dict into their own section
+ # (rendered via `_render_strategy_diagnostics`) rather than nesting a
+ # correlation table or a full-sample ADF test under "Robustness".
+ diagnostics_sections = {
+ key: value
+ for key, value in (robustness or {}).items()
+ if isinstance(value, DiagnosticsSection)
+ }
+ robustness_only = {
+ key: value
+ for key, value in (robustness or {}).items()
+ if key not in diagnostics_sections
+ }
+ strategy_diagnostics_html = _render_strategy_diagnostics(diagnostics_sections)
+ trigger_counts = stop_loss_take_profit_trigger_counts(result.trades)
+ stop_loss_take_profit_html = (
+ f"
Stop-loss triggered on {trigger_counts['stop_loss']} "
+ "symbol-position(s); take-profit on "
+ f"{trigger_counts['take_profit']} symbol-position(s), "
+ "counted per trade-log row -- a declared multi-symbol position "
+ "(e.g. a hedge with more than one leg) that force-flattens "
+ "contributes one row per leg, so this is not necessarily the "
+ "count of distinct stop-loss/take-profit EVENTS.
"
+ if trigger_counts["stop_loss"] or trigger_counts["take_profit"]
+ else ""
+ )
+ robustness_html = _render_robustness(robustness_only, warnings)
data_quality_html = _render_data_quality(result.metadata.get("data_quality"))
# A walk-forward OOS result's `metrics` *are* the stitched out-of-sample
# series (see WalkForwardValidator._build_oos_result) — labelling the
@@ -265,8 +307,15 @@ def image(name: str, alt: str) -> str:
{image("exposure", "Exposure")}
{image("cumulative_costs", "Cumulative costs")}
{image("returns_distribution", "Return distribution")}
+ {stop_loss_take_profit_html}
+ {
+ f"
Strategy diagnostics
{strategy_diagnostics_html}"
+ if diagnostics_sections
+ else ""
+ }
+
Robustness
{robustness_html}
Limitations
{limitations_html}
@@ -328,26 +377,58 @@ def _render_sensitivity_heatmap(
return f''
+def _render_strategy_diagnostics(sections: Mapping[str, DiagnosticsSection]) -> str:
+ """Render each strategy-declared results diagnostic in its own subsection.
+
+ Kept structurally separate from :func:`_render_robustness` -- a
+ correlation, spread or full-sample ADF test describes whether the
+ STRATEGY's own assumptions hold on this data, not evidence the backtest
+ RESULT is robust to cost/parameter/regime perturbation, so it must never
+ appear under the "Robustness" heading. Dispatched purely by the type of
+ each ``robustness`` dict value (see :func:`render_html_report`), never
+ by strategy name -- a future profile's own section needs no change here.
+ """
+ parts: list[str] = []
+ for key, section in sections.items():
+ heading = str(key).replace("_", " ").title()
+ parts.append(f"
{html.escape(heading)}
")
+ if section.note:
+ parts.append(f"
{html.escape(section.note)}
")
+ if section.chart_data_uri:
+ parts.append(f'')
+ parts.append(_table_html(_format_report_table(section.table)))
+ return "".join(parts)
+
+
def _render_robustness(
robustness: dict[str, Any] | None, warnings: list[str] | None = None
) -> str:
- """Render supplied validation artefacts or explain how to generate them."""
+ """Render supplied validation artefacts or explain how to generate them.
+
+ Never receives a `DiagnosticsSection` value -- `render_html_report()`
+ filters those into `_render_strategy_diagnostics` before calling this.
+ """
if not robustness:
return (
"
No robustness evidence is attached to this run.
"
"
"
"
quantlab walk-forward — out-of-sample folds plus "
"stress-test evidence.
quantlab permutation-test — significance "
+ "against a random-sign null.
"
+ "
quantlab robustness — runs every technique "
+ "enabled under a config's robustness: block in one "
+ "pass.
"
"
"
"
See notebooks/05_robustness_analysis.ipynb for "
- "a worked example of all four.
"
+ "a worked example, or the Python API "
+ "(quantlab.validation.*) to call these directly.
"
)
parts: list[str] = []
for key, value in robustness.items():
@@ -360,6 +441,17 @@ def _render_robustness(
"low p-value is evidence against that specific null, not a "
"probability of future profitability."
)
+ elif key == "bootstrap":
+ parts.append(
+ "
p_lower/p_upper are the boundaries of a central "
+ "percentile interval over resampled histories (width set by "
+ "robustness.bootstrap.confidence_level; 0.90 -> the 5th/95th "
+ "percentiles).
"
+ )
+ if isinstance(value, pd.DataFrame) and len(value):
+ from quantlab.reporting.tables import format_bootstrap_summary
+
+ value = format_bootstrap_summary(value)
elif key == "sensitivity" and isinstance(value, pd.DataFrame) and len(value):
parts.append(_render_sensitivity_heatmap(value, warnings))
parts.append(_render_robustness_value(value))
diff --git a/src/quantlab/reporting/research_summary.py b/src/quantlab/reporting/research_summary.py
index 516e193..7d1b7e0 100644
--- a/src/quantlab/reporting/research_summary.py
+++ b/src/quantlab/reporting/research_summary.py
@@ -123,8 +123,15 @@ def executive_summary(result: BacktestResult) -> str:
def research_question(result: BacktestResult) -> str:
- """Return a question that names only configured and attached evidence."""
+ """Return a question that names only configured and attached evidence.
+
+ Returns ``result.config.research_question`` verbatim when set --
+ every experiment that doesn't set it keeps the auto-generated text
+ below unchanged.
+ """
cfg = result.config
+ if cfg.research_question is not None:
+ return cfg.research_question
strategy = _actually_used(result, "strategy", cfg.strategy_name)
portfolio = cfg.portfolio
volatility_targeted = (
@@ -173,7 +180,14 @@ def research_question(result: BacktestResult) -> str:
def hypothesis(result: BacktestResult) -> str:
- """Return H1/H0 and state whether OOS evidence is attached to the run."""
+ """Return H1/H0 and state whether OOS evidence is attached to the run.
+
+ Returns ``result.config.hypothesis`` verbatim when set -- every
+ experiment that doesn't set it keeps the auto-generated text below
+ unchanged.
+ """
+ if result.config.hypothesis is not None:
+ return result.config.hypothesis
oos = _oos_metrics(result)
oos_status = (
f"attached to this run: {oos[1]}."
@@ -259,7 +273,7 @@ def _portfolio_methodology(result: BacktestResult) -> str:
(portfolio.target_minimum_weight, "target minimum weight", ".2%"),
(portfolio.maximum_gross_exposure, "maximum gross exposure", ".2f"),
(portfolio.maximum_net_exposure, "maximum absolute net exposure", ".2f"),
- (portfolio.maximum_turnover, "maximum L1 turnover per rebalance", ".2f"),
+ (portfolio.maximum_turnover, "maximum L1 turnover per period", ".2f"),
)
for value, label, spec in optional:
if value is not None:
diff --git a/src/quantlab/reporting/sections.py b/src/quantlab/reporting/sections.py
new file mode 100644
index 0000000..36fde23
--- /dev/null
+++ b/src/quantlab/reporting/sections.py
@@ -0,0 +1,28 @@
+"""Generic, strategy-agnostic containers for an extra HTML report section.
+
+A strategy profile that declares its own :class:`~quantlab.dashboard.
+explorer.profile.ResultsDiagnostics` builds one of these to describe its
+report section; ``html_report.py`` renders it by type, never by strategy
+name, so a future strategy can add its own report section without any
+change to the report renderer.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import pandas as pd
+
+
+@dataclass(frozen=True)
+class DiagnosticsSection:
+ """One report section: a table, an optional chart, an optional note.
+
+ ``chart_data_uri`` is a ready-to-embed ``data:image/...;base64,...``
+ string (see ``reporting.charts.fig_to_base64``). ``note`` is plain
+ text (escaped at render time, never treated as markup).
+ """
+
+ table: pd.DataFrame
+ chart_data_uri: str | None = None
+ note: str | None = None
diff --git a/src/quantlab/reporting/tables.py b/src/quantlab/reporting/tables.py
index 0ea5935..639e3f3 100644
--- a/src/quantlab/reporting/tables.py
+++ b/src/quantlab/reporting/tables.py
@@ -20,6 +20,7 @@
if TYPE_CHECKING:
from quantlab.backtesting.result import BacktestResult
+ from quantlab.features.pairs_diagnostics import PairDiagnostics
_METRIC_FORMAT = {
@@ -38,7 +39,7 @@
"kurtosis": ("Kurtosis", "num"),
"annual_turnover": ("Annual turnover (x/year)", "num"),
"average_gross_exposure": ("Avg gross exposure (x)", "num"),
- "number_of_trades": ("Number of trades", "int"),
+ "number_of_trades": ("Number of fills", "int"),
"beta": ("Beta", "num"),
"alpha": ("Alpha (ann.)", "pct"),
"information_ratio": ("Information ratio", "num"),
@@ -221,3 +222,67 @@ def regime_table(
row["Observations"] = len(regime_returns)
rows.append(row)
return pd.DataFrame(rows)
+
+
+#: Formatting kind for each statistic name BootstrapResult.summary() reports.
+_BOOTSTRAP_STATISTIC_FORMAT: dict[str, tuple[str, str]] = {
+ "cagr": ("CAGR", "pct"),
+ "sharpe": ("Sharpe", "num"),
+ "max_drawdown": ("Max Drawdown", "pct"),
+ "final_value": ("Final Value", "currency"),
+}
+
+
+def pair_diagnostics_summary_table(diagnostics: PairDiagnostics) -> pd.DataFrame:
+ """Metric/Value summary table for a pairs-trading result's diagnostics.
+
+ A snapshot only -- the full spread/indicator/rolling-stability history
+ lives in the accompanying chart (``reporting.charts.pair_spread_
+ chart``), not in this table.
+ """
+ adf = diagnostics.adf_result
+ coint = diagnostics.cointegration_result
+ rows = [
+ ("Symbols", f"{diagnostics.symbol_a} / {diagnostics.symbol_b}"),
+ ("Return correlation", _fmt(diagnostics.correlation, "num")),
+ (
+ "Hedge-ratio stability (std of beta)",
+ _fmt(diagnostics.hedge_ratio_stability, "num"),
+ ),
+ ("Half-life (periods)", _fmt(diagnostics.half_life, "num")),
+ ("ADF statistic (spread)", _fmt(adf.statistic, "num") if adf else "n/a"),
+ ("ADF p-value (spread)", _fmt(adf.pvalue, "num") if adf else "n/a"),
+ (
+ "Engle-Granger statistic",
+ _fmt(coint.statistic, "num") if coint else "n/a",
+ ),
+ ("Engle-Granger p-value", _fmt(coint.pvalue, "num") if coint else "n/a"),
+ ]
+ return pd.DataFrame(rows, columns=["Metric", "Value"])
+
+
+def format_bootstrap_summary(summary: pd.DataFrame) -> pd.DataFrame:
+ """Format ``BootstrapResult.summary()`` for display.
+
+ Its ``mean``/``median``/``std``/``p_lower``/``p_upper`` columns stack
+ values of very different scale across rows -- a CAGR near 0.05 next to
+ a final value near 100000 -- because each row is a different
+ statistic sharing the same generic columns. Pandas' default float
+ repr renders that mix inconsistently (scientific notation for some
+ cells, fixed-point for others, depending on each cell's own
+ magnitude). Formatting every cell in a row by its own statistic's
+ kind -- the same percent/number/currency convention ``metrics_table``
+ uses -- keeps the whole table in fixed-point notation regardless of
+ what the other rows contain.
+ """
+ numeric_columns = [column for column in summary.columns if column != "statistic"]
+ rows = []
+ for _, row in summary.iterrows():
+ label, kind = _BOOTSTRAP_STATISTIC_FORMAT.get(
+ row["statistic"], (str(row["statistic"]), "num")
+ )
+ formatted_row: dict[str, object] = {"statistic": label}
+ for column in numeric_columns:
+ formatted_row[column] = _fmt(row[column], kind)
+ rows.append(formatted_row)
+ return pd.DataFrame(rows, columns=["statistic", *numeric_columns])
diff --git a/src/quantlab/strategies/base.py b/src/quantlab/strategies/base.py
index 69a97b9..2392c04 100644
--- a/src/quantlab/strategies/base.py
+++ b/src/quantlab/strategies/base.py
@@ -8,20 +8,75 @@
import typing
from abc import ABC, abstractmethod
from collections.abc import Callable, Mapping
+from dataclasses import dataclass
from numbers import Integral, Real
-from typing import Any, TypeVar
+from typing import Any, ClassVar, TypeVar
import numpy as np
import pandas as pd
from quantlab.data.base import price_matrix
from quantlab.exceptions import StrategyError
-from quantlab.features._validation import numeric_pandas
+from quantlab.features._validation import finite_real, numeric_pandas
+
+#: Accepted values for every strategy's ``price_type`` constructor
+#: parameter (see ``BaseStrategy._prices()``).
+PRICE_TYPES = frozenset({"adjusted_close", "close"})
_REGISTRY: dict[str, type[BaseStrategy]] = {}
_StrategyT = TypeVar("_StrategyT", bound="BaseStrategy")
+class UnsetType:
+ """Sentinel distinguishing "not passed" from an explicit ``None``.
+
+ Used for an optional, indicator/threshold-dependent constructor
+ parameter (e.g. `MeanReversionStrategy`/`PairsTradingStrategy`'s
+ ``stop_threshold``) whose sensible default depends on another
+ parameter chosen in the SAME call (e.g. ``indicator``) and therefore
+ cannot be a plain literal default. If ``None`` were the parameter's
+ own default, "not passed" and "explicitly disabled" would collapse to
+ the same value, making "disable this" inexpressible. `UNSET` is the
+ constructor's actual default instead: `UNSET` resolves to whatever
+ indicator-specific default applies, while an explicit ``None`` is
+ respected as "disabled".
+ """
+
+ def __repr__(self) -> str:
+ """Return a short, unambiguous debug representation."""
+ return ""
+
+
+UNSET = UnsetType()
+
+
+def validate_risk_control_parameters(
+ stop_loss_pct: object, take_profit_pct: object
+) -> tuple[float | None, float | None]:
+ """Validate a strategy's ``stop_loss_pct``/``take_profit_pct`` constructor pair.
+
+ Shared by every strategy that accepts these two (rather than each
+ duplicating the same two ``finite_real`` calls) -- both are optional,
+ strictly positive fractions (e.g. ``0.10`` = 10%) with no relational
+ constraint between them (unlike ``entry``/``exit``/``stop``, a
+ stop-loss and a take-profit are independent conditions on opposite
+ sides of zero return, not points on the same ordered scale).
+ """
+ validated_stop_loss = (
+ None
+ if stop_loss_pct is None
+ else finite_real(stop_loss_pct, name="stop_loss_pct", minimum=0.0, strict=True)
+ )
+ validated_take_profit = (
+ None
+ if take_profit_pct is None
+ else finite_real(
+ take_profit_pct, name="take_profit_pct", minimum=0.0, strict=True
+ )
+ )
+ return validated_stop_loss, validated_take_profit
+
+
def _registry_name(name: object) -> str:
if not isinstance(name, str) or not name.strip():
raise StrategyError("Strategy name must be a non-empty string.")
@@ -107,19 +162,28 @@ def strategy_sweepable_parameter_names(name: str) -> set[str]:
(comma-separated) are easy to misparse as an int/str instead of a bool.
``default_parameter_grid`` already treats these as fixed, not swept, for
walk-forward's own default grid — sensitivity applies the same rule.
+
+ Also excludes ``cls.deprecated_parameter_names`` (a strategy's own
+ deprecated backward-compatible aliases for a renamed parameter, if
+ any are currently registered): offering both a deprecated alias and
+ its canonical replacement as independent sweep axes would let a sweep
+ set the alias to a value that conflicts with the canonical name
+ already fixed elsewhere in the same config.
"""
registry_name = _registry_name(name)
if registry_name not in _REGISTRY:
raise StrategyError(
f"Unknown strategy '{registry_name}'. Registered: {sorted(_REGISTRY)}."
)
- signature = inspect.signature(_REGISTRY[registry_name].__init__)
+ strategy_class = _REGISTRY[registry_name]
+ signature = inspect.signature(strategy_class.__init__)
return {
parameter.name
for parameter in signature.parameters.values()
if parameter.name != "self"
and parameter.kind not in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD)
and not isinstance(parameter.default, bool)
+ and parameter.name not in strategy_class.deprecated_parameter_names
}
@@ -216,10 +280,79 @@ def validate_strategy_parameters(name: str, parameters: Mapping[str, Any]) -> No
cls.validate_parameters(complete)
+@dataclass(frozen=True)
+class SignalReasons:
+ """Optional, strategy-specific explanation of ``generate_signals()``.
+
+ Both frames must share ``generate_signals()``'s own ``dates x
+ symbols`` shape and index/columns exactly. ``detail_code`` is a
+ closed set of stable, machine-readable strings (or ``None`` where no
+ transition happened that date); ``details`` is optional free text
+ with the concrete values/thresholds involved, for human reading
+ only. Mirrors ``trade_log.py``'s own ``trigger_reason_code``/
+ ``trigger_reason_detail_code``/``trigger_reason_details`` split one
+ level up: this is the strategy's own contribution to a row whose
+ ``trigger_reason_code == "strategy_signal"``.
+ """
+
+ detail_code: pd.DataFrame
+ details: pd.DataFrame
+
+
class BaseStrategy(ABC):
"""Abstract base class for signal-generating strategies."""
name: str = "base"
+ #: Price series ``_prices()`` reads for signal generation --
+ #: "adjusted_close" (default) or "close". A class attribute fallback
+ #: for any strategy that doesn't accept its own ``price_type``
+ #: constructor parameter; every built-in strategy sets its own
+ #: instance attribute of the same name, validated at construction.
+ price_type: str = "adjusted_close"
+ #: Fractional (e.g. 0.10 = 10%) gross-return thresholds that force-
+ #: flatten this strategy's REAL executed position (see
+ #: `quantlab.backtesting.accounting._detect_stop_loss_take_profit`) --
+ #: class attribute fallbacks (mirroring `price_type` above) so calling
+ #: code can read `strategy.stop_loss_pct`/`strategy.take_profit_pct`
+ #: uniformly across every strategy, including ones that don't accept
+ #: either as a constructor parameter. `None` (default) disables the
+ #: check entirely, with strictly no change to accounting's numbers.
+ stop_loss_pct: float | None = None
+ take_profit_pct: float | None = None
+ #: Per-symbol calendar names, engine-injected onto the strategy
+ #: instance right before `generate_signals()`/`explain_signals()`/
+ #: `decision_signal()` are called (see
+ #: `quantlab.backtesting.engine.BacktestEngine.run`) so every rolling-
+ #: window feature call site can compute on each symbol's own native
+ #: calendar rather than a closure-padded combined timeline -- see
+ #: `quantlab.features.native_calendar.compute_native_then_align`.
+ #: Never a user-configured constructor hyperparameter: exempted from
+ #: both the post-construction freeze (`__setattr__` below) and
+ #: `parameters()` (must never appear in a config-YAML round-trip,
+ #: execution-model hash, or sweep-parameter enumeration -- it is
+ #: engine context, not a strategy parameter). `None` when the engine
+ #: has not injected it (e.g. a strategy constructed directly in a
+ #: unit test); every native-calendar call site must treat that the
+ #: same as "no mixed calendars", falling back to a single vectorized
+ #: computation.
+ symbol_calendars: dict[str, str] | None = None
+ #: Instance attributes exempted from both the freeze and `parameters()`
+ #: -- engine-injected context, never a real strategy parameter.
+ _NON_PARAMETER_ATTRIBUTES: ClassVar[frozenset[str]] = frozenset(
+ {"symbol_calendars"}
+ )
+ #: Constructor keyword(s) kept only as deprecated backward-compatible
+ #: aliases for a renamed parameter. No built-in strategy currently
+ #: registers any (defaults to empty) -- kept as generic infrastructure
+ #: for the next time a strategy parameter is renamed. A registered
+ #: alias would still be fully valid to pass, and still documented via
+ #: `ParameterDoc` -- excluded only from
+ #: `strategy_sweepable_parameter_names()`, so a sensitivity/robustness
+ #: sweep never offers BOTH an alias and its canonical name as
+ #: independent axes (which would let a sweep set the alias to a value
+ #: that conflicts with the canonical name already fixed elsewhere in
+ #: the same config).
+ deprecated_parameter_names: ClassVar[frozenset[str]] = frozenset()
@classmethod
def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]:
@@ -233,13 +366,91 @@ def generate_signals(
"""Return a ``dates x symbols`` signal matrix in ``[-1, 1]``."""
raise NotImplementedError
- @staticmethod
- def _prices(data: pd.DataFrame) -> pd.DataFrame:
- """Return a finite, positive adjusted-close matrix."""
- prices = price_matrix(data, adjusted=True)
+ def explain_signals(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> SignalReasons | None:
+ """Optionally explain WHY ``generate_signals()`` changed its output.
+
+ Must be a pure function of exactly the same ``data``/``features``
+ given to ``generate_signals()`` -- no information unavailable at
+ each row's own date (no look-ahead), and no dependency on state
+ left over from a prior call. The default implementation returns
+ ``None``, meaning no strategy-specific attribution is available;
+ callers must treat that as "not analyzed", not as "unknown"/"no
+ reason" -- the generic ``strategy_signal`` reason still applies.
+ """
+ return None
+
+ def decision_signal(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> pd.DataFrame | None:
+ """Optional diagnostic representation of the strategy's decision state.
+
+ Used ONLY for trade-reason attribution (trigger detection: "did
+ the strategy make a new decision since the last rebalance") and
+ strategic-origin tracking -- never a substitute for
+ ``generate_signals()``'s own output in sizing, allocation,
+ constraints, execution, PnL or cost calculations. Must be a pure
+ function of exactly the same ``data``/``features`` given to
+ ``generate_signals()`` (same no-look-ahead, no-leftover-state
+ contract as :meth:`explain_signals`).
+
+ The default (``None``) means ``generate_signals()``'s own output
+ is already a faithful decision proxy -- true for every built-in
+ strategy except :class:`~quantlab.strategies.pairs_trading.
+ PairsTradingStrategy`, whose raw signal mixes a discrete decision
+ state with purely mechanical rescaling (price/beta/normalization)
+ that a plain "did the signal change" comparison cannot tell
+ apart from a real new decision. Overriding this is an exception,
+ not the norm: a strategy whose signal is itself the decision
+ (the common case, including every strategy with a continuous or
+ volatility-adjusted signal, where a magnitude change legitimately
+ IS a new sizing decision) must leave this at its default.
+
+ When overridden, the returned frame must share ``generate_
+ signals()``'s own ``dates x symbols`` shape, index and columns
+ exactly, contain only finite numeric values (no NaN/Infinity --
+ raises :class:`~quantlab.exceptions.StrategyError` otherwise,
+ never silently coerced or dropped), and is never reindexed by a
+ caller to "align" a mismatched shape -- a caller that receives a
+ mismatched frame must raise, not guess an alignment that could
+ introduce a temporal offset.
+ """
+ return None
+
+ def position_groups(self) -> tuple[tuple[str, ...], ...] | None:
+ """Optional: symbol columns whose COMBINED P&L is one logical position.
+
+ Used ONLY by a stop-loss/take-profit check (see
+ :func:`quantlab.backtesting.accounting._detect_stop_loss_take_profit`)
+ to decide whether a group of columns should be force-flattened
+ together based on their combined realized return, rather than
+ each column's own return independently. The default (``None``)
+ means every symbol is its own independent group -- correct for
+ every built-in strategy except :class:`~quantlab.strategies.
+ pairs_trading.PairsTradingStrategy`, whose two legs (``symbol_a``/
+ ``symbol_b``) form one economic position that a per-leg check
+ would evaluate incorrectly (e.g. treating a leg that moves
+ against the pair's own net P&L as a standalone loss). A symbol
+ never mentioned in any returned group is still its own
+ independent group -- this need not enumerate every column.
+ """
+ return None
+
+ def _prices(self, data: pd.DataFrame) -> pd.DataFrame:
+ """Return a finite, positive price matrix at ``self.price_type``."""
+ if self.price_type not in PRICE_TYPES:
+ raise StrategyError(
+ f"Unknown price_type {self.price_type!r}; expected one of "
+ f"{sorted(PRICE_TYPES)}."
+ )
+ adjusted = self.price_type == "adjusted_close"
+ prices = price_matrix(data, adjusted=adjusted)
try:
validated = numeric_pandas(
- prices, name="adjusted-close prices", strictly_positive=True
+ prices,
+ name=f"{self.price_type.replace('_', '-')} prices",
+ strictly_positive=True,
)
except (TypeError, ValueError) as exc:
raise StrategyError(str(exc)) from exc
@@ -249,6 +460,26 @@ def _prices(data: pd.DataFrame) -> pd.DataFrame:
)
return validated.astype(float)
+ def _native_feature(
+ self,
+ prices: pd.DataFrame,
+ compute_fn: Callable[[pd.DataFrame], pd.DataFrame],
+ ) -> pd.DataFrame:
+ """Compute a rolling-window feature on each symbol's own calendar.
+
+ Uses ``self.symbol_calendars``, aligned back onto
+ ``prices.index`` -- see
+ :func:`quantlab.features.native_calendar.compute_native_then_align`.
+ ``self.symbol_calendars is None`` (not engine-injected, e.g. a
+ strategy constructed directly in a unit test) short-circuits to
+ calling ``compute_fn(prices)`` directly.
+ """
+ from quantlab.features.native_calendar import compute_native_then_align
+
+ return compute_native_then_align(
+ compute_fn, prices, self.symbol_calendars, pd.DatetimeIndex(prices.index)
+ )
+
@staticmethod
def _validate_signals(
signals: pd.DataFrame, reference: pd.DataFrame | None = None
@@ -273,13 +504,91 @@ def _validate_signals(
raise StrategyError("Finite signals must remain within [-1, 1].")
return validated.fillna(0.0).astype(float)
+ @staticmethod
+ def _validate_decision_signal(
+ decision: pd.DataFrame, reference: pd.DataFrame
+ ) -> pd.DataFrame:
+ """Validate a :meth:`decision_signal` result against its reference.
+
+ Stricter than :meth:`_validate_signals`: NaN/Infinity are always
+ an error (never silently filled), and axes must match ``reference``
+ exactly -- no reindexing, which could otherwise mask a temporal
+ misalignment (look-ahead) between the decision frame and the
+ signal it is meant to diagnose.
+ """
+ if not isinstance(decision, pd.DataFrame):
+ raise StrategyError("decision_signal() must return a pandas DataFrame.")
+ if not decision.index.equals(reference.index) or not decision.columns.equals(
+ reference.columns
+ ):
+ raise StrategyError(
+ "decision_signal() index and columns must exactly match "
+ "generate_signals()'s own output."
+ )
+ try:
+ values = decision.to_numpy(dtype=float)
+ except (TypeError, ValueError) as exc:
+ raise StrategyError(
+ "decision_signal() must contain only numeric values."
+ ) from exc
+ if not np.isfinite(values).all():
+ raise StrategyError("decision_signal() must not contain NaN or Infinity.")
+ return decision.astype(float)
+
+ @staticmethod
+ def _validate_signal_reasons(
+ detail_code: pd.DataFrame, details: pd.DataFrame, reference: pd.DataFrame
+ ) -> SignalReasons:
+ """Validate an ``explain_signals()`` result against its reference.
+
+ ``reference`` is whatever axes ``generate_signals()`` itself used
+ (its own price/signal matrix) -- both frames must match exactly,
+ the same requirement ``_validate_signals`` already enforces for
+ the numeric signal matrix.
+ """
+ normalized: dict[str, pd.DataFrame] = {}
+ for frame, name in ((detail_code, "detail_code"), (details, "details")):
+ if not isinstance(frame, pd.DataFrame):
+ raise StrategyError(f"SignalReasons.{name} must be a pandas DataFrame.")
+ if not frame.index.equals(reference.index) or not frame.columns.equals(
+ reference.columns
+ ):
+ raise StrategyError(
+ f"SignalReasons.{name} index and columns must exactly match "
+ "the price matrix."
+ )
+ # Normalize to plain object dtype with real None for missing
+ # cells -- assigning a None/str column into a DataFrame can
+ # get silently promoted to pandas' StringDtype, whose missing
+ # marker is NaN rather than None (bites even careful callers,
+ # not just naive ones), and downstream code (engine.py,
+ # trade_log.py) relies on a strict `is None` check.
+ frame = frame.astype(object).where(frame.notna(), None)
+ bad = frame.map(
+ lambda value: value is not None and not isinstance(value, str)
+ )
+ if bad.to_numpy().any():
+ raise StrategyError(f"SignalReasons.{name} values must be str or None.")
+ normalized[name] = frame
+ return SignalReasons(
+ detail_code=normalized["detail_code"], details=normalized["details"]
+ )
+
def _freeze_parameters(self) -> None:
object.__setattr__(self, "_strategy_parameters_frozen", True)
def __setattr__(self, name: str, value: object) -> None:
- """Prevent public parameter mutation after construction."""
- if getattr(self, "_strategy_parameters_frozen", False) and not name.startswith(
- "_"
+ """Prevent public parameter mutation after construction.
+
+ ``_NON_PARAMETER_ATTRIBUTES`` (e.g. ``symbol_calendars``) is
+ exempted: engine-injected context set on the instance after
+ construction, not a user-supplied hyperparameter the freeze is
+ meant to protect.
+ """
+ if (
+ getattr(self, "_strategy_parameters_frozen", False)
+ and not name.startswith("_")
+ and name not in self._NON_PARAMETER_ATTRIBUTES
):
raise AttributeError(
"Strategy parameters are immutable after construction."
@@ -289,7 +598,11 @@ def __setattr__(self, name: str, value: object) -> None:
def parameters(self) -> dict[str, Any]:
"""Return a defensive copy of public strategy parameters."""
return copy.deepcopy(
- {key: value for key, value in vars(self).items() if not key.startswith("_")}
+ {
+ key: value
+ for key, value in vars(self).items()
+ if not key.startswith("_") and key not in self._NON_PARAMETER_ATTRIBUTES
+ }
)
def __repr__(self) -> str:
diff --git a/src/quantlab/strategies/buy_and_hold.py b/src/quantlab/strategies/buy_and_hold.py
index b005ede..9c0bf5c 100644
--- a/src/quantlab/strategies/buy_and_hold.py
+++ b/src/quantlab/strategies/buy_and_hold.py
@@ -2,9 +2,19 @@
from __future__ import annotations
+from collections.abc import Mapping
+from typing import Any
+
+import numpy as np
import pandas as pd
-from quantlab.strategies.base import BaseStrategy, register_strategy
+from quantlab.features._validation import choice
+from quantlab.strategies.base import (
+ PRICE_TYPES,
+ BaseStrategy,
+ SignalReasons,
+ register_strategy,
+)
@register_strategy("buy_and_hold")
@@ -15,9 +25,20 @@ class BuyAndHoldStrategy(BaseStrategy):
for multiple assets this is not necessarily a literal buy-once portfolio.
"""
- def __init__(self) -> None:
+ def __init__(self, price_type: str = "adjusted_close") -> None:
+ values = self.validate_parameters({"price_type": price_type})
+ self.price_type = values["price_type"]
self._freeze_parameters()
+ @classmethod
+ def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]:
+ """Validate the price-type choice."""
+ values = dict(parameters)
+ values["price_type"] = choice(
+ values["price_type"], name="price_type", options=PRICE_TYPES
+ )
+ return values
+
def generate_signals(
self, data: pd.DataFrame, features: pd.DataFrame | None = None
) -> pd.DataFrame:
@@ -25,3 +46,37 @@ def generate_signals(
prices = self._prices(data)
signals = prices.notna().astype(float)
return self._validate_signals(signals, prices)
+
+ def explain_signals(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> SignalReasons:
+ """Explain each 0<->1 transition as a change in price availability.
+
+ Price availability is the ONLY thing this strategy's signal
+ depends on (see ``generate_signals``), so every transition is
+ either the first date a symbol's price becomes valid, or a later
+ date it stops being valid (a gap/delisting in the underlying
+ data).
+ """
+ prices = self._prices(data)
+ available = prices.notna()
+ previous_available = available.shift(1, fill_value=False)
+ became_available = (available & ~previous_available).to_numpy()
+ became_unavailable = (~available & previous_available).to_numpy()
+
+ detail_code = np.full(became_available.shape, None, dtype=object)
+ details = np.full(became_available.shape, None, dtype=object)
+ detail_code[became_available] = "price_became_available"
+ details[became_available] = "price became available"
+ detail_code[became_unavailable] = "price_became_unavailable"
+ details[became_unavailable] = "price became unavailable"
+
+ return self._validate_signal_reasons(
+ pd.DataFrame(
+ detail_code, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ pd.DataFrame(
+ details, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ prices,
+ )
diff --git a/src/quantlab/strategies/mean_reversion.py b/src/quantlab/strategies/mean_reversion.py
index 8ce2c4a..7d311a7 100644
--- a/src/quantlab/strategies/mean_reversion.py
+++ b/src/quantlab/strategies/mean_reversion.py
@@ -1,4 +1,25 @@
-"""Stateful mean reversion around a trailing price z-score."""
+"""Stateful mean reversion around a trailing, zero-centered indicator.
+
+Five interchangeable indicators (``indicator=``) can drive the same
+entry/exit/stop state machine: a rolling z-score (the default), a
+Bollinger-band-relative deviation (NOT the traditional [0, 1]-ranged %B --
+see ``bollinger``'s own branch in :func:`_centered_indicator`), RSI,
+distance to a moving average, or a rolling percentile rank -- see
+:data:`INDICATORS`. Only three (``zscore``, ``rsi``, ``percentile``) are
+offered as a primary choice in the dashboard/lab UI (see
+:data:`UI_INDICATORS`); ``bollinger``/``distance_ma`` remain fully valid,
+tested constructor arguments for internal/research use. Every indicator is
+first converted to a zero-centered series (negative = oversold/long
+candidate, positive = overbought/short candidate) by
+:func:`_centered_indicator`; the state machine itself
+(:func:`_walk_positions_with_reasons`) only ever compares a threshold
+against ``abs(value)`` on that centered series, so it is completely
+indicator-agnostic.
+
+``stop_threshold`` is genuinely optional: pass an explicit ``None`` to
+disable it entirely, or leave it unset to use the chosen indicator's
+default (see :data:`INDICATOR_DEFAULT_THRESHOLDS`).
+"""
from __future__ import annotations
@@ -8,105 +29,376 @@
import numpy as np
import pandas as pd
-from quantlab.features._validation import boolean, finite_real, positive_int
-from quantlab.features.mean_reversion import rolling_zscore
-from quantlab.strategies.base import BaseStrategy, register_strategy
+from quantlab.constants import EPSILON
+from quantlab.features._validation import boolean, choice, finite_real, positive_int
+from quantlab.features.mean_reversion import (
+ normalized_distance_to_mean,
+ rolling_percentile_rank,
+ rolling_zscore,
+ rsi,
+)
+from quantlab.features.native_calendar import compute_native_then_align
+from quantlab.logging_config import get_logger
+from quantlab.strategies.base import (
+ PRICE_TYPES,
+ UNSET,
+ BaseStrategy,
+ SignalReasons,
+ UnsetType,
+ register_strategy,
+ validate_risk_control_parameters,
+)
+
+logger = get_logger(__name__)
+
+#: Every indicator `MeanReversionStrategy` can drive its state machine from.
+#: `bollinger`/`distance_ma` remain fully supported here (validated,
+#: tested, computable) for internal/research use -- see `UI_INDICATORS` for
+#: the narrower set actually offered as a primary choice in the dashboard
+#: and Strategy Explorer lab.
+INDICATORS = frozenset({"zscore", "bollinger", "rsi", "distance_ma", "percentile"})
+
+#: Indicators offered as the primary choice in the dashboard sidebar and
+#: the Strategy Explorer lab's indicator selector. `bollinger` is excluded
+#: here -- it is `(price - rolling_mean) / (num_std * rolling_std)`, the
+#: SAME rolling mean/std construction as `zscore` merely rescaled by
+#: `bollinger_num_std`, so it rarely produces a meaningfully different
+#: backtest. `distance_ma` is excluded for a DIFFERENT reason: it is
+#: `(price - rolling_mean) / rolling_mean` -- normalized by the mean's own
+#: level, with no volatility term at all -- so unlike `bollinger` it is
+#: not simply a rescaled `zscore` and can diverge from it materially
+#: whenever the asset's volatility regime shifts (a fixed % move away
+#: from the mean reads as a smaller z-score in a high-volatility period
+#: than in a low-volatility one, but reads as the same `distance_ma`
+#: either way). Both stay valid, documented `INDICATORS` members and can
+#: still be selected programmatically (YAML config, Python, robustness
+#: sweeps) -- only the two main UI selectors are narrowed.
+UI_INDICATORS: tuple[str, ...] = ("zscore", "rsi", "percentile")
+
+#: Sensible (entry, exit, stop) defaults per indicator, used only when the
+#: caller leaves `entry_threshold`/`exit_threshold` unset, or leaves
+#: `stop_threshold` at its own sentinel default (see `MeanReversionStrategy
+#: .__init__`) -- each indicator's centered series has a different natural
+#: scale (a z-score's few units vs. a fractional distance vs. RSI's +/-50
+#: range), so one shared default would be meaningless for at least four of
+#: the five indicators. Public: the dashboard sidebar and the Strategy
+#: Explorer lab both read these same numbers for their own widgets'
+#: default values, rather than each hardcoding a second copy that could
+#: silently drift from this one.
+INDICATOR_DEFAULT_THRESHOLDS: dict[str, tuple[float, float, float]] = {
+ "zscore": (2.0, 0.5, 4.0),
+ "bollinger": (1.0, 0.2, 1.5),
+ "rsi": (20.0, 10.0, 45.0),
+ "distance_ma": (0.05, 0.01, 0.15),
+ "percentile": (0.45, 0.10, 0.49),
+}
+
+
+def _centered_indicator(
+ prices: pd.DataFrame,
+ indicator: str,
+ lookback_period: int,
+ bollinger_num_std: float,
+ symbol_calendars: Mapping[str, str] | None = None,
+) -> pd.DataFrame:
+ """Compute ``indicator``'s zero-centered series for the state machine.
+
+ Zero-centered: negative means below "normal" (a long/oversold
+ candidate), positive means above "normal" (a short/overbought
+ candidate) -- the same sign convention `rolling_zscore` already has,
+ so the entry/exit/stop machine below never needs to know which
+ indicator produced the series it is walking.
+
+ ``symbol_calendars`` (the strategy's own, see `BaseStrategy.symbol_
+ calendars`) routes every rolling computation through `compute_native_
+ then_align` so each symbol's window is computed on its own native
+ session dates rather than a closure-padded combined timeline --
+ `None` short-circuits to a single vectorized computation.
+ """
+ combined_index = pd.DatetimeIndex(prices.index)
+ if indicator == "zscore":
+ return compute_native_then_align(
+ lambda p: rolling_zscore(p, lookback_period),
+ prices,
+ symbol_calendars,
+ combined_index,
+ )
+ if indicator == "bollinger":
+ def _bollinger(p: pd.DataFrame) -> pd.DataFrame:
+ mean = p.rolling(lookback_period, min_periods=lookback_period).mean()
+ std = p.rolling(lookback_period, min_periods=lookback_period).std(ddof=1)
+ return (p - mean) / (bollinger_num_std * std + EPSILON)
-def _walk_positions(
+ return compute_native_then_align(
+ _bollinger, prices, symbol_calendars, combined_index
+ )
+ if indicator == "rsi":
+ return (
+ compute_native_then_align(
+ lambda p: rsi(p, lookback_period),
+ prices,
+ symbol_calendars,
+ combined_index,
+ )
+ - 50.0
+ )
+ if indicator == "distance_ma":
+ return compute_native_then_align(
+ lambda p: normalized_distance_to_mean(p, lookback_period),
+ prices,
+ symbol_calendars,
+ combined_index,
+ )
+ if indicator == "percentile":
+ return (
+ compute_native_then_align(
+ lambda p: rolling_percentile_rank(p, lookback_period),
+ prices,
+ symbol_calendars,
+ combined_index,
+ )
+ - 0.5
+ )
+ raise ValueError(f"Unknown indicator {indicator!r}.") # unreachable after choice()
+
+
+def _walk_positions_with_reasons(
z: np.ndarray,
entry: float,
exit_: float,
stop: float | None,
long_only: bool,
-) -> np.ndarray:
- """Convert z-scores into persistent positions in ``{-1, 0, 1}``."""
+) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ """Convert a centered indicator into persistent positions in ``{-1, 0, 1}``.
+
+ Also records, at every index where the state actually transitions, a
+ closed-set ``reason_detail_code`` and a human-readable ``reason_
+ details`` string naming exactly which branch below fired -- computed
+ in the SAME pass as the position itself, so ``generate_signals()``
+ and ``explain_signals()`` can never disagree about why a position
+ changed (both call this one function; neither reconstructs the other
+ separately). Indicator-agnostic: ``z`` is whatever
+ :func:`_centered_indicator` produced for the configured ``indicator``,
+ never assumed to specifically be a z-score.
+ """
positions = np.zeros_like(z, dtype=float)
+ detail_code = np.full(z.shape, None, dtype=object)
+ details = np.full(z.shape, None, dtype=object)
state = 0.0
for index, value in enumerate(z):
- if np.isnan(value) or (stop is not None and abs(value) > stop):
+ previous_state = state
+ if np.isnan(value):
+ state = 0.0
+ if previous_state != state:
+ detail_code[index] = "data_unavailable_exit"
+ details[index] = "indicator unavailable (insufficient trailing history)"
+ elif stop is not None and abs(value) > stop:
state = 0.0
+ if previous_state != state:
+ signed_stop = stop if value > 0 else -stop
+ detail_code[index] = "stop_loss_exit"
+ details[index] = (
+ f"indicator {value:.4f} breached stop threshold {signed_stop:.4f}"
+ )
elif state == 0.0:
if value < -entry:
state = 1.0
+ detail_code[index] = "oversold_entry"
+ details[index] = (
+ f"indicator {value:.4f} crossed entry threshold {-entry:.4f}"
+ )
elif value > entry and not long_only:
state = -1.0
+ detail_code[index] = "overbought_entry"
+ details[index] = (
+ f"indicator {value:.4f} crossed entry threshold {entry:.4f}"
+ )
elif (state == 1.0 and value > -exit_) or (state == -1.0 and value < exit_):
state = 0.0
+ threshold = -exit_ if previous_state == 1.0 else exit_
+ detail_code[index] = "mean_reversion_exit"
+ details[index] = (
+ f"indicator {value:.4f} crossed exit threshold {threshold:.4f}"
+ )
positions[index] = state
- return positions
+ return positions, detail_code, details
@register_strategy("mean_reversion")
class MeanReversionStrategy(BaseStrategy):
- """Trade deviations from a trailing mean until exit or stop thresholds."""
+ """Trade deviations from a chosen indicator until exit or stop thresholds."""
def __init__(
self,
lookback_period: int = 20,
- entry_zscore: float = 2.0,
- exit_zscore: float = 0.5,
- stop_zscore: float | None = 4.0,
+ indicator: str = "zscore",
+ entry_threshold: float | None = None,
+ exit_threshold: float | None = None,
+ stop_threshold: float | UnsetType | None = UNSET,
+ bollinger_num_std: float = 2.0,
long_only: bool = True,
+ price_type: str = "adjusted_close",
+ stop_loss_pct: float | None = None,
+ take_profit_pct: float | None = None,
) -> None:
values = self.validate_parameters(
{
"lookback_period": lookback_period,
- "entry_zscore": entry_zscore,
- "exit_zscore": exit_zscore,
- "stop_zscore": stop_zscore,
+ "indicator": indicator,
+ "entry_threshold": entry_threshold,
+ "exit_threshold": exit_threshold,
+ "stop_threshold": stop_threshold,
+ "bollinger_num_std": bollinger_num_std,
"long_only": long_only,
+ "price_type": price_type,
+ "stop_loss_pct": stop_loss_pct,
+ "take_profit_pct": take_profit_pct,
}
)
self.lookback_period = values["lookback_period"]
- self.entry_zscore = values["entry_zscore"]
- self.exit_zscore = values["exit_zscore"]
- self.stop_zscore = values["stop_zscore"]
+ self.indicator = values["indicator"]
+ self.entry_threshold = values["entry_threshold"]
+ self.exit_threshold = values["exit_threshold"]
+ self.stop_threshold = values["stop_threshold"]
+ self.bollinger_num_std = values["bollinger_num_std"]
self.long_only = values["long_only"]
+ self.price_type = values["price_type"]
+ self.stop_loss_pct = values["stop_loss_pct"]
+ self.take_profit_pct = values["take_profit_pct"]
self._freeze_parameters()
@classmethod
def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]:
- """Validate z-score windows, thresholds and direction mode."""
+ """Validate the indicator choice, thresholds and direction mode."""
values = dict(parameters)
values["lookback_period"] = positive_int(
values["lookback_period"], name="lookback_period", minimum=2
)
- values["entry_zscore"] = finite_real(
- values["entry_zscore"], name="entry_zscore", minimum=0.0, strict=True
+ values["indicator"] = choice(
+ values["indicator"], name="indicator", options=INDICATORS
)
- values["exit_zscore"] = finite_real(
- values["exit_zscore"], name="exit_zscore", minimum=0.0
+ values["bollinger_num_std"] = finite_real(
+ values["bollinger_num_std"],
+ name="bollinger_num_std",
+ minimum=0.0,
+ strict=True,
)
- if values["stop_zscore"] is not None:
- values["stop_zscore"] = finite_real(
- values["stop_zscore"], name="stop_zscore", minimum=0.0
- )
values["long_only"] = boolean(values["long_only"], name="long_only")
- if values["entry_zscore"] <= values["exit_zscore"]:
- raise ValueError("entry_zscore must exceed exit_zscore.")
+ values["price_type"] = choice(
+ values["price_type"], name="price_type", options=PRICE_TYPES
+ )
+ values["stop_loss_pct"], values["take_profit_pct"] = (
+ validate_risk_control_parameters(
+ values["stop_loss_pct"], values["take_profit_pct"]
+ )
+ )
+
+ entry_threshold = values["entry_threshold"]
+ exit_threshold = values["exit_threshold"]
+ stop_threshold = values["stop_threshold"]
+ default_entry, default_exit, default_stop = INDICATOR_DEFAULT_THRESHOLDS[
+ values["indicator"]
+ ]
+ if entry_threshold is None:
+ entry_threshold = default_entry
+ if exit_threshold is None:
+ exit_threshold = default_exit
+ if isinstance(stop_threshold, UnsetType):
+ # Not passed at all -> use this indicator's default stop.
+ stop_threshold = default_stop
+ # else: an explicit stop_threshold=None means "disabled" and is
+ # left as None; an explicit float is used as-is.
+
+ values["entry_threshold"] = finite_real(
+ entry_threshold, name="entry_threshold", minimum=0.0, strict=True
+ )
+ values["exit_threshold"] = finite_real(
+ exit_threshold, name="exit_threshold", minimum=0.0
+ )
+ if stop_threshold is not None:
+ values["stop_threshold"] = finite_real(
+ stop_threshold, name="stop_threshold", minimum=0.0
+ )
+ else:
+ values["stop_threshold"] = None
+ if values["entry_threshold"] <= values["exit_threshold"]:
+ raise ValueError("entry_threshold must exceed exit_threshold.")
if (
- values["stop_zscore"] is not None
- and values["stop_zscore"] <= values["entry_zscore"]
+ values["stop_threshold"] is not None
+ and values["stop_threshold"] <= values["entry_threshold"]
):
- raise ValueError("stop_zscore must exceed entry_zscore.")
+ raise ValueError("stop_threshold must exceed entry_threshold.")
return values
def generate_signals(
self, data: pd.DataFrame, features: pd.DataFrame | None = None
) -> pd.DataFrame:
- """Return the trailing z-score state for every asset."""
+ """Return the trailing state machine's position for every asset."""
prices = self._prices(data)
- zscore = rolling_zscore(prices, self.lookback_period)
+ indicator = _centered_indicator(
+ prices,
+ self.indicator,
+ self.lookback_period,
+ self.bollinger_num_std,
+ self.symbol_calendars,
+ )
signals = pd.DataFrame(
0.0, index=prices.index, columns=prices.columns, dtype=float
)
for symbol in prices.columns:
- signals[symbol] = _walk_positions(
- zscore[symbol].to_numpy(dtype=float),
- entry=self.entry_zscore,
- exit_=self.exit_zscore,
- stop=self.stop_zscore,
+ positions, _, _ = _walk_positions_with_reasons(
+ indicator[symbol].to_numpy(dtype=float),
+ entry=self.entry_threshold,
+ exit_=self.exit_threshold,
+ stop=self.stop_threshold,
long_only=self.long_only,
)
+ signals[symbol] = positions
return self._validate_signals(signals, prices)
+
+ def explain_signals(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> SignalReasons:
+ """Explain each position transition from the same indicator walk.
+
+ Recomputes the centered indicator and re-walks the identical
+ state machine used by ``generate_signals()`` (same function, same
+ parameters, same ``data``) -- a pure, deterministic recomputation,
+ never a cache of a prior call, so it can never look ahead or
+ drift from the positions actually produced.
+ """
+ prices = self._prices(data)
+ indicator = _centered_indicator(
+ prices,
+ self.indicator,
+ self.lookback_period,
+ self.bollinger_num_std,
+ self.symbol_calendars,
+ )
+ # Built as plain numpy object arrays and handed to the DataFrame
+ # constructor with an explicit dtype=object: assigning column by
+ # column (`frame[symbol] = array`) lets pandas' string-dtype
+ # inference silently promote a None/str object column to its new
+ # StringDtype, which represents "missing" as NaN instead of the
+ # None _validate_signal_reasons requires -- constructing the
+ # whole frame at once with dtype=object forced avoids that.
+ detail_code_values = np.empty(prices.shape, dtype=object)
+ details_values = np.empty(prices.shape, dtype=object)
+ for column_index, symbol in enumerate(prices.columns):
+ _, symbol_detail_code, symbol_details = _walk_positions_with_reasons(
+ indicator[symbol].to_numpy(dtype=float),
+ entry=self.entry_threshold,
+ exit_=self.exit_threshold,
+ stop=self.stop_threshold,
+ long_only=self.long_only,
+ )
+ detail_code_values[:, column_index] = symbol_detail_code
+ details_values[:, column_index] = symbol_details
+ detail_code = pd.DataFrame(
+ detail_code_values, index=prices.index, columns=prices.columns, dtype=object
+ )
+ details = pd.DataFrame(
+ details_values, index=prices.index, columns=prices.columns, dtype=object
+ )
+ return self._validate_signal_reasons(detail_code, details, prices)
diff --git a/src/quantlab/strategies/momentum.py b/src/quantlab/strategies/momentum.py
index 65ac225..5fc122b 100644
--- a/src/quantlab/strategies/momentum.py
+++ b/src/quantlab/strategies/momentum.py
@@ -8,17 +8,23 @@
import numpy as np
import pandas as pd
+from quantlab.constants import EPSILON
from quantlab.features._validation import (
boolean,
+ choice,
finite_real,
non_negative_int,
positive_int,
)
from quantlab.features.cross_sectional import select_top_bottom
-from quantlab.features.momentum import momentum
-from quantlab.features.returns import simple_returns
-from quantlab.features.volatility import realized_volatility
-from quantlab.strategies.base import BaseStrategy, register_strategy
+from quantlab.features.momentum import momentum, volatility_adjusted_momentum
+from quantlab.strategies.base import (
+ PRICE_TYPES,
+ BaseStrategy,
+ SignalReasons,
+ register_strategy,
+ validate_risk_control_parameters,
+)
_TIME_SERIES_SCALINGS = frozenset({"binary", "continuous", "volatility_adjusted"})
@@ -48,6 +54,9 @@ def __init__(
signal_scaling: str = "binary",
volatility_window: int = 63,
periods_per_year: int = 252,
+ price_type: str = "adjusted_close",
+ stop_loss_pct: float | None = None,
+ take_profit_pct: float | None = None,
) -> None:
values = self.validate_parameters(
{
@@ -57,6 +66,9 @@ def __init__(
"signal_scaling": signal_scaling,
"volatility_window": volatility_window,
"periods_per_year": periods_per_year,
+ "price_type": price_type,
+ "stop_loss_pct": stop_loss_pct,
+ "take_profit_pct": take_profit_pct,
}
)
self.lookback_period = values["lookback_period"]
@@ -65,6 +77,9 @@ def __init__(
self.signal_scaling = values["signal_scaling"]
self.volatility_window = values["volatility_window"]
self.periods_per_year = values["periods_per_year"]
+ self.price_type = values["price_type"]
+ self.stop_loss_pct = values["stop_loss_pct"]
+ self.take_profit_pct = values["take_profit_pct"]
self._freeze_parameters()
@classmethod
@@ -93,6 +108,14 @@ def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]:
values["periods_per_year"] = positive_int(
values["periods_per_year"], name="periods_per_year"
)
+ values["price_type"] = choice(
+ values["price_type"], name="price_type", options=PRICE_TYPES
+ )
+ values["stop_loss_pct"], values["take_profit_pct"] = (
+ validate_risk_control_parameters(
+ values["stop_loss_pct"], values["take_profit_pct"]
+ )
+ )
return values
def generate_signals(
@@ -100,24 +123,38 @@ def generate_signals(
) -> pd.DataFrame:
"""Return per-asset trailing-momentum signals."""
prices = self._prices(data)
- score = momentum(prices, self.lookback_period, self.skip_period)
+ score = self._native_feature(
+ prices, lambda p: momentum(p, self.lookback_period, self.skip_period)
+ )
if self.signal_scaling == "binary":
signal = pd.DataFrame(
np.sign(score), index=score.index, columns=score.columns
)
elif self.signal_scaling == "continuous":
- dispersion = score.rolling(
- self.lookback_period, min_periods=min(20, self.lookback_period)
- ).std(ddof=1)
+ dispersion = self._native_feature(
+ score,
+ lambda s: s.rolling(
+ self.lookback_period, min_periods=min(20, self.lookback_period)
+ ).std(ddof=1),
+ )
signal = (score / dispersion).clip(-1.0, 1.0)
elif self.signal_scaling == "volatility_adjusted":
- volatility = realized_volatility(
- simple_returns(prices),
- window=self.volatility_window,
- periods_per_year=self.periods_per_year,
- )
- signal = (score / volatility).clip(-1.0, 1.0)
+ # Delegates to the public helper (rather than recomputing
+ # momentum/volatility inline) so a zero-volatility window is
+ # masked to NaN -- never silently divided into +-inf, which
+ # `.clip(-1, 1)` would otherwise turn into a false +-1.0 full
+ # -conviction signal instead of the "no reliable read" it is.
+ signal = self._native_feature(
+ prices,
+ lambda p: volatility_adjusted_momentum(
+ p,
+ self.lookback_period,
+ self.skip_period,
+ self.volatility_window,
+ self.periods_per_year,
+ ),
+ ).clip(-1.0, 1.0)
else: # pragma: no cover - constructor invariant
raise RuntimeError(f"Unsupported signal scaling: {self.signal_scaling!r}.")
@@ -125,10 +162,127 @@ def generate_signals(
signal = signal.clip(lower=0.0)
return self._validate_signals(signal, prices)
+ def explain_signals(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> SignalReasons | None:
+ """Explain each transition of a ``binary``-scaled momentum signal.
+
+ Only ``signal_scaling == "binary"`` gets a specific attribution:
+ that mode is a clean, discrete ``sign(momentum_score)`` (like
+ ``trend_following``'s crossover), so "the score crossed zero" is
+ a real, nameable event. ``"continuous"``/``"volatility_adjusted"``
+ produce a continuously varying value that changes on almost every
+ rebalance date -- the generic ``"signal X -> Y since last
+ rebalance"`` text already IS the complete explanation there (the
+ magnitude itself is the story); inventing a label repeated on
+ nearly every row would be exactly the kind of generic-reason-
+ dressed-as-specific this feature exists to avoid, so this
+ deliberately returns ``None`` for those two modes.
+ """
+ if self.signal_scaling != "binary":
+ return None
+ prices = self._prices(data)
+ score = self._native_feature(
+ prices, lambda p: momentum(p, self.lookback_period, self.skip_period)
+ )
+ signal = pd.DataFrame(np.sign(score), index=score.index, columns=score.columns)
+ if self.long_only:
+ signal = signal.clip(lower=0.0)
+ final = signal.fillna(0.0).to_numpy()
+ previous = np.vstack([np.zeros((1, final.shape[1])), final[:-1]])
+ score_values = score.to_numpy()
+
+ detail_code = np.empty(final.shape, dtype=object)
+ details = np.empty(final.shape, dtype=object)
+ for row in range(final.shape[0]):
+ for col in range(final.shape[1]):
+ delta = final[row, col] - previous[row, col]
+ if abs(delta) <= EPSILON:
+ continue
+ score_value = score_values[row, col]
+ if final[row, col] > EPSILON:
+ detail_code[row, col] = "positive_momentum_entry"
+ details[row, col] = (
+ f"momentum score {score_value:.4f} turned positive"
+ )
+ elif final[row, col] < -EPSILON:
+ detail_code[row, col] = "negative_momentum_entry"
+ details[row, col] = (
+ f"momentum score {score_value:.4f} turned negative"
+ )
+ else:
+ detail_code[row, col] = "momentum_exit"
+ details[row, col] = f"momentum score {score_value:.4f} crossed zero"
+
+ return self._validate_signal_reasons(
+ pd.DataFrame(
+ detail_code, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ pd.DataFrame(
+ details, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ prices,
+ )
+
+
+_CROSS_SECTIONAL_SCALINGS = frozenset({"binary", "continuous"})
+
+
+def _cross_sectional_magnitude(
+ score: pd.DataFrame, selection: pd.DataFrame
+) -> pd.DataFrame:
+ """Per-date, per-leg rank-based magnitude, never zero for a selected asset.
+
+ Computed separately within each of the two SELECTED legs (long:
+ ``selection > 0``, short: ``selection < 0``), never across the whole
+ cross-section. For the long leg: rank ascending by score (1 = weakest
+ selected long, N = strongest), divided by the leg's own selected count
+ N, so magnitude is non-decreasing in score and always in ``(0, 1]``
+ (never exactly 0 for a selected asset, since the minimum attainable
+ rank is 1, not 0). The short leg mirrors this: rank DESCENDING by
+ score (the most negative -- the best short candidate -- gets the top
+ rank N), so magnitude is non-increasing in score. Tied scores get the
+ IDENTICAL rank -- the top of their shared tie group (pandas'
+ ``rank(method="max")``) -- so magnitude depends only on each asset's
+ own score, never on column/symbol order, and a leg with a single
+ selected asset (or every selected score tied) resolves to a magnitude
+ of exactly 1.0 for every tied member.
+ """
+ long_mask = selection.gt(0.0)
+ short_mask = selection.lt(0.0)
+ long_ranks = score.where(long_mask).rank(axis=1, method="max")
+ short_ranks = score.where(short_mask).rank(axis=1, method="max", ascending=False)
+ long_count = long_mask.sum(axis=1)
+ short_count = short_mask.sum(axis=1)
+ with np.errstate(invalid="ignore", divide="ignore"):
+ long_magnitude = long_ranks.div(long_count, axis=0)
+ short_magnitude = short_ranks.div(short_count, axis=0)
+ magnitude = pd.DataFrame(0.0, index=score.index, columns=score.columns)
+ magnitude = magnitude.where(~long_mask, long_magnitude)
+ magnitude = magnitude.where(~short_mask, short_magnitude)
+ return magnitude
+
@register_strategy("cross_sectional_momentum")
class CrossSectionalMomentumStrategy(BaseStrategy):
- """Select the strongest assets and optionally short the weakest."""
+ """Select the strongest assets and optionally short the weakest.
+
+ ``binary`` (default) gives every selected asset an identical signal
+ magnitude (+-1). ``continuous`` scales each selected asset's SIGNAL
+ magnitude by its RANK within its own selected leg (see
+ :func:`_cross_sectional_magnitude`): within the long leg, the weakest
+ selected name gets the smallest magnitude and the strongest gets the
+ full +1; within the short leg (mirrored), the least-negative selected
+ name gets the smallest magnitude and the most negative gets the full
+ -1 -- guaranteed monotone in score by construction and never zero for
+ a selected asset, while WHICH assets are selected (the top/bottom
+ fraction cutoff itself) is unchanged. This is a SIGNAL magnitude, not
+ a portfolio weight: the allocator downstream still determines the
+ actual target weights. Only the ``signal_proportional`` allocator
+ actually uses this magnitude -- the default ``equal_weight`` allocator
+ discards it (``np.sign`` of the signal), making ``continuous`` behave
+ identically to ``binary``.
+ """
def __init__(
self,
@@ -138,6 +292,9 @@ def __init__(
bottom_fraction: float = 0.25,
long_short: bool = False,
signal_scaling: str = "binary",
+ price_type: str = "adjusted_close",
+ stop_loss_pct: float | None = None,
+ take_profit_pct: float | None = None,
) -> None:
values = self.validate_parameters(
{
@@ -147,6 +304,9 @@ def __init__(
"bottom_fraction": bottom_fraction,
"long_short": long_short,
"signal_scaling": signal_scaling,
+ "price_type": price_type,
+ "stop_loss_pct": stop_loss_pct,
+ "take_profit_pct": take_profit_pct,
}
)
self.lookback_period = values["lookback_period"]
@@ -155,6 +315,9 @@ def __init__(
self.bottom_fraction = values["bottom_fraction"]
self.long_short = values["long_short"]
self.signal_scaling = values["signal_scaling"]
+ self.price_type = values["price_type"]
+ self.stop_loss_pct = values["stop_loss_pct"]
+ self.take_profit_pct = values["take_profit_pct"]
self._freeze_parameters()
@classmethod
@@ -187,7 +350,15 @@ def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]:
"long_short is enabled."
)
values["signal_scaling"] = _scaling(
- values["signal_scaling"], allowed=frozenset({"binary"})
+ values["signal_scaling"], allowed=_CROSS_SECTIONAL_SCALINGS
+ )
+ values["price_type"] = choice(
+ values["price_type"], name="price_type", options=PRICE_TYPES
+ )
+ values["stop_loss_pct"], values["take_profit_pct"] = (
+ validate_risk_control_parameters(
+ values["stop_loss_pct"], values["take_profit_pct"]
+ )
)
return values
@@ -196,9 +367,92 @@ def generate_signals(
) -> pd.DataFrame:
"""Return disjoint cross-sectional selections."""
prices = self._prices(data)
- score = momentum(prices, self.lookback_period, self.skip_period)
+ score = self._native_feature(
+ prices, lambda p: momentum(p, self.lookback_period, self.skip_period)
+ )
bottom = self.bottom_fraction if self.long_short else 0.0
selection = select_top_bottom(
score, top_fraction=self.top_fraction, bottom_fraction=bottom
)
- return self._validate_signals(selection, prices)
+ if self.signal_scaling == "continuous":
+ magnitude = _cross_sectional_magnitude(score, selection)
+ selection_values = selection.to_numpy(dtype=float)
+ scaled = np.where(
+ selection_values != 0.0,
+ selection_values * magnitude.to_numpy(dtype=float),
+ 0.0,
+ )
+ signal = pd.DataFrame(
+ scaled, index=selection.index, columns=selection.columns
+ )
+ else:
+ signal = selection
+ return self._validate_signals(signal, prices)
+
+ def explain_signals(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> SignalReasons | None:
+ """Explain each entry into/exit from the top or bottom selection.
+
+ There is no persistent membership state at all (see
+ ``select_top_bottom``'s own docstring: it recomputes the disjoint
+ top/bottom groups from scratch at every date) -- a transition is
+ simply "this symbol's selection changed since yesterday", read
+ directly off the SAME ``select_top_bottom(...)`` call
+ ``generate_signals()`` itself makes (the real function, not a
+ reimplementation, so it can never diverge). ``select_top_bottom``
+ does not expose each symbol's rank or the cutoff score, so those
+ are not included here -- only the momentum score, which is real
+ and directly available.
+
+ Only ``signal_scaling == "binary"`` gets this attribution, same
+ rationale as ``TimeSeriesMomentumStrategy``: under ``"continuous"``
+ the executed WEIGHT still varies within an unchanged selection on
+ almost every rebalance (the cross-sectional magnitude), so the
+ generic "signal X -> Y since last rebalance" text already is the
+ complete explanation there.
+ """
+ if self.signal_scaling != "binary":
+ return None
+ prices = self._prices(data)
+ score = self._native_feature(
+ prices, lambda p: momentum(p, self.lookback_period, self.skip_period)
+ )
+ bottom = self.bottom_fraction if self.long_short else 0.0
+ selection = select_top_bottom(
+ score, top_fraction=self.top_fraction, bottom_fraction=bottom
+ )
+ final = selection.fillna(0.0).to_numpy()
+ previous = np.vstack([np.zeros((1, final.shape[1])), final[:-1]])
+ score_values = score.to_numpy()
+
+ detail_code = np.empty(final.shape, dtype=object)
+ details = np.empty(final.shape, dtype=object)
+ for row in range(final.shape[0]):
+ for col in range(final.shape[1]):
+ delta = final[row, col] - previous[row, col]
+ if abs(delta) <= EPSILON:
+ continue
+ score_value = score_values[row, col]
+ if final[row, col] > EPSILON:
+ detail_code[row, col] = "entered_top_selection"
+ details[row, col] = f"momentum score {score_value:.4f}"
+ elif final[row, col] < -EPSILON:
+ detail_code[row, col] = "entered_bottom_selection"
+ details[row, col] = f"momentum score {score_value:.4f}"
+ elif previous[row, col] > EPSILON:
+ detail_code[row, col] = "left_top_selection"
+ details[row, col] = f"momentum score {score_value:.4f}"
+ else:
+ detail_code[row, col] = "left_bottom_selection"
+ details[row, col] = f"momentum score {score_value:.4f}"
+
+ return self._validate_signal_reasons(
+ pd.DataFrame(
+ detail_code, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ pd.DataFrame(
+ details, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ prices,
+ )
diff --git a/src/quantlab/strategies/pairs_trading.py b/src/quantlab/strategies/pairs_trading.py
index 969d6b1..abb6f7c 100644
--- a/src/quantlab/strategies/pairs_trading.py
+++ b/src/quantlab/strategies/pairs_trading.py
@@ -3,43 +3,78 @@
from __future__ import annotations
from collections.abc import Mapping
-from typing import Any
+from typing import Any, cast
import numpy as np
import pandas as pd
from quantlab.constants import EPSILON
+from quantlab.data.calendar import is_session_day
from quantlab.exceptions import StrategyError
from quantlab.features._validation import (
boolean,
+ choice,
finite_real,
numeric_pandas,
positive_int,
same_axes,
)
-from quantlab.features.mean_reversion import rolling_zscore
+from quantlab.features.mean_reversion import rolling_percentile_rank, rolling_zscore
+from quantlab.features.mean_reversion import rsi as _price_rsi
+from quantlab.features.pairs_diagnostics import spread as compute_spread
+from quantlab.features.stationarity import adf_test
from quantlab.logging_config import get_logger
-from quantlab.strategies.base import BaseStrategy, register_strategy
+from quantlab.strategies.base import (
+ PRICE_TYPES,
+ UNSET,
+ BaseStrategy,
+ SignalReasons,
+ UnsetType,
+ register_strategy,
+ validate_risk_control_parameters,
+)
+from quantlab.strategies.mean_reversion import INDICATOR_DEFAULT_THRESHOLDS
logger = get_logger(__name__)
+#: Indicators `PairsTradingStrategy` can drive its state machine from --
+#: mirrors mean_reversion's `UI_INDICATORS` (the 3 indicators that can
+#: meaningfully diverge), applied to the spread residual instead of a raw
+#: price. No `bollinger`/`distance_ma` here: pairs trading never offered
+#: them, and mean_reversion's own analysis (they rarely diverge from
+#: `zscore`) applies just as much to a spread.
+INDICATORS = ("zscore", "rsi", "percentile")
+
+
+def _centered_spread_indicator(
+ spread: pd.Series, indicator: str, window: int
+) -> pd.Series:
+ """Compute ``indicator``'s zero-centered series for the spread residual.
+
+ Mirrors `quantlab.strategies.mean_reversion._centered_indicator`'s
+ dispatch and sign convention (negative = below normal, positive =
+ above normal), but for a single spread Series that can legitimately be
+ zero or negative (unlike a price) -- `rsi`/`rolling_percentile_rank`
+ are called with `strictly_positive=False` for exactly that reason.
+ """
+ if indicator == "zscore":
+ return rolling_zscore(spread, window)
+ if indicator == "rsi":
+ return _price_rsi(spread, window, strictly_positive=False) - 50.0
+ if indicator == "percentile":
+ return rolling_percentile_rank(spread, window, strictly_positive=False) - 0.5
+ raise ValueError(f"Unknown indicator {indicator!r}.") # unreachable after choice()
+
def adf_pvalue(series: pd.Series) -> float | None:
- """Return an ADF p-value, or ``None`` when the test is inconclusive."""
- if not isinstance(series, pd.Series):
- raise TypeError("series must be a pandas Series.")
- validated = numeric_pandas(series, name="series")
- values = validated.dropna().to_numpy(dtype=float)
- if len(values) < 20 or np.allclose(values, values[0]):
- return None
- try:
- from statsmodels.tsa.stattools import adfuller
-
- pvalue = float(adfuller(values, autolag="AIC")[1])
- except Exception as exc: # pragma: no cover - third-party numerical failures
- logger.warning("ADF test failed: %s", exc)
- return None
- return pvalue if np.isfinite(pvalue) else None
+ """Return an ADF p-value, or ``None`` when the test is inconclusive.
+
+ Thin convenience wrapper over :func:`quantlab.features.stationarity.
+ adf_test`, kept here since the stationarity gate below only ever needs
+ the raw p-value, not the full structured result.
+ """
+ result = adf_test(series)
+ return result.pvalue if result is not None else None
def _ols_coefficients(x: np.ndarray, y: np.ndarray) -> tuple[float, float]:
@@ -111,37 +146,104 @@ def _rolling_hedge_ratio(
def _walk_pairs_positions(
- zscore: np.ndarray,
+ indicator: np.ndarray,
tradable: np.ndarray,
entry: float,
exit_: float,
stop: float | None,
) -> np.ndarray:
- """Convert spread z-scores into persistent positions in ``{-1, 0, 1}``."""
- if len(zscore) != len(tradable):
- raise ValueError("zscore and tradable must have the same length.")
- positions = np.zeros_like(zscore, dtype=float)
+ """Convert the spread's centered indicator into positions in ``{-1, 0, 1}``."""
+ positions, _, _ = _walk_pairs_positions_with_reasons(
+ indicator, tradable, entry, exit_, stop
+ )
+ return positions
+
+
+def _walk_pairs_positions_with_reasons(
+ indicator: np.ndarray,
+ tradable: np.ndarray,
+ entry: float,
+ exit_: float,
+ stop: float | None,
+ *,
+ adf_gate_enabled: bool = True,
+) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ """Convert the spread's centered indicator into positions in ``{-1, 0, 1}``.
+
+ Also records, at every index where the state actually transitions, a
+ closed-set ``reason_detail_code`` and a human-readable ``reason_
+ details`` string naming exactly which branch fired -- computed in the
+ SAME pass as the position itself (mirrors mean_reversion's
+ ``_walk_positions_with_reasons``), so ``generate_signals()`` and
+ ``explain_signals()`` can never disagree. The stationarity (ADF) gate
+ is consulted ONLY inside the ``state == 0.0`` branch, exactly as in
+ the original state machine -- it can silently block an entry but can
+ never, by itself, force an exit, so it never gets its own
+ ``reason_detail_code``; a successful entry mentions the gate as
+ context in ``reason_details`` only.
+ """
+ if len(indicator) != len(tradable):
+ raise ValueError("indicator and tradable must have the same length.")
+ positions = np.zeros_like(indicator, dtype=float)
+ detail_code = np.full(indicator.shape, None, dtype=object)
+ details = np.full(indicator.shape, None, dtype=object)
state = 0.0
- for index, value in enumerate(zscore):
- if not np.isfinite(value) or (stop is not None and abs(value) > stop):
+ for index, value in enumerate(indicator):
+ previous_state = state
+ if not np.isfinite(value):
+ state = 0.0
+ if previous_state != state:
+ detail_code[index] = "data_unavailable_exit"
+ details[index] = (
+ "spread indicator unavailable (insufficient trailing history)"
+ )
+ elif stop is not None and abs(value) > stop:
state = 0.0
- elif state == 0.0 and bool(tradable[index]):
- if value < -entry:
- state = 1.0
- elif value > entry:
- state = -1.0
+ if previous_state != state:
+ signed_stop = stop if value > 0 else -stop
+ detail_code[index] = "stop_loss_exit"
+ details[index] = (
+ f"spread indicator {value:.4f} breached stop threshold "
+ f"{signed_stop:.4f}"
+ )
+ elif state == 0.0:
+ if bool(tradable[index]):
+ gate_clause = (
+ "stationarity gate open" if adf_gate_enabled else "gate disabled"
+ )
+ if value < -entry:
+ state = 1.0
+ detail_code[index] = "spread_oversold_entry"
+ details[index] = (
+ f"spread indicator {value:.4f} crossed entry threshold "
+ f"{-entry:.4f} ({gate_clause})"
+ )
+ elif value > entry:
+ state = -1.0
+ detail_code[index] = "spread_overbought_entry"
+ details[index] = (
+ f"spread indicator {value:.4f} crossed entry threshold "
+ f"{entry:.4f} ({gate_clause})"
+ )
elif (state == 1.0 and value > -exit_) or (state == -1.0 and value < exit_):
state = 0.0
+ threshold = -exit_ if previous_state == 1.0 else exit_
+ detail_code[index] = "mean_reversion_exit"
+ details[index] = (
+ f"spread indicator {value:.4f} crossed exit threshold {threshold:.4f}"
+ )
positions[index] = state
- return positions
+ return positions, detail_code, details
@register_strategy("pairs_trading")
class PairsTradingStrategy(BaseStrategy):
"""Trade the residual of a trailing price-level regression between two assets.
- The ADF test gates new entries. Open positions still follow their z-score
- exit and stop rules, and any undefined z-score forces the pair flat.
+ The ADF test gates new entries (unless ``adf_pvalue_threshold=None``
+ disables it). Open positions still follow their own exit/stop rules
+ (on the chosen ``indicator``'s centered series of the spread), and any
+ undefined indicator value forces the pair flat.
"""
def __init__(
@@ -149,37 +251,59 @@ def __init__(
symbol_a: str,
symbol_b: str,
formation_window: int = 252,
- zscore_window: int = 63,
- entry_zscore: float = 2.0,
- exit_zscore: float = 0.5,
- stop_zscore: float | None = 4.0,
+ indicator_window: int = 63,
+ indicator: str = "zscore",
+ entry_threshold: float | None = None,
+ exit_threshold: float | None = None,
+ stop_threshold: float | UnsetType | None = UNSET,
dynamic_hedge_ratio: bool = True,
- adf_pvalue_threshold: float = 0.10,
+ adf_pvalue_threshold: float | None = 0.10,
+ price_type: str = "adjusted_close",
+ stop_loss_pct: float | None = None,
+ take_profit_pct: float | None = None,
) -> None:
values = self.validate_parameters(
{
"symbol_a": symbol_a,
"symbol_b": symbol_b,
"formation_window": formation_window,
- "zscore_window": zscore_window,
- "entry_zscore": entry_zscore,
- "exit_zscore": exit_zscore,
- "stop_zscore": stop_zscore,
+ "indicator_window": indicator_window,
+ "indicator": indicator,
+ "entry_threshold": entry_threshold,
+ "exit_threshold": exit_threshold,
+ "stop_threshold": stop_threshold,
"dynamic_hedge_ratio": dynamic_hedge_ratio,
"adf_pvalue_threshold": adf_pvalue_threshold,
+ "price_type": price_type,
+ "stop_loss_pct": stop_loss_pct,
+ "take_profit_pct": take_profit_pct,
}
)
self.symbol_a = values["symbol_a"]
self.symbol_b = values["symbol_b"]
self.formation_window = values["formation_window"]
- self.zscore_window = values["zscore_window"]
- self.entry_zscore = values["entry_zscore"]
- self.exit_zscore = values["exit_zscore"]
- self.stop_zscore = values["stop_zscore"]
+ self.indicator_window = values["indicator_window"]
+ self.indicator = values["indicator"]
+ self.entry_threshold = values["entry_threshold"]
+ self.exit_threshold = values["exit_threshold"]
+ self.stop_threshold = values["stop_threshold"]
self.dynamic_hedge_ratio = values["dynamic_hedge_ratio"]
self.adf_pvalue_threshold = values["adf_pvalue_threshold"]
+ self.price_type = values["price_type"]
+ self.stop_loss_pct = values["stop_loss_pct"]
+ self.take_profit_pct = values["take_profit_pct"]
self._freeze_parameters()
+ def position_groups(self) -> tuple[tuple[str, ...], ...] | None:
+ """The two legs form one economic position for stop-loss/take-profit.
+
+ See `BaseStrategy.position_groups()` -- a per-leg check would
+ evaluate the wrong thing (e.g. treat a hedge leg's own gain,
+ which OFFSETS the pair's real loss, as if it were an independent
+ position).
+ """
+ return ((self.symbol_a, self.symbol_b),)
+
@classmethod
def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]:
"""Validate symbols, windows, thresholds and ADF confidence."""
@@ -194,43 +318,75 @@ def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]:
values["formation_window"] = positive_int(
values["formation_window"], name="formation_window", minimum=20
)
- values["zscore_window"] = positive_int(
- values["zscore_window"], name="zscore_window", minimum=2
+ values["indicator_window"] = positive_int(
+ values["indicator_window"], name="indicator_window", minimum=2
+ )
+ values["indicator"] = choice(
+ values["indicator"], name="indicator", options=frozenset(INDICATORS)
)
- values["entry_zscore"] = finite_real(
- values["entry_zscore"], name="entry_zscore", minimum=0.0, strict=True
+ entry_threshold = values["entry_threshold"]
+ exit_threshold = values["exit_threshold"]
+ stop_threshold = values["stop_threshold"]
+ default_entry, default_exit, default_stop = INDICATOR_DEFAULT_THRESHOLDS[
+ values["indicator"]
+ ]
+ if entry_threshold is None:
+ entry_threshold = default_entry
+ if exit_threshold is None:
+ exit_threshold = default_exit
+ if isinstance(stop_threshold, UnsetType):
+ stop_threshold = default_stop
+ # else: an explicit stop_threshold=None means "disabled" and is
+ # left as None; an explicit float is used as-is.
+ values["entry_threshold"] = finite_real(
+ entry_threshold, name="entry_threshold", minimum=0.0, strict=True
)
- values["exit_zscore"] = finite_real(
- values["exit_zscore"], name="exit_zscore", minimum=0.0
+ values["exit_threshold"] = finite_real(
+ exit_threshold, name="exit_threshold", minimum=0.0
)
- if values["stop_zscore"] is not None:
- values["stop_zscore"] = finite_real(
- values["stop_zscore"], name="stop_zscore", minimum=0.0
+ if stop_threshold is not None:
+ values["stop_threshold"] = finite_real(
+ stop_threshold, name="stop_threshold", minimum=0.0
)
- if values["entry_zscore"] <= values["exit_zscore"]:
- raise ValueError("entry_zscore must exceed exit_zscore.")
+ else:
+ values["stop_threshold"] = None
+ if values["entry_threshold"] <= values["exit_threshold"]:
+ raise ValueError("entry_threshold must exceed exit_threshold.")
if (
- values["stop_zscore"] is not None
- and values["stop_zscore"] <= values["entry_zscore"]
+ values["stop_threshold"] is not None
+ and values["stop_threshold"] <= values["entry_threshold"]
):
- raise ValueError("stop_zscore must exceed entry_zscore.")
+ raise ValueError("stop_threshold must exceed entry_threshold.")
values["dynamic_hedge_ratio"] = boolean(
values["dynamic_hedge_ratio"], name="dynamic_hedge_ratio"
)
- values["adf_pvalue_threshold"] = finite_real(
- values["adf_pvalue_threshold"],
- name="adf_pvalue_threshold",
- minimum=0.0,
- strict=True,
+ if values["adf_pvalue_threshold"] is not None:
+ values["adf_pvalue_threshold"] = finite_real(
+ values["adf_pvalue_threshold"],
+ name="adf_pvalue_threshold",
+ minimum=0.0,
+ strict=True,
+ )
+ if values["adf_pvalue_threshold"] >= 1.0:
+ raise ValueError("adf_pvalue_threshold must be strictly below 1.")
+ values["price_type"] = choice(
+ values["price_type"], name="price_type", options=PRICE_TYPES
+ )
+ values["stop_loss_pct"], values["take_profit_pct"] = (
+ validate_risk_control_parameters(
+ values["stop_loss_pct"], values["take_profit_pct"]
+ )
)
- if values["adf_pvalue_threshold"] >= 1.0:
- raise ValueError("adf_pvalue_threshold must be strictly below 1.")
return values
def generate_signals(
self, data: pd.DataFrame, features: pd.DataFrame | None = None
) -> pd.DataFrame:
- """Return dollar-hedged signals for the two configured legs."""
+ """Return signals for the two configured legs.
+
+ The two legs' relative weights are scaled by the fitted hedge ratio
+ (``beta``), not necessarily dollar-neutral.
+ """
prices = self._prices(data)
for symbol in (self.symbol_a, self.symbol_b):
if symbol not in prices.columns:
@@ -238,20 +394,14 @@ def generate_signals(
f"Pairs trading needs symbol '{symbol}' in the data; "
f"available: {list(prices.columns)}."
)
- a = prices[self.symbol_a]
- b = prices[self.symbol_b]
- intercept, beta = rolling_hedge_parameters(
- a, b, self.formation_window, self.dynamic_hedge_ratio
- )
- spread = a - intercept - beta * b
- zscore = rolling_zscore(spread, self.zscore_window)
+ a, b, indicator, beta, tradable = self._native_pair_context(prices)
state = pd.Series(
_walk_pairs_positions(
- zscore.to_numpy(dtype=float),
- self._stationarity_gate(a, b),
- entry=self.entry_zscore,
- exit_=self.exit_zscore,
- stop=self.stop_zscore,
+ indicator.to_numpy(dtype=float),
+ tradable,
+ entry=self.entry_threshold,
+ exit_=self.exit_threshold,
+ stop=self.stop_threshold,
),
index=prices.index,
dtype=float,
@@ -272,40 +422,261 @@ def generate_signals(
signals.loc[:, [self.symbol_a, self.symbol_b]] = pair_signals
return self._validate_signals(signals, prices)
- def _stationarity_gate(self, a: pd.Series, b: pd.Series) -> np.ndarray:
- """Test full trailing formation residuals at bounded intervals."""
- gate = np.zeros(len(a), dtype=bool)
- last_pvalue: float | None = None
- static_coefficients: tuple[float, float] | None = None
- if not self.dynamic_hedge_ratio and len(a) >= self.formation_window:
- static_coefficients = _ols_coefficients(
- b.iloc[: self.formation_window].to_numpy(dtype=float),
- a.iloc[: self.formation_window].to_numpy(dtype=float),
+ def explain_signals(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> SignalReasons:
+ """Explain each transition of the pair's shared position state.
+
+ Recomputes the spread/indicator/ADF-gate exactly like
+ ``generate_signals()`` and re-walks the SAME state machine
+ (shared helper function) so the two can never disagree. Both
+ legs move together (one shared pair position), so they always
+ carry the same reason on the same date; every other symbol in
+ the universe stays ``None`` (this strategy never touches them).
+ """
+ prices = self._prices(data)
+ for symbol in (self.symbol_a, self.symbol_b):
+ if symbol not in prices.columns:
+ raise StrategyError(
+ f"Pairs trading needs symbol '{symbol}' in the data; "
+ f"available: {list(prices.columns)}."
+ )
+ _, _, indicator, _, tradable = self._native_pair_context(prices)
+ _, symbol_detail_code, symbol_details = _walk_pairs_positions_with_reasons(
+ indicator.to_numpy(dtype=float),
+ tradable,
+ entry=self.entry_threshold,
+ exit_=self.exit_threshold,
+ stop=self.stop_threshold,
+ adf_gate_enabled=self.adf_pvalue_threshold is not None,
+ )
+
+ detail_code = np.full(
+ (len(prices.index), len(prices.columns)), None, dtype=object
+ )
+ details = np.full((len(prices.index), len(prices.columns)), None, dtype=object)
+ a_index = prices.columns.get_loc(self.symbol_a)
+ b_index = prices.columns.get_loc(self.symbol_b)
+ detail_code[:, a_index] = symbol_detail_code
+ detail_code[:, b_index] = symbol_detail_code
+ details[:, a_index] = symbol_details
+ details[:, b_index] = symbol_details
+
+ return self._validate_signal_reasons(
+ pd.DataFrame(
+ detail_code, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ pd.DataFrame(
+ details, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ prices,
+ )
+
+ def decision_signal(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> pd.DataFrame:
+ """Expose the discrete pair state, stripped of price/beta rescaling.
+
+ ``generate_signals()``'s own output mixes the discrete decision
+ (``state`` in ``{-1, 0, 1}``, which only changes on a real
+ entry/exit/reversal) with purely mechanical rescaling from
+ ``a``/``b`` (price) and ``beta`` (hedge ratio, recomputed daily
+ when ``dynamic_hedge_ratio=True``) -- a plain "did the final
+ signal change" comparison cannot tell a real new decision apart
+ from pure drift. This recomputes ``state`` the same way
+ ``explain_signals()`` does (shared helper, same pass, never
+ diverges from ``generate_signals()``'s own internal ``state``) and
+ rebroadcasts it onto ``symbol_a``/``symbol_b`` (0 elsewhere) --
+ diagnostic only, see :meth:`BaseStrategy.decision_signal`.
+ """
+ prices = self._prices(data)
+ for symbol in (self.symbol_a, self.symbol_b):
+ if symbol not in prices.columns:
+ raise StrategyError(
+ f"Pairs trading needs symbol '{symbol}' in the data; "
+ f"available: {list(prices.columns)}."
+ )
+ _, _, indicator, _, tradable = self._native_pair_context(prices)
+ state, _, _ = _walk_pairs_positions_with_reasons(
+ indicator.to_numpy(dtype=float),
+ tradable,
+ entry=self.entry_threshold,
+ exit_=self.exit_threshold,
+ stop=self.stop_threshold,
+ )
+ decision = pd.DataFrame(
+ 0.0, index=prices.index, columns=prices.columns, dtype=float
+ )
+ decision[self.symbol_a] = state
+ decision[self.symbol_b] = state
+ return self._validate_decision_signal(decision, prices)
+
+ def _native_pair_context(
+ self, prices: pd.DataFrame
+ ) -> tuple[pd.Series, pd.Series, pd.Series, pd.Series, np.ndarray]:
+ """Return ``(a, b, indicator, beta, tradable)`` for the shared state machine.
+
+ The hedge-ratio fit, spread/indicator and periodic ADF re-check are
+ all rolling-window computations over the two legs jointly -- per
+ ``BaseStrategy.symbol_calendars``, they are computed on the
+ INTERSECTION of both legs' own native session dates (never a date
+ manufactured by one leg's own closure padding), then reindexed and
+ forward-filled onto the combined timeline, exactly like a single-
+ symbol native feature (see ``quantlab.features.native_calendar``).
+ ``tradable`` (the state machine's own entry gate) is the AND of the
+ ADF stationarity gate with "both legs open today" -- a closed leg
+ must never allow a fresh entry on a stale price for that leg, even
+ when the ADF gate alone would allow it; an already-open position's
+ exit/stop rules are unaffected (the state machine only consults
+ ``tradable`` for a NEW entry, never for an exit/stop).
+ """
+ a = prices[self.symbol_a]
+ b = prices[self.symbol_b]
+ calendars = self.symbol_calendars or {}
+ calendar_a = calendars.get(self.symbol_a)
+ calendar_b = calendars.get(self.symbol_b)
+ combined_index = pd.DatetimeIndex(prices.index)
+ open_a = (
+ is_session_day(calendar_a, combined_index)
+ if calendar_a is not None
+ else np.ones(len(prices.index), dtype=bool)
+ )
+ open_b = (
+ is_session_day(calendar_b, combined_index)
+ if calendar_b is not None
+ else np.ones(len(prices.index), dtype=bool)
+ )
+ both_open = open_a & open_b
+
+ if bool(both_open.all()):
+ intercept, beta = rolling_hedge_parameters(
+ a, b, self.formation_window, self.dynamic_hedge_ratio
+ )
+ spread = compute_spread(a, b, intercept, beta)
+ indicator = _centered_spread_indicator(
+ spread, self.indicator, self.indicator_window
)
- for position in range(self.formation_window, len(a)):
- if (position - self.formation_window) % self.zscore_window == 0:
- start = position - self.formation_window
- window = pd.concat(
- {"a": a.iloc[start:position], "b": b.iloc[start:position]},
- axis=1,
- ).dropna()
- if len(window) != self.formation_window:
+ adf_gate = self._stationarity_gate(a, b)
+ return a, b, indicator, beta, both_open & adf_gate
+
+ native_index = prices.index[both_open]
+ native_a, native_b = a.loc[native_index], b.loc[native_index]
+ intercept, beta_native = rolling_hedge_parameters(
+ native_a, native_b, self.formation_window, self.dynamic_hedge_ratio
+ )
+ spread_native = compute_spread(native_a, native_b, intercept, beta_native)
+ indicator_native = _centered_spread_indicator(
+ spread_native, self.indicator, self.indicator_window
+ )
+ adf_gate_native = self._stationarity_gate(native_a, native_b)
+
+ fillable = pd.Series(~both_open, index=prices.index)
+ beta = beta_native.reindex(prices.index)
+ beta = beta.mask(fillable & beta.isna(), beta.ffill())
+ indicator = indicator_native.reindex(prices.index)
+ indicator = indicator.mask(fillable & indicator.isna(), indicator.ffill())
+ adf_gate_series = pd.Series(adf_gate_native, index=native_index).reindex(
+ prices.index
+ )
+ adf_gate_series = adf_gate_series.mask(
+ fillable & adf_gate_series.isna(), adf_gate_series.ffill()
+ )
+ adf_gate = adf_gate_series.fillna(False).to_numpy(dtype=bool)
+ return a, b, indicator, beta, both_open & adf_gate
+
+ def _stationarity_gate(self, a: pd.Series, b: pd.Series) -> np.ndarray:
+ """Test full trailing formation residuals at bounded intervals.
+
+ Returns an all-``True`` gate (every date tradable) without running
+ any ADF test when ``adf_pvalue_threshold is None`` -- the gate is
+ disabled entirely, not merely widened.
+ """
+ if self.adf_pvalue_threshold is None:
+ return np.ones(len(a), dtype=bool)
+ pvalues = periodic_stationarity_pvalues(
+ a,
+ b,
+ formation_window=self.formation_window,
+ stride=self.indicator_window,
+ dynamic_hedge_ratio=self.dynamic_hedge_ratio,
+ )
+ values = pvalues.to_numpy(dtype=float)
+ gate = np.isfinite(values) & (values <= self.adf_pvalue_threshold)
+ return cast(np.ndarray, gate)
+
+
+def periodic_stationarity_pvalues(
+ a: pd.Series,
+ b: pd.Series,
+ *,
+ formation_window: int,
+ stride: int,
+ dynamic_hedge_ratio: bool,
+) -> pd.Series:
+ """ADF p-value of a single-window regression residual, rechecked periodically.
+
+ Recomputed every ``stride`` positions starting at ``formation_window``
+ and held constant between checkpoints (matching ``PairsTradingStrategy.
+ _stationarity_gate``'s own periodic recheck, which this function IS --
+ ``_stationarity_gate`` just thresholds it). With ``dynamic_hedge_ratio=
+ True``, each checkpoint refits (intercept, beta) on its own trailing
+ ``formation_window``-length window. With ``dynamic_hedge_ratio=False``,
+ every checkpoint instead reuses the ONE (intercept, beta) fit once on
+ the very first ``formation_window`` -- only the ADF test itself, not
+ the regression, is redone at each checkpoint. Either way this is
+ deliberately distinct from running ADF on a slice of ``spread(a, b,
+ *rolling_hedge_parameters(...))``: with ``dynamic_hedge_ratio=True``
+ that spread's hedge ratio is refit EVERY day (trailing
+ ``formation_window``-length window ending at that day), so slicing it
+ would test a residual built from a DIFFERENT regression than the
+ checkpoint-window fit the strategy's own gate actually uses. Reused
+ identically by :func:`quantlab.features.pairs_diagnostics.
+ compute_pair_diagnostics` (its ``rolling_adf_pvalue`` field) so the
+ Strategy Explorer's diagnostics agree with what the live strategy
+ gates entries on whenever the data range, symbols, price type and
+ parameters are identical.
+
+ Returns ``NaN`` before the first checkpoint (``position <
+ formation_window``) and at any checkpoint where the window has missing
+ data or the regression is numerically degenerate.
+ """
+ if not isinstance(a, pd.Series) or not isinstance(b, pd.Series):
+ raise TypeError("a and b must be pandas Series.")
+ same_axes(a, b, names=("b",))
+ window_length = positive_int(formation_window, name="formation_window", minimum=2)
+ check_stride = positive_int(stride, name="stride", minimum=1)
+ use_dynamic = boolean(dynamic_hedge_ratio, name="dynamic_hedge_ratio")
+
+ pvalue = pd.Series(np.nan, index=a.index, dtype=float)
+ last_pvalue: float | None = None
+ static_coefficients: tuple[float, float] | None = None
+ if not use_dynamic and len(a) >= window_length:
+ static_coefficients = _ols_coefficients(
+ b.iloc[:window_length].to_numpy(dtype=float),
+ a.iloc[:window_length].to_numpy(dtype=float),
+ )
+ for position in range(window_length, len(a)):
+ if (position - window_length) % check_stride == 0:
+ start = position - window_length
+ window = pd.concat(
+ {"a": a.iloc[start:position], "b": b.iloc[start:position]},
+ axis=1,
+ ).dropna()
+ if len(window) != window_length:
+ last_pvalue = None
+ else:
+ intercept, beta = (
+ _ols_coefficients(
+ window["b"].to_numpy(dtype=float),
+ window["a"].to_numpy(dtype=float),
+ )
+ if use_dynamic
+ else static_coefficients or (np.nan, np.nan)
+ )
+ if not np.isfinite(intercept) or not np.isfinite(beta):
last_pvalue = None
else:
- intercept, beta = (
- _ols_coefficients(
- window["b"].to_numpy(dtype=float),
- window["a"].to_numpy(dtype=float),
- )
- if self.dynamic_hedge_ratio
- else static_coefficients or (np.nan, np.nan)
- )
- if not np.isfinite(intercept) or not np.isfinite(beta):
- last_pvalue = None
- else:
- residual = window["a"] - intercept - beta * window["b"]
- last_pvalue = adf_pvalue(residual)
- gate[position] = (
- last_pvalue is not None and last_pvalue <= self.adf_pvalue_threshold
- )
- return gate
+ residual = window["a"] - intercept - beta * window["b"]
+ last_pvalue = adf_pvalue(residual)
+ pvalue.iloc[position] = last_pvalue if last_pvalue is not None else np.nan
+ return pvalue
diff --git a/src/quantlab/strategies/trend_following.py b/src/quantlab/strategies/trend_following.py
index 42fb992..ecfba77 100644
--- a/src/quantlab/strategies/trend_following.py
+++ b/src/quantlab/strategies/trend_following.py
@@ -5,11 +5,19 @@
from collections.abc import Mapping
from typing import Any
+import numpy as np
import pandas as pd
-from quantlab.features._validation import boolean, positive_int
-from quantlab.features.momentum import ma_crossover_signal
-from quantlab.strategies.base import BaseStrategy, register_strategy
+from quantlab.constants import EPSILON
+from quantlab.features._validation import boolean, choice, positive_int
+from quantlab.features.momentum import ma_crossover_signal, moving_average
+from quantlab.strategies.base import (
+ PRICE_TYPES,
+ BaseStrategy,
+ SignalReasons,
+ register_strategy,
+ validate_risk_control_parameters,
+)
@register_strategy("trend_following")
@@ -25,17 +33,26 @@ def __init__(
fast_window: int = 20,
slow_window: int = 100,
long_only: bool = True,
+ price_type: str = "adjusted_close",
+ stop_loss_pct: float | None = None,
+ take_profit_pct: float | None = None,
) -> None:
values = self.validate_parameters(
{
"fast_window": fast_window,
"slow_window": slow_window,
"long_only": long_only,
+ "price_type": price_type,
+ "stop_loss_pct": stop_loss_pct,
+ "take_profit_pct": take_profit_pct,
}
)
self.fast_window = values["fast_window"]
self.slow_window = values["slow_window"]
self.long_only = values["long_only"]
+ self.price_type = values["price_type"]
+ self.stop_loss_pct = values["stop_loss_pct"]
+ self.take_profit_pct = values["take_profit_pct"]
self._freeze_parameters()
@classmethod
@@ -45,8 +62,16 @@ def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]:
values["fast_window"] = positive_int(values["fast_window"], name="fast_window")
values["slow_window"] = positive_int(values["slow_window"], name="slow_window")
values["long_only"] = boolean(values["long_only"], name="long_only")
+ values["price_type"] = choice(
+ values["price_type"], name="price_type", options=PRICE_TYPES
+ )
if values["fast_window"] >= values["slow_window"]:
raise ValueError("fast_window must be smaller than slow_window.")
+ values["stop_loss_pct"], values["take_profit_pct"] = (
+ validate_risk_control_parameters(
+ values["stop_loss_pct"], values["take_profit_pct"]
+ )
+ )
return values
def generate_signals(
@@ -54,9 +79,74 @@ def generate_signals(
) -> pd.DataFrame:
"""Return trailing moving-average crossover directions."""
prices = self._prices(data)
- signal = ma_crossover_signal(
- prices, fast_window=self.fast_window, slow_window=self.slow_window
+ signal = self._native_feature(
+ prices,
+ lambda p: ma_crossover_signal(
+ p, fast_window=self.fast_window, slow_window=self.slow_window
+ ),
)
if self.long_only:
signal = signal.clip(lower=0.0)
return self._validate_signals(signal, prices)
+
+ def explain_signals(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> SignalReasons:
+ """Explain each transition as a bullish/bearish MA crossover.
+
+ The signal is memoryless -- recomputed fresh every row from the
+ CURRENT fast/slow moving averages (see ``generate_signals``), no
+ persistent state. A transition is "today's final (post-
+ ``long_only``) signal differs from yesterday's", computed on the
+ exact same clipped signal ``generate_signals()`` returns, so the
+ two can never disagree. An increase in the final signal always
+ means the fast MA crossed above the slow one (bullish); a
+ decrease always means it crossed below (bearish) -- true whether
+ the move is a fresh entry, an exit forced by ``long_only``
+ clipping, or a direct long<->short reversal, since the final
+ signal is a monotonic function of ``sign(fast_ma - slow_ma)``.
+ """
+ prices = self._prices(data)
+ fast_ma = self._native_feature(
+ prices, lambda p: moving_average(p, self.fast_window)
+ ).to_numpy()
+ slow_ma = self._native_feature(
+ prices, lambda p: moving_average(p, self.slow_window)
+ ).to_numpy()
+ signal = self._native_feature(
+ prices,
+ lambda p: ma_crossover_signal(
+ p, fast_window=self.fast_window, slow_window=self.slow_window
+ ),
+ )
+ if self.long_only:
+ signal = signal.clip(lower=0.0)
+ final = signal.fillna(0.0).to_numpy()
+ previous = np.vstack([np.zeros((1, final.shape[1])), final[:-1]])
+
+ detail_code = np.empty(final.shape, dtype=object)
+ details = np.empty(final.shape, dtype=object)
+ for row in range(final.shape[0]):
+ for col in range(final.shape[1]):
+ if final[row, col] - previous[row, col] > EPSILON:
+ detail_code[row, col] = "bullish_crossover"
+ details[row, col] = (
+ f"fast MA {fast_ma[row, col]:.4f} crossed above "
+ f"slow MA {slow_ma[row, col]:.4f}"
+ )
+ elif previous[row, col] - final[row, col] > EPSILON:
+ detail_code[row, col] = "bearish_crossover"
+ details[row, col] = (
+ f"fast MA {fast_ma[row, col]:.4f} crossed below "
+ f"slow MA {slow_ma[row, col]:.4f}"
+ )
+
+ return self._validate_signal_reasons(
+ pd.DataFrame(
+ detail_code, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ pd.DataFrame(
+ details, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ prices,
+ )
diff --git a/src/quantlab/validation/bootstrap.py b/src/quantlab/validation/bootstrap.py
index aed434e..1c36256 100644
--- a/src/quantlab/validation/bootstrap.py
+++ b/src/quantlab/validation/bootstrap.py
@@ -35,8 +35,18 @@ def __post_init__(self) -> None:
raise ValueError(f"samples is missing columns: {sorted(missing)}.")
object.__setattr__(self, "samples", self.samples.loc[:, _SAMPLE_COLUMNS].copy())
- def summary(self) -> pd.DataFrame:
- """Return the mean, dispersion and central percentile interval."""
+ def summary(self, confidence_level: float = 0.90) -> pd.DataFrame:
+ """Return the mean, dispersion and central percentile interval.
+
+ ``confidence_level`` (default 0.90) sets the width of the reported
+ ``p_lower``/``p_upper`` percentile band, e.g. 0.90 -> the 5th/95th
+ percentiles, 0.95 -> the 2.5th/97.5th.
+ """
+ level = finite_real(confidence_level, name="confidence_level")
+ if not (0.0 < level < 1.0):
+ raise ValueError("confidence_level must be strictly between 0 and 1.")
+ lower_quantile = (1.0 - level) / 2.0
+ upper_quantile = 1.0 - lower_quantile
rows = []
for column in self.samples.columns:
series = self.samples[column].dropna()
@@ -44,8 +54,8 @@ def summary(self) -> pd.DataFrame:
{
"statistic": column,
"median": float(series.median()),
- "p05": float(series.quantile(0.05)),
- "p95": float(series.quantile(0.95)),
+ "p_lower": float(series.quantile(lower_quantile)),
+ "p_upper": float(series.quantile(upper_quantile)),
"mean": float(series.mean()),
"std": float(series.std(ddof=1)) if len(series) > 1 else 0.0,
}
diff --git a/src/quantlab/validation/parameter_grid.py b/src/quantlab/validation/parameter_grid.py
index c8a3edc..b16b5df 100644
--- a/src/quantlab/validation/parameter_grid.py
+++ b/src/quantlab/validation/parameter_grid.py
@@ -76,18 +76,45 @@ def default_parameter_grid(config: ExperimentConfig) -> dict[str, list[Any]]:
grid.update(_cross_sectional_fraction_grid(config, parameters))
return grid
case "mean_reversion":
- configured_entry = float(parameters.get("entry_zscore", 2.0))
- exit_zscore = float(parameters.get("exit_zscore", 0.5))
- stop_zscore = parameters.get("stop_zscore", 4.0)
- maximum_entry = float(stop_zscore) if stop_zscore is not None else None
+ from quantlab.strategies.mean_reversion import INDICATOR_DEFAULT_THRESHOLDS
+
+ indicator = parameters.get("indicator", "zscore")
+ default_entry, default_exit, default_stop = (
+ INDICATOR_DEFAULT_THRESHOLDS.get(
+ indicator, INDICATOR_DEFAULT_THRESHOLDS["zscore"]
+ )
+ )
+ # entry_threshold/exit_threshold=None (absent, or an explicit
+ # YAML `null`) both mean "the indicator's own default" per
+ # MeanReversionStrategy.validate_parameters -- unlike
+ # stop_threshold, there is no separate UNSET/None distinction
+ # to preserve here.
+ raw_entry = parameters.get("entry_threshold")
+ configured_entry = default_entry if raw_entry is None else float(raw_entry)
+ raw_exit = parameters.get("exit_threshold")
+ exit_threshold = default_exit if raw_exit is None else float(raw_exit)
+ stop_threshold = parameters.get("stop_threshold", default_stop)
+ maximum_entry = (
+ float(stop_threshold) if stop_threshold is not None else None
+ )
+ # Candidates scaled around THIS indicator's own default (0.75x/
+ # 1x/1.25x) rather than z-score-specific literals that would
+ # be meaningless on e.g. rsi's or percentile's own scale.
return {
"lookback_period": _ordered_unique(
[10, 20, 40, int(parameters.get("lookback_period", 20))]
),
- "entry_zscore": [
+ "entry_threshold": [
value
- for value in _ordered_unique([1.5, 2.0, 2.5, configured_entry])
- if value > exit_zscore
+ for value in _ordered_unique(
+ [
+ default_entry * 0.75,
+ default_entry,
+ default_entry * 1.25,
+ configured_entry,
+ ]
+ )
+ if value > exit_threshold
and (maximum_entry is None or value < maximum_entry)
],
}
@@ -106,21 +133,45 @@ def default_parameter_grid(config: ExperimentConfig) -> dict[str, list[Any]]:
]
return {"fast_window": fast_candidates, "slow_window": slow_candidates}
case "pairs_trading":
- configured_entry = float(parameters.get("entry_zscore", 2.0))
- exit_zscore = float(parameters.get("exit_zscore", 0.5))
- stop_zscore = parameters.get("stop_zscore", 4.0)
- maximum_entry = float(stop_zscore) if stop_zscore is not None else None
+ from quantlab.strategies.mean_reversion import INDICATOR_DEFAULT_THRESHOLDS
+
+ indicator = parameters.get("indicator", "zscore")
+ default_entry, default_exit, default_stop = (
+ INDICATOR_DEFAULT_THRESHOLDS.get(
+ indicator, INDICATOR_DEFAULT_THRESHOLDS["zscore"]
+ )
+ )
+ # entry_threshold/exit_threshold=None (absent, or an explicit
+ # YAML `null`) both mean "the indicator's own default" per
+ # PairsTradingStrategy.validate_parameters -- unlike
+ # stop_threshold, there is no separate UNSET/None distinction
+ # to preserve here.
+ raw_entry = parameters.get("entry_threshold")
+ configured_entry = default_entry if raw_entry is None else float(raw_entry)
+ raw_exit = parameters.get("exit_threshold")
+ exit_threshold = default_exit if raw_exit is None else float(raw_exit)
+ stop_threshold = parameters.get("stop_threshold", default_stop)
+ maximum_entry = (
+ float(stop_threshold) if stop_threshold is not None else None
+ )
return {
"formation_window": _ordered_unique(
[126, 252, 504, int(parameters.get("formation_window", 252))]
),
- "zscore_window": _ordered_unique(
- [21, 63, 126, int(parameters.get("zscore_window", 63))]
+ "indicator_window": _ordered_unique(
+ [21, 63, 126, int(parameters.get("indicator_window", 63))]
),
- "entry_zscore": [
+ "entry_threshold": [
value
- for value in _ordered_unique([1.5, 2.0, 2.5, configured_entry])
- if value > exit_zscore
+ for value in _ordered_unique(
+ [
+ default_entry * 0.75,
+ default_entry,
+ default_entry * 1.25,
+ configured_entry,
+ ]
+ )
+ if value > exit_threshold
and (maximum_entry is None or value < maximum_entry)
],
}
diff --git a/src/quantlab/validation/parameter_sensitivity.py b/src/quantlab/validation/parameter_sensitivity.py
index e88cbe9..1dabedb 100644
--- a/src/quantlab/validation/parameter_sensitivity.py
+++ b/src/quantlab/validation/parameter_sensitivity.py
@@ -181,8 +181,8 @@ def run_walk_forward_parameter_sensitivity(
WalkForwardValidator` run (all folds, OOS reconstruction), scored on that
run's out-of-sample metrics. This keeps Walk-forward mode's sensitivity
heatmap genuinely walk-forward-derived rather than silently reusing plain
- single-backtest numbers. Train/validation/test windows and expanding
- mode come from ``base_config.validation``
+ single-backtest numbers. Train/validation/test/step windows and
+ expanding mode come from ``base_config.validation``
(:func:`~quantlab.validation.walk_forward.resolve_walk_forward_windows`).
Args:
@@ -212,7 +212,7 @@ def run_walk_forward_parameter_sensitivity(
if not isinstance(base_config, ExperimentConfig):
raise TypeError("base_config must be an ExperimentConfig.")
_validate_parameter_axes(base_config, parameter_x, values_x, parameter_y, values_y)
- train_window, validation_window, test_window = resolve_walk_forward_windows(
+ train_window, validation_window, test_window, step = resolve_walk_forward_windows(
base_config
)
expanding = base_config.validation.expanding
@@ -299,6 +299,7 @@ def _validate_cell_state(state: Any, progress: int) -> bool:
validation_window=validation_window,
test_window=test_window,
expanding=expanding,
+ step=step,
)
if wf.oos_result is None:
raise InvalidConfigurationError(
diff --git a/src/quantlab/validation/robustness.py b/src/quantlab/validation/robustness.py
index 1595794..856b7d5 100644
--- a/src/quantlab/validation/robustness.py
+++ b/src/quantlab/validation/robustness.py
@@ -4,6 +4,7 @@
import math
from collections.abc import Callable
+from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -13,7 +14,7 @@
from quantlab.backtesting.runner import run_backtest_from_config
from quantlab.config import ExperimentConfig
from quantlab.constants import SYMBOL, TIMESTAMP
-from quantlab.exceptions import QuantLabError
+from quantlab.exceptions import InvalidConfigurationError, QuantLabError
from quantlab.logging_config import get_logger
from quantlab.risk import metrics as M
from quantlab.risk._validation import (
@@ -76,6 +77,24 @@ def _failed_row(name: str, error: QuantLabError) -> dict[str, object]:
}
+def _ensure_unique_scenario_names(names: list[str]) -> None:
+ """Reject scenario names that collide once formatted for display.
+
+ Multipliers close enough together (e.g. 1.0000001 and 1.0000002) both
+ format to "x1" under ``:g`` -- distinct configured values must not
+ silently collapse onto the same row identity.
+ """
+ seen: set[str] = set()
+ for name in names:
+ if name in seen:
+ raise InvalidConfigurationError(
+ f"Two configured stress-test scenarios both format to the "
+ f"name {name!r} -- pick magnitudes that remain visibly "
+ "distinct once rounded for display."
+ )
+ seen.add(name)
+
+
_STRESS_METRIC_COLUMNS = ("total_return", "cagr", "sharpe", "max_drawdown")
@@ -165,6 +184,74 @@ def _baseline_returns_is_valid(
return False
+@dataclass(frozen=True)
+class _CostScenario:
+ """One commission- or slippage-multiplier stress scenario."""
+
+ name: str
+ kind: str # "commission" | "slippage"
+ multiplier: float
+
+
+def _cost_scenarios(config: ExperimentConfig) -> list[_CostScenario]:
+ """Return every configured commission/slippage scenario, in fixed order.
+
+ Commission scenarios first (in the configured order), then slippage.
+ """
+ settings = config.robustness.stress_test
+ return [
+ _CostScenario(f"commission x{multiplier:g}", "commission", multiplier)
+ for multiplier in settings.commission_multipliers
+ ] + [
+ _CostScenario(f"slippage x{multiplier:g}", "slippage", multiplier)
+ for multiplier in settings.slippage_multipliers
+ ]
+
+
+def _cost_scenario_config(
+ config: ExperimentConfig, scenario: _CostScenario
+) -> ExperimentConfig:
+ """Return ``config`` with exactly one cost component scaled."""
+ if scenario.kind == "commission":
+ return scale_costs(config, commission_mult=scenario.multiplier)
+ return scale_costs(config, slippage_mult=scenario.multiplier)
+
+
+def _execution_delay_scenarios(config: ExperimentConfig) -> list[tuple[str, int]]:
+ """Return ``(name, delay)`` for every configured execution-delay scenario."""
+ return [
+ (f"execution delay +{delay}", delay)
+ for delay in config.robustness.stress_test.execution_delays
+ ]
+
+
+def _best_days_removed_scenarios(config: ExperimentConfig) -> list[tuple[str, int]]:
+ """Return ``(name, n)`` for every configured best-days-removed scenario."""
+ return [
+ (f"best {n} days removed", n)
+ for n in config.robustness.stress_test.best_days_removed
+ ]
+
+
+def _reduced_universe_scenarios(config: ExperimentConfig) -> list[tuple[str, int]]:
+ """Return ``(name, count)`` for every configured reduced-universe scenario.
+
+ Every configured ``count`` gets a row: one whose universe is too small
+ to leave at least 2 tradable symbols is recorded with status="failed"
+ at run time (see ``_universe_reduction_is_feasible``), never silently
+ omitted from the table.
+ """
+ return [
+ (f"reduced universe (-{count})", count)
+ for count in config.robustness.stress_test.reduce_universe_by
+ ]
+
+
+def _universe_reduction_is_feasible(config: ExperimentConfig, count: int) -> bool:
+ """Return whether dropping ``count`` symbols leaves >=2 tradable ones."""
+ return len(config.symbols) > count + 1
+
+
def run_stress_tests(
data: pd.DataFrame,
config: ExperimentConfig,
@@ -174,14 +261,22 @@ def run_stress_tests(
) -> pd.DataFrame:
"""Re-run the experiment under cost, delay and universe perturbations.
+ Every scenario's magnitude comes from ``config.robustness.stress_test``
+ (commission/slippage multipliers, execution delays, days-removed
+ counts, universe-reduction counts) -- each a list, so more than one
+ magnitude can be evaluated per scenario type; an empty list disables
+ that scenario type entirely. See :class:`~quantlab.config.
+ StressTestSettings`.
+
Args:
data: Canonical long OHLCV frame.
config: Experiment configuration.
on_progress: Optional callback invoked as ``on_progress(done, total)``
- once before the first scenario and once after each of the
- (baseline plus) up to 6 scenarios below completes — each is a
- single backtest, so this is coarser than a fold-level signal but
- still enough to show a stalled run is actually progressing.
+ once before the first scenario and once after each scenario
+ (baseline plus every configured cost/delay/best-days/universe
+ scenario) completes — each is a single backtest, so this is
+ coarser than a fold-level signal but still enough to show a
+ stalled run is actually progressing.
checkpoint_path: Optional path to persist per-scenario progress to,
so an interrupted run resumes from its last completed scenario
instead of starting over. See ``quantlab.validation.checkpoint``.
@@ -195,11 +290,20 @@ def run_stress_tests(
periods_per_year = positive_int(config.periods_per_year, name="periods_per_year")
risk_free_rate = finite_real(config.risk_free_rate, name="risk_free_rate")
- reduced_universe = len(config.symbols) > 2
- total_scenarios = 1 + 3 + 2 + (1 if reduced_universe else 0)
+ cost_scenarios = _cost_scenarios(config)
+ delay_scenarios = _execution_delay_scenarios(config)
+ best_days_scenarios = _best_days_removed_scenarios(config)
+ universe_scenarios = _reduced_universe_scenarios(config)
+ total_scenarios = (
+ 1
+ + len(cost_scenarios)
+ + len(delay_scenarios)
+ + len(best_days_scenarios)
+ + len(universe_scenarios)
+ )
rows: list[dict[str, object]] = []
- # "best 10 days removed" needs the actual baseline returns Series later,
+ # "best N days removed" needs the actual baseline returns Series later,
# not just its already-computed metrics row, so it has to be part of the
# checkpointed state too, or resuming past "baseline" would lose it.
baseline_returns: pd.Series | None = None
@@ -207,19 +311,17 @@ def run_stress_tests(
completed_scenarios = 0
# The exact scenario name each row must have, in order -- fixed by this
- # function's own scenario sequence below, independent of `progress`
- # (which only ever names a prefix of it: a config with two symbols or
- # fewer never reaches "reduced universe", and neither does an in-progress
- # resume).
- _scenario_names_in_order = [
- "baseline",
- "commission x2",
- "commission x5",
- "slippage x2",
- "execution delay +1",
- "best 10 days removed",
- "reduced universe",
- ]
+ # function's own scenario sequence below (built from the same
+ # configured lists, so it can never drift from what actually runs),
+ # independent of `progress` (which only ever names a prefix of it).
+ _scenario_names_in_order = (
+ ["baseline"]
+ + [scenario.name for scenario in cost_scenarios]
+ + [name for name, _ in delay_scenarios]
+ + [name for name, _ in best_days_scenarios]
+ + [name for name, _ in universe_scenarios]
+ )
+ _ensure_unique_scenario_names(_scenario_names_in_order)
def _validate_stress_state(state: Any, progress: int) -> bool:
# One row per scenario, in lockstep with `progress` -- so an exact
@@ -290,85 +392,114 @@ def _checkpoint() -> None:
if on_progress is not None:
on_progress(completed_scenarios, total_scenarios)
- scenarios = {
- "commission x2": scale_costs(config, commission_mult=2.0),
- "commission x5": scale_costs(config, commission_mult=5.0),
- "slippage x2": scale_costs(config, slippage_mult=2.0),
- }
- for position, (name, scenario_config) in enumerate(scenarios.items(), start=2):
+ position = 2
+ for scenario in cost_scenarios:
if completed_scenarios < position:
- result = run_backtest_from_config(data, scenario_config)
- rows.append(
- _metrics_row(name, result.returns, periods_per_year, risk_free_rate)
- )
+ try:
+ scenario_config = _cost_scenario_config(config, scenario)
+ result = run_backtest_from_config(data, scenario_config)
+ except QuantLabError as exc:
+ logger.warning("%s scenario failed: %s", scenario.name, exc)
+ rows.append(_failed_row(scenario.name, exc))
+ else:
+ rows.append(
+ _metrics_row(
+ scenario.name, result.returns, periods_per_year, risk_free_rate
+ )
+ )
completed_scenarios += 1
_checkpoint()
if on_progress is not None:
on_progress(completed_scenarios, total_scenarios)
+ position += 1
- if completed_scenarios < 5:
- delayed = run_backtest_from_config(data, config, execution_delay=1)
- rows.append(
- _metrics_row(
- "execution delay +1",
- delayed.returns,
- periods_per_year,
- risk_free_rate,
- )
- )
- completed_scenarios += 1
- _checkpoint()
- if on_progress is not None:
- on_progress(completed_scenarios, total_scenarios)
+ for name, delay in delay_scenarios:
+ if completed_scenarios < position:
+ try:
+ delayed = run_backtest_from_config(data, config, execution_delay=delay)
+ except QuantLabError as exc:
+ logger.warning("%s scenario failed: %s", name, exc)
+ rows.append(_failed_row(name, exc))
+ else:
+ rows.append(
+ _metrics_row(
+ name, delayed.returns, periods_per_year, risk_free_rate
+ )
+ )
+ completed_scenarios += 1
+ _checkpoint()
+ if on_progress is not None:
+ on_progress(completed_scenarios, total_scenarios)
+ position += 1
- if completed_scenarios < 6:
- assert baseline_returns is not None
- rows.append(
- _metrics_row(
- "best 10 days removed",
- remove_best_days(baseline_returns, 10),
- periods_per_year,
- risk_free_rate,
- )
- )
- completed_scenarios += 1
- _checkpoint()
- if on_progress is not None:
- on_progress(completed_scenarios, total_scenarios)
+ for name, n in best_days_scenarios:
+ if completed_scenarios < position:
+ # completed_scenarios >= 1 guarantees baseline_returns is set.
+ assert baseline_returns is not None
+ try:
+ scenario_returns = remove_best_days(baseline_returns, n)
+ except QuantLabError as exc:
+ logger.warning("%s scenario failed: %s", name, exc)
+ rows.append(_failed_row(name, exc))
+ else:
+ rows.append(
+ _metrics_row(
+ name, scenario_returns, periods_per_year, risk_free_rate
+ )
+ )
+ completed_scenarios += 1
+ _checkpoint()
+ if on_progress is not None:
+ on_progress(completed_scenarios, total_scenarios)
+ position += 1
- if reduced_universe and completed_scenarios < 7:
- reduced_symbols = config.symbols[:-1]
- reduced_instruments = [
- instrument
- for instrument in config.data.instruments
- if instrument.symbol in reduced_symbols
- ]
- data_config = config.data.revalidated_copy(
- update={"instruments": reduced_instruments}
- )
- reduced_config = config.revalidated_copy(update={"data": data_config})
- required_symbols = set(reduced_symbols)
- if config.benchmark_symbol is not None:
- required_symbols.add(config.benchmark_symbol)
- subset = data[data[SYMBOL].isin(required_symbols)].reset_index(drop=True)
- try:
- result = run_backtest_from_config(subset, reduced_config)
- except QuantLabError as exc:
- logger.warning("Reduced-universe scenario failed: %s", exc)
- rows.append(_failed_row("reduced universe", exc))
- else:
- rows.append(
- _metrics_row(
- "reduced universe",
- result.returns,
- periods_per_year,
- risk_free_rate,
+ for name, count in universe_scenarios:
+ if completed_scenarios < position:
+ if not _universe_reduction_is_feasible(config, count):
+ rows.append(
+ _failed_row(
+ name,
+ QuantLabError(
+ f"Universe has only {len(config.symbols)} symbols; "
+ f"removing {count} would leave fewer than 2 tradable."
+ ),
+ )
)
- )
- completed_scenarios += 1
- _checkpoint()
- if on_progress is not None:
- on_progress(completed_scenarios, total_scenarios)
+ else:
+ try:
+ reduced_symbols = config.symbols[:-count]
+ reduced_instruments = [
+ instrument
+ for instrument in config.data.instruments
+ if instrument.symbol in reduced_symbols
+ ]
+ data_config = config.data.revalidated_copy(
+ update={"instruments": reduced_instruments}
+ )
+ reduced_config = config.revalidated_copy(
+ update={"data": data_config}
+ )
+ required_symbols = set(reduced_symbols)
+ if config.benchmark_symbol is not None:
+ required_symbols.add(config.benchmark_symbol)
+ subset = data[data[SYMBOL].isin(required_symbols)].reset_index(
+ drop=True
+ )
+ result = run_backtest_from_config(subset, reduced_config)
+ except QuantLabError as exc:
+ logger.warning("%s scenario failed: %s", name, exc)
+ rows.append(_failed_row(name, exc))
+ else:
+ rows.append(
+ _metrics_row(
+ name, result.returns, periods_per_year, risk_free_rate
+ )
+ )
+ completed_scenarios += 1
+ _checkpoint()
+ if on_progress is not None:
+ on_progress(completed_scenarios, total_scenarios)
+ position += 1
if checkpoint_path is not None:
clear_checkpoint(checkpoint_path)
@@ -403,27 +534,32 @@ def run_walk_forward_stress_tests(
) -> pd.DataFrame:
"""Re-run the whole walk-forward process under cost/delay/universe stress.
- Unlike :func:`run_stress_tests`, every scenario except "best 10 days
+ Every scenario's magnitude comes from ``config.robustness.stress_test``
+ (see :func:`run_stress_tests` and :class:`~quantlab.config.
+ StressTestSettings`) -- each a list, so more than one magnitude can be
+ evaluated per scenario type; an empty list disables that scenario type
+ entirely.
+
+ Unlike :func:`run_stress_tests`, every scenario except "best N days
removed" re-evaluates parameter selection under the scenario's
perturbation instead of a single plain backtest — so a Walk-forward
mode's Robustness numbers never silently come from a different
validation method than the one currently in effect.
- The three cost-only scenarios ("commission x2", "commission x5",
- "slippage x2") never change signals or portfolio allocation — only the
- accounting step depends on execution costs — so they share a single
- :class:`~quantlab.validation.walk_forward.WalkForwardWeightCache` built
- once from the baseline config, cheaply re-scoring each fold's cached
- candidates under the new costs via
+ Every commission/slippage scenario never changes signals or portfolio
+ allocation — only the accounting step depends on execution costs — so
+ they all share a single :class:`~quantlab.validation.walk_forward.
+ WalkForwardWeightCache` built once from the baseline config, cheaply
+ re-scoring each fold's cached candidates under the new costs via
:meth:`~quantlab.validation.walk_forward.WalkForwardValidator.
rescore_with_costs` instead of re-running signal generation and
- allocation three more times. "Execution delay +1" and "reduced
- universe" genuinely change the weights themselves (the delay shift and
- the tradable universe respectively), so they still re-execute
+ allocation once per scenario. Every execution-delay and reduced-universe
+ scenario genuinely changes the weights themselves (the delay shift and
+ the tradable universe respectively), so each still re-executes
:class:`~quantlab.validation.walk_forward.WalkForwardValidator` end to
- end. "Best 10 days removed" stays a post-hoc transform of the
- already-realised baseline OOS returns: it changes no configuration, so
- re-running walk-forward would only waste time reproducing the exact
+ end. Every "best N days removed" scenario stays a post-hoc transform of
+ the already-realised baseline OOS returns: it changes no configuration,
+ so re-running walk-forward would only waste time reproducing the exact
same selection.
Args:
@@ -431,20 +567,20 @@ def run_walk_forward_stress_tests(
config: The baseline experiment config (``validation.method`` must
be ``"walk_forward"``).
wf_baseline: The already-computed baseline ``WalkForwardResult``,
- reused for "baseline" and "best 10 days removed".
+ reused for "baseline" and every "best N days removed" scenario.
on_progress: Optional callback invoked as ``on_progress(done, total)``
once before any work starts and once after each unit of work
completes: one tick per candidate (folds x grid size) while the
- weight cache used by the three cost-only scenarios is
+ weight cache used by the commission/slippage scenarios is
(re)built — the bulk of the total cost — then one tick per
- remaining scenario ("execution delay +1" and "reduced universe"
- are each a full walk-forward run; the three cost-only rescores
- and "best 10 days removed" are
- cheap).
+ remaining scenario (each execution-delay/reduced-universe
+ scenario is a full walk-forward run; the commission/slippage
+ rescores and best-N-days-removed scenarios are cheap).
checkpoint_path: Optional path to persist progress to, so an
interrupted run resumes instead of starting over — at the
- scenario-block level (baseline / [cache-build + the 3 cost-only
- rescores] / execution-delay+1 / best-10-days / reduced-universe)
+ scenario-block level (baseline / [cache-build + every
+ commission/slippage rescore] / every execution-delay scenario /
+ every best-N-days scenario / every reduced-universe scenario)
for this function's own progress, plus a separate, nested
checkpoint for the cache-build block specifically (passed
through to :meth:`~quantlab.validation.walk_forward.
@@ -500,7 +636,9 @@ def run_walk_forward_stress_tests(
)
periods_per_year = positive_int(config.periods_per_year, name="periods_per_year")
risk_free_rate = finite_real(config.risk_free_rate, name="risk_free_rate")
- train_window, validation_window, test_window = resolve_walk_forward_windows(config)
+ train_window, validation_window, test_window, step = resolve_walk_forward_windows(
+ config
+ )
expanding = config.validation.expanding
grid = parameter_grid_for_config(config)
@@ -520,6 +658,7 @@ def run_walk_forward_stress_tests(
"train_window": train_window,
"validation_window": validation_window,
"test_window": test_window,
+ "step": step,
"expanding": expanding,
}
if baseline_windows != expected_windows:
@@ -536,18 +675,24 @@ def run_walk_forward_stress_tests(
f"its own recorded walk_forward_parameter_grid {baseline_grid!r} "
f"does not match {grid!r} derived from `config`."
)
- # The "execution delay +1" scenario below is only meaningful relative to
- # a zero-delay baseline; a baseline already run with a non-zero delay
- # would need "+1" to mean "one more than the baseline's own", not a
- # hardcoded absolute 1.
- baseline_delay = wf_baseline.oos_result.metadata.get("walk_forward_execution_delay")
- if baseline_delay != 0:
- raise ValueError(
- "wf_baseline was not built with execution_delay=0: its own "
- f"recorded walk_forward_execution_delay is {baseline_delay!r}, "
- "so this call's 'execution delay +1' scenario cannot be "
- "compared against it."
+ # Every configured execution-delay scenario below is a delay relative to
+ # the baseline's own delay; a baseline already run with a non-zero delay
+ # would make "delay +N" mean "N more than the baseline's own", not a
+ # delay of exactly N. Only checked when a delay scenario is actually
+ # configured -- an unrelated non-zero baseline delay is not this
+ # function's problem when no delay scenario will ever use it.
+ delay_scenarios = _execution_delay_scenarios(config)
+ if delay_scenarios:
+ baseline_delay = wf_baseline.oos_result.metadata.get(
+ "walk_forward_execution_delay"
)
+ if baseline_delay != 0:
+ raise ValueError(
+ "wf_baseline was not built with execution_delay=0: its own "
+ f"recorded walk_forward_execution_delay is {baseline_delay!r}, "
+ "so this call's configured execution-delay scenarios cannot "
+ "be compared against it."
+ )
def _run_walk_forward(
scenario_config: ExperimentConfig,
@@ -563,20 +708,25 @@ def _run_walk_forward(
validation_window=validation_window,
test_window=test_window,
expanding=expanding,
+ step=step,
execution_delay=execution_delay,
)
- cost_scenarios = {
- "commission x2": scale_costs(config, commission_mult=2.0),
- "commission x5": scale_costs(config, commission_mult=5.0),
- "slippage x2": scale_costs(config, slippage_mult=2.0),
- }
- reduced_universe = len(config.symbols) > 2
+ cost_scenarios = _cost_scenarios(config)
+ best_days_scenarios = _best_days_removed_scenarios(config)
+ universe_scenarios = _reduced_universe_scenarios(config)
n_baseline_folds = len(wf_baseline.folds)
n_combinations = math.prod(len(values) for values in grid.values()) if grid else 1
- n_cache_units = n_baseline_folds * n_combinations
+ # No cost scenario means the weight cache below is never consulted --
+ # skip counting (and later, building) it entirely rather than paying for
+ # the second-most-expensive step of this whole function for nothing.
+ n_cache_units = (n_baseline_folds * n_combinations) if cost_scenarios else 0
total_units = (
- n_cache_units + len(cost_scenarios) + 2 + (1 if reduced_universe else 0)
+ n_cache_units
+ + len(cost_scenarios)
+ + len(delay_scenarios)
+ + len(best_days_scenarios)
+ + len(universe_scenarios)
)
def _cache_progress(done: int, _total: int) -> None:
@@ -584,48 +734,49 @@ def _cache_progress(done: int, _total: int) -> None:
on_progress(done, total_units)
# Scenario-block-level checkpoint (own file): baseline / [cache-build +
- # the 3 cost-only rescores, as one block] / execution-delay+1 /
- # best-10-days / reduced-universe. `rows` is the only state that needs
- # to survive between blocks — every block computes from `config`/`data`/
- # `wf_baseline`, already covered by `provenance`, not from a prior
- # block's output.
+ # every commission/slippage rescore, as one block] / every
+ # execution-delay scenario / every best-N-days scenario / every
+ # reduced-universe scenario -- always exactly 5 blocks, even when a
+ # scenario type's own list is empty (that block then simply appends no
+ # rows). `rows` is the only state that needs to survive between blocks
+ # — every block computes from `config`/`data`/`wf_baseline`, already
+ # covered by `provenance`, not from a prior block's output.
rows: list[dict[str, object]] = []
provenance: dict[str, Any] | None = None
cache_checkpoint_path: Path | None = None
completed_blocks = 0
- total_blocks = 4 + (1 if reduced_universe else 0)
+ total_blocks = 5
+ _block_row_counts = [
+ 1,
+ len(cost_scenarios),
+ len(delay_scenarios),
+ len(best_days_scenarios),
+ len(universe_scenarios),
+ ]
def _expected_block_row_count(progress: int) -> int:
- """Return exactly how many rows each block count has appended.
+ """Return exactly how many rows the first ``progress`` blocks append.
- Block 1 ("baseline") appends 1 row; block 2 appends one row per
- cost-only scenario (``len(cost_scenarios)`` = 3: commission x2,
- commission x5, slippage x2); blocks 3-5 ("execution delay +1",
- "best 10 days removed", "reduced universe") each append exactly 1.
- Not a simple linear formula in ``progress`` alone, but still fully
+ Not a simple linear formula in ``progress`` alone (each block can
+ append a different, configured number of rows), but still fully
determined by it -- there is no ambiguity to fall back to a mere
upper bound for.
"""
- if progress <= 0:
- return 0
- if progress == 1:
- return 1
- if progress == 2:
- return 1 + len(cost_scenarios)
- return 1 + len(cost_scenarios) + (progress - 2)
+ return sum(_block_row_counts[:progress])
# The exact scenario name each row must have, in order -- not just how
# many rows there should be. Fixed by the block structure above:
- # baseline, then the 3 cost-only rescores (in `cost_scenarios`' own
- # order), then execution delay, best-10-days, and (if applicable)
- # reduced universe.
- _scenario_names_in_order = [
- "baseline",
- *cost_scenarios.keys(),
- "execution delay +1",
- "best 10 days removed",
- "reduced universe",
- ]
+ # baseline, then every commission/slippage rescore (in `cost_scenarios`'
+ # own order), then every execution-delay, best-N-days and
+ # reduced-universe scenario.
+ _scenario_names_in_order = (
+ ["baseline"]
+ + [scenario.name for scenario in cost_scenarios]
+ + [name for name, _ in delay_scenarios]
+ + [name for name, _ in best_days_scenarios]
+ + [name for name, _ in universe_scenarios]
+ )
+ _ensure_unique_scenario_names(_scenario_names_in_order)
def _validate_block_state(state: Any, progress: int) -> bool:
# A structurally-plausible-but-incoherent checkpoint (e.g.
@@ -680,7 +831,12 @@ def _validate_block_state(state: Any, progress: int) -> bool:
completed_units = 0
if completed_blocks >= 2:
completed_units += n_cache_units + len(cost_scenarios)
- completed_units += max(0, completed_blocks - 2)
+ if completed_blocks >= 3:
+ completed_units += len(delay_scenarios)
+ if completed_blocks >= 4:
+ completed_units += len(best_days_scenarios)
+ if completed_blocks >= 5:
+ completed_units += len(universe_scenarios)
if on_progress is not None and completed_blocks >= 2:
# Otherwise block 2 is about to run (fresh or resumed) and
# run_with_weight_cache() below already emits its own accurate
@@ -707,43 +863,60 @@ def _checkpoint_block() -> None:
_checkpoint_block()
if completed_blocks < 2:
- # Signals and portfolio allocation never depend on execution costs,
- # so build the per-candidate weight cache once (this is where the
- # candidate-level progress reported above comes from — finer-grained
- # than one tick per fold, since this build does roughly twice the
- # work of a plain walk-forward fold) and reuse it for every
- # cost-only scenario below instead of paying for a full re-run each.
- # This block gets its own nested checkpoint (see the docstring):
- # it's the expensive one, and the whole point of the weight-cache
- # optimisation is not having to redo it.
- validator = WalkForwardValidator(config)
- _, weight_cache = validator.run_with_weight_cache(
- data,
- parameter_grid=grid,
- train_window=train_window,
- validation_window=validation_window,
- test_window=test_window,
- expanding=expanding,
- execution_delay=0,
- on_progress=_cache_progress,
- checkpoint_path=cache_checkpoint_path,
- )
- # No separate on_progress call needed here: run_with_weight_cache's
- # own ticks (via _cache_progress) already reached completed_units ==
- # n_cache_units by the time it returns.
- completed_units = n_cache_units
-
- for name, scenario_config in cost_scenarios.items():
- wf = validator.rescore_with_costs(weight_cache, scenario_config)
- assert wf.oos_result is not None # same data/windows as the baseline
- rows.append(
- _metrics_row(
- name, wf.oos_result.returns, periods_per_year, risk_free_rate
- )
+ if cost_scenarios:
+ # Signals and portfolio allocation never depend on execution
+ # costs, so build the per-candidate weight cache once (this is
+ # where the candidate-level progress reported above comes from —
+ # finer-grained than one tick per fold, since this build does
+ # roughly twice the work of a plain walk-forward fold) and reuse
+ # it for every cost-only scenario below instead of paying for a
+ # full re-run each. This block gets its own nested checkpoint
+ # (see the docstring): it's the expensive one, and the whole
+ # point of the weight-cache optimisation is not having to redo
+ # it. Skipped entirely when there is no cost scenario to use it
+ # for -- building it would waste this function's second-most
+ # expensive step on nothing.
+ validator = WalkForwardValidator(config)
+ _, weight_cache = validator.run_with_weight_cache(
+ data,
+ parameter_grid=grid,
+ train_window=train_window,
+ validation_window=validation_window,
+ test_window=test_window,
+ expanding=expanding,
+ step=step,
+ execution_delay=0,
+ on_progress=_cache_progress,
+ checkpoint_path=cache_checkpoint_path,
)
- completed_units += 1
- if on_progress is not None:
- on_progress(completed_units, total_units)
+ # No separate on_progress call needed here: run_with_weight_
+ # cache's own ticks (via _cache_progress) already reached
+ # completed_units == n_cache_units by the time it returns.
+ completed_units = n_cache_units
+
+ for scenario in cost_scenarios:
+ scenario_config = _cost_scenario_config(config, scenario)
+ wf = validator.rescore_with_costs(weight_cache, scenario_config)
+ if wf.oos_result is None:
+ # Rescoring reuses the baseline's own already-fitted
+ # folds/windows, so this should be unreachable -- a hard
+ # invariant violation, not an expected external failure
+ # to report as a "failed" row.
+ raise QuantLabError(
+ f"{scenario.name}: rescore_with_costs produced no OOS "
+ "result despite reusing the baseline's own windows."
+ )
+ rows.append(
+ _metrics_row(
+ scenario.name,
+ wf.oos_result.returns,
+ periods_per_year,
+ risk_free_rate,
+ )
+ )
+ completed_units += 1
+ if on_progress is not None:
+ on_progress(completed_units, total_units)
completed_blocks += 1
if cache_checkpoint_path is not None:
clear_checkpoint(cache_checkpoint_path)
@@ -752,76 +925,106 @@ def _checkpoint_block() -> None:
# seeded correctly above, nothing left to recompute for this block.
if completed_blocks < 3:
- delayed = _run_walk_forward(config, data, grid, execution_delay=1)
- assert delayed.oos_result is not None
- rows.append(
- _metrics_row(
- "execution delay +1",
- delayed.oos_result.returns,
- periods_per_year,
- risk_free_rate,
- )
- )
- completed_units += 1
+ for name, delay in delay_scenarios:
+ try:
+ delayed = _run_walk_forward(config, data, grid, execution_delay=delay)
+ if delayed.oos_result is None:
+ raise QuantLabError(
+ f"{name}: no walk-forward fold fit under this delay."
+ )
+ except QuantLabError as exc:
+ logger.warning("%s scenario failed: %s", name, exc)
+ rows.append(_failed_row(name, exc))
+ else:
+ rows.append(
+ _metrics_row(
+ name,
+ delayed.oos_result.returns,
+ periods_per_year,
+ risk_free_rate,
+ )
+ )
+ completed_units += 1
+ if on_progress is not None:
+ on_progress(completed_units, total_units)
completed_blocks += 1
_checkpoint_block()
- if on_progress is not None:
- on_progress(completed_units, total_units)
if completed_blocks < 4:
- rows.append(
- _metrics_row(
- "best 10 days removed",
- remove_best_days(wf_baseline.oos_result.returns, 10),
- periods_per_year,
- risk_free_rate,
- )
- )
- completed_units += 1
+ for name, n in best_days_scenarios:
+ try:
+ scenario_returns = remove_best_days(wf_baseline.oos_result.returns, n)
+ except QuantLabError as exc:
+ logger.warning("%s scenario failed: %s", name, exc)
+ rows.append(_failed_row(name, exc))
+ else:
+ rows.append(
+ _metrics_row(
+ name, scenario_returns, periods_per_year, risk_free_rate
+ )
+ )
+ completed_units += 1
+ if on_progress is not None:
+ on_progress(completed_units, total_units)
completed_blocks += 1
_checkpoint_block()
- if on_progress is not None:
- on_progress(completed_units, total_units)
-
- if reduced_universe and completed_blocks < 5:
- reduced_symbols = config.symbols[:-1]
- reduced_instruments = [
- instrument
- for instrument in config.data.instruments
- if instrument.symbol in reduced_symbols
- ]
- data_config = config.data.revalidated_copy(
- update={"instruments": reduced_instruments}
- )
- reduced_config = config.revalidated_copy(update={"data": data_config})
- required_symbols = set(reduced_symbols)
- if config.benchmark_symbol is not None:
- required_symbols.add(config.benchmark_symbol)
- subset = data[data[SYMBOL].isin(required_symbols)].reset_index(drop=True)
- try:
- reduced_grid = parameter_grid_for_config(reduced_config)
- wf_reduced = _run_walk_forward(reduced_config, subset, reduced_grid)
- if wf_reduced.oos_result is None:
- raise QuantLabError(
- "No walk-forward fold fit the reduced universe's history."
+
+ if completed_blocks < 5:
+ for name, count in universe_scenarios:
+ if not _universe_reduction_is_feasible(config, count):
+ rows.append(
+ _failed_row(
+ name,
+ QuantLabError(
+ f"Universe has only {len(config.symbols)} symbols; "
+ f"removing {count} would leave fewer than 2 tradable."
+ ),
+ )
)
- except QuantLabError as exc:
- logger.warning("Reduced-universe walk-forward scenario failed: %s", exc)
- rows.append(_failed_row("reduced universe", exc))
- else:
- rows.append(
- _metrics_row(
- "reduced universe",
- wf_reduced.oos_result.returns,
- periods_per_year,
- risk_free_rate,
+ completed_units += 1
+ if on_progress is not None:
+ on_progress(completed_units, total_units)
+ continue
+ reduced_symbols = config.symbols[:-count]
+ reduced_instruments = [
+ instrument
+ for instrument in config.data.instruments
+ if instrument.symbol in reduced_symbols
+ ]
+ try:
+ data_config = config.data.revalidated_copy(
+ update={"instruments": reduced_instruments}
)
- )
- completed_units += 1
+ reduced_config = config.revalidated_copy(update={"data": data_config})
+ required_symbols = set(reduced_symbols)
+ if config.benchmark_symbol is not None:
+ required_symbols.add(config.benchmark_symbol)
+ subset = data[data[SYMBOL].isin(required_symbols)].reset_index(
+ drop=True
+ )
+ reduced_grid = parameter_grid_for_config(reduced_config)
+ wf_reduced = _run_walk_forward(reduced_config, subset, reduced_grid)
+ if wf_reduced.oos_result is None:
+ raise QuantLabError(
+ "No walk-forward fold fit the reduced universe's history."
+ )
+ except QuantLabError as exc:
+ logger.warning("Reduced-universe walk-forward scenario failed: %s", exc)
+ rows.append(_failed_row(name, exc))
+ else:
+ rows.append(
+ _metrics_row(
+ name,
+ wf_reduced.oos_result.returns,
+ periods_per_year,
+ risk_free_rate,
+ )
+ )
+ completed_units += 1
+ if on_progress is not None:
+ on_progress(completed_units, total_units)
completed_blocks += 1
_checkpoint_block()
- if on_progress is not None:
- on_progress(completed_units, total_units)
if checkpoint_path is not None:
clear_checkpoint(checkpoint_path)
diff --git a/src/quantlab/validation/splits.py b/src/quantlab/validation/splits.py
index 5a1e9e5..1c04540 100644
--- a/src/quantlab/validation/splits.py
+++ b/src/quantlab/validation/splits.py
@@ -87,15 +87,30 @@ def walk_forward_windows(
test_window: int,
*,
expanding: bool = True,
+ step: int | None = None,
) -> list[WalkForwardWindow]:
"""Generate contiguous rolling or expanding walk-forward folds.
- Test blocks advance by ``test_window`` and therefore never overlap.
+ Each fold's train window starts ``step`` periods after the previous
+ fold's (default: ``step = test_window``, the original "test blocks
+ advance by test_window and never overlap" behaviour). A ``step``
+ smaller than ``test_window`` makes consecutive test blocks overlap
+ (denser evaluation). ``step`` must not exceed ``test_window``: a larger
+ step would skip observations between folds, leaving the stitched OOS
+ curve with gaps that CAGR/annualisation (which assume regularly spaced
+ observations) would silently misread as a shorter elapsed time.
"""
_validate_datetime_index(index)
train_window = _validate_window(train_window, name="train_window")
validation_window = _validate_window(validation_window, name="validation_window")
test_window = _validate_window(test_window, name="test_window")
+ step = test_window if step is None else _validate_window(step, name="step")
+ if step > test_window:
+ raise InvalidConfigurationError(
+ f"step ({step}) must not exceed test_window ({test_window}) -- a "
+ "larger step would skip observations between folds, which "
+ "CAGR/annualisation cannot account for."
+ )
if not isinstance(expanding, (bool, np.bool_)):
raise InvalidConfigurationError("expanding must be a boolean.")
expanding = bool(expanding)
@@ -120,7 +135,7 @@ def walk_forward_windows(
)
)
fold += 1
- start += test_window
+ start += step
return folds
diff --git a/src/quantlab/validation/walk_forward.py b/src/quantlab/validation/walk_forward.py
index 153f2a1..aeaba4a 100644
--- a/src/quantlab/validation/walk_forward.py
+++ b/src/quantlab/validation/walk_forward.py
@@ -36,7 +36,7 @@
run_backtest_from_config,
)
from quantlab.backtesting.trade_log import build_trade_log
-from quantlab.config import BenchmarkKind, ExperimentConfig
+from quantlab.config import BenchmarkKind, ExperimentConfig, RebalanceFrequency
from quantlab.constants import SYMBOL
from quantlab.data.base import price_matrix
from quantlab.data.calendar import uniform_calendar
@@ -44,6 +44,7 @@
from quantlab.data.storage import ParquetStorage
from quantlab.exceptions import InvalidConfigurationError
from quantlab.execution.execution_model import ExecutionModel
+from quantlab.execution.orders import executed_weights as compute_executed_weights
from quantlab.execution.orders import shift_respecting_tradability
from quantlab.logging_config import get_logger
from quantlab.portfolio.rebalancing import (
@@ -257,6 +258,7 @@ class WalkForwardWeightCache:
train_window: int
validation_window: int
test_window: int
+ step: int
expanding: bool
execution_delay: int
#: The config this cache's weights/candidates were actually computed
@@ -268,16 +270,22 @@ class WalkForwardWeightCache:
base_config: ExperimentConfig
-def resolve_walk_forward_windows(config: ExperimentConfig) -> tuple[int, int, int]:
- """Resolve train/validation/test windows, applying the documented default.
+def resolve_walk_forward_windows(
+ config: ExperimentConfig,
+) -> tuple[int, int, int, int]:
+ """Resolve train/validation/test/step windows, applying documented defaults.
Shared by the CLI, dashboard and walk-forward-aware robustness/sensitivity
- functions so the 500/126/126 fallback lives in exactly one place.
+ functions so the 500/126/126 fallback (and ``step``'s own default of
+ "equal to the resolved test_window", reproducing the original
+ non-overlapping-folds behaviour) lives in exactly one place.
"""
+ test_window = config.validation.test_window or 126
return (
config.validation.train_window or 500,
config.validation.validation_window or 126,
- config.validation.test_window or 126,
+ test_window,
+ config.validation.step or test_window,
)
@@ -305,6 +313,7 @@ class _PreparedWalkForward:
scorer: Callable[[pd.Series, pd.Series, int, float], float]
grid: dict[str, Sequence[Any]]
delay: int
+ step: int
class WalkForwardValidator:
@@ -324,6 +333,7 @@ def run(
test_window: int,
*,
expanding: bool = True,
+ step: int | None = None,
execution_delay: int = 0,
on_progress: Callable[[int, int], None] | None = None,
checkpoint_path: Path | None = None,
@@ -340,6 +350,12 @@ def run(
test_window: Out-of-sample test periods per fold.
expanding: Grow the training window across folds instead of
sliding it.
+ step: Periods to advance between consecutive folds' train
+ windows. ``None`` (default) equals ``test_window``, so test
+ blocks are contiguous and never overlap. A smaller step
+ overlaps test blocks; ``step`` must not exceed
+ ``test_window`` (a larger step would leave gaps that
+ CAGR/annualisation cannot account for).
on_progress: Optional callback invoked as ``on_progress(done,
total)`` once before the first candidate (``done=0``, or the
resumed count if ``checkpoint_path`` supplied a partial run)
@@ -373,8 +389,10 @@ def run(
validation_window,
test_window,
expanding=expanding,
+ step=step,
execution_delay=execution_delay,
)
+ step = prepared.step
fold_windows: list[WalkForwardWindow] = []
fold_parameters: list[dict[str, Any]] = []
@@ -449,6 +467,7 @@ def _validate_fold_state(state: Any, progress: int) -> bool:
train_window=train_window,
validation_window=validation_window,
test_window=test_window,
+ step=step,
expanding=expanding,
execution_delay=execution_delay,
parameter_grid={k: list(v) for k, v in parameter_grid.items()},
@@ -522,6 +541,7 @@ def _tick() -> None:
train_window,
validation_window,
test_window,
+ step,
expanding,
started,
)
@@ -538,6 +558,7 @@ def run_with_weight_cache(
test_window: int,
*,
expanding: bool = True,
+ step: int | None = None,
execution_delay: int = 0,
on_progress: Callable[[int, int], None] | None = None,
checkpoint_path: Path | None = None,
@@ -575,8 +596,10 @@ def run_with_weight_cache(
validation_window,
test_window,
expanding=expanding,
+ step=step,
execution_delay=execution_delay,
)
+ step = prepared.step
fold_windows: list[WalkForwardWindow] = []
fold_parameters: list[dict[str, Any]] = []
@@ -650,6 +673,7 @@ def _validate_cache_fold_state(state: Any, progress: int) -> bool:
train_window=train_window,
validation_window=validation_window,
test_window=test_window,
+ step=step,
expanding=expanding,
execution_delay=execution_delay,
parameter_grid={k: list(v) for k, v in parameter_grid.items()},
@@ -740,6 +764,7 @@ def _tick() -> None:
train_window,
validation_window,
test_window,
+ step,
expanding,
started,
)
@@ -752,6 +777,7 @@ def _tick() -> None:
train_window=train_window,
validation_window=validation_window,
test_window=test_window,
+ step=step,
expanding=expanding,
execution_delay=prepared.delay,
base_config=self.base_config,
@@ -818,6 +844,10 @@ def rescore_with_costs(
) from exc
tradable = cache.data[cache.data[SYMBOL].isin(set(scenario_config.symbols))]
+ accounting_kwargs = _accounting_kwargs_for_config(scenario_config)
+ shared_calendar = uniform_calendar(
+ instrument.calendar for instrument in scenario_config.data.instruments
+ )
fold_parameters: list[dict[str, Any]] = []
fold_scores: list[float] = []
target_pieces: list[pd.DataFrame] = []
@@ -852,6 +882,14 @@ def rescore_with_costs(
execution_model,
scenario_config.initial_capital,
tradable=aligned_tradable,
+ rebalance_date=_rebalance_date_for_run_accounting(
+ candidate.validation_weights,
+ scenario_config.portfolio.rebalance_frequency,
+ shared_calendar,
+ aligned_tradable,
+ 0,
+ ),
+ **accounting_kwargs,
)
equity = M.equity_from_returns(accounting.net_returns)
score = scorer(
@@ -887,6 +925,7 @@ def rescore_with_costs(
cache.train_window,
cache.validation_window,
cache.test_window,
+ cache.step,
cache.expanding,
started,
)
@@ -901,6 +940,7 @@ def _prepare(
*,
expanding: bool,
execution_delay: int,
+ step: int | None = None,
) -> _PreparedWalkForward:
"""Validate inputs and compute what run() and run_with_weight_cache() share."""
if not isinstance(data, pd.DataFrame):
@@ -938,18 +978,31 @@ def _prepare(
"Walk-forward data is missing configured tradable symbol(s): "
f"{missing_symbols}. Refusing to validate a reduced universe."
)
+ # train_window/validation_window/test_window are likewise never
+ # pre-validated here -- walk_forward_windows() below is the single
+ # place that validates every window parameter, `step` included.
index = pd.DatetimeIndex(price_matrix(tradable, adjusted=True).index)
windows = walk_forward_windows(
- index, train_window, validation_window, test_window, expanding=expanding
+ index,
+ train_window,
+ validation_window,
+ test_window,
+ expanding=expanding,
+ step=step,
)
+ # None resolves to test_window, matching walk_forward_windows()'s own
+ # default -- read back here (rather than reconstructed independently)
+ # only after the call above has already validated it.
+ resolved_step = test_window if step is None else int(step)
if not windows:
logger.warning(
"No walk-forward windows fit in %d observations with "
- "train=%d validation=%d test=%d.",
+ "train=%d validation=%d test=%d step=%d.",
len(index),
train_window,
validation_window,
test_window,
+ resolved_step,
)
combinations = _grid_combinations(grid)
@@ -975,6 +1028,7 @@ def _prepare(
scorer=scorer,
grid=grid,
delay=delay,
+ step=resolved_step,
)
def _finalize(
@@ -993,6 +1047,7 @@ def _finalize(
train_window: int,
validation_window: int,
test_window: int,
+ step: int,
expanding: bool,
started: float,
) -> WalkForwardResult:
@@ -1008,22 +1063,49 @@ def _finalize(
# Applying rebalancing, turnover and accounting once preserves state
# and transaction costs across fold boundaries.
if target_pieces:
- all_targets = pd.concat(target_pieces).sort_index()
- if not all_targets.index.is_unique:
+ all_targets = pd.concat(target_pieces)
+ if step < test_window:
+ # An intentionally smaller step overlaps consecutive test
+ # blocks by design (see walk_forward_windows()'s docstring)
+ # -- target_pieces is in fold order, so keeping the LAST
+ # occurrence of a repeated date keeps the most recent
+ # fold's (more up-to-date training data) decision for it,
+ # done here, before sort_index() below, while "last in the
+ # concatenation" still unambiguously means "from the
+ # highest-numbered fold" (a stable post-sort tiebreak is
+ # not guaranteed).
+ all_targets = all_targets[~all_targets.index.duplicated(keep="last")]
+ elif not all_targets.index.is_unique:
duplicates = all_targets.index[all_targets.index.duplicated()].unique()
raise InvalidConfigurationError(
"Walk-forward test blocks overlap; duplicate target dates: "
f"{list(duplicates[:5])}."
)
+ all_targets = all_targets.sort_index()
tradable_mask = _tradable_mask_if_mixed_calendar(
active_config, pd.DatetimeIndex(all_targets.index)
)
shared_calendar = uniform_calendar(
instrument.calendar for instrument in active_config.data.instruments
)
+ # Mirrors engine.py's own `decision_portfolio_config`: when
+ # weight drift is enabled, `apply_weight_drift` (via
+ # run_accounting, below) is the sole place `maximum_turnover`
+ # is applied -- capping it here too would hand the drift layer
+ # an already-capped intermediate target instead of the true
+ # schedule target, letting it trade the portfolio backward
+ # toward that stale value once organic drift has carried it
+ # past it.
+ decision_portfolio_config = (
+ active_config.portfolio.revalidated_copy(
+ update={"maximum_turnover": None}
+ )
+ if active_config.portfolio.model_weight_drift
+ else active_config.portfolio
+ )
all_weights = rebalance_and_cap_turnover(
all_targets,
- active_config.portfolio,
+ decision_portfolio_config,
tradable=tradable_mask,
calendar=shared_calendar,
)
@@ -1061,6 +1143,24 @@ def _finalize(
)
aligned_starts.append(all_weights.index[execution_location])
+ for previous_window, window, previous_start, start in zip(
+ fold_windows,
+ fold_windows[1:],
+ aligned_starts,
+ aligned_starts[1:],
+ strict=False,
+ ):
+ if start <= previous_start:
+ raise InvalidConfigurationError(
+ f"Fold {window.fold}'s first execution date ({start.date()}) "
+ f"does not come after fold {previous_window.fold}'s "
+ f"({previous_start.date()}) -- a small step combined with "
+ "infrequent rebalancing collapsed these folds' reporting "
+ "windows onto each other, which would attribute zero (or "
+ "the wrong) observations to one of them; increase step or "
+ "rebalance more often."
+ )
+
prices = price_matrix(tradable, adjusted=True)
asset_returns = compute_asset_returns(prices).reindex(all_weights.index)
execution_model = build_execution_from_config(active_config, data)
@@ -1075,10 +1175,10 @@ def _finalize(
if delay > 0:
if aligned_tradable_mask is not None:
# A raw row-count shift would delay execution onto a
- # date a symbol can't actually trade on -- same bug the
- # mandatory look-ahead-barrier shift inside
- # run_accounting avoids, so the extra configured delay
- # must avoid it too.
+ # date a symbol can't actually trade on -- the mandatory
+ # look-ahead-barrier shift inside run_accounting avoids
+ # exactly this, and the extra configured delay must
+ # avoid it too.
executed_weights = shift_respecting_tradability(
executed_weights, delay, aligned_tradable_mask
).fillna(0.0)
@@ -1090,6 +1190,14 @@ def _finalize(
execution_model,
active_config.initial_capital,
tradable=aligned_tradable_mask,
+ rebalance_date=_rebalance_date_for_run_accounting(
+ all_weights,
+ active_config.portfolio.rebalance_frequency,
+ shared_calendar,
+ aligned_tradable_mask,
+ delay,
+ ),
+ **_accounting_kwargs_for_config(active_config),
)
oos_returns = accounting.net_returns
oos_equity = accounting.equity
@@ -1109,6 +1217,7 @@ def _finalize(
train_window,
validation_window,
test_window,
+ step,
expanding,
)
else:
@@ -1171,6 +1280,7 @@ def _build_oos_result(
train_window: int,
validation_window: int,
test_window: int,
+ step: int,
expanding: bool,
) -> BacktestResult:
"""Build a genuine BacktestResult from the stitched OOS series.
@@ -1184,6 +1294,16 @@ def _build_oos_result(
or a cost-scenario config from :meth:`rescore_with_costs`), so its
cost fields and metadata describe what was actually run.
"""
+ # No trigger_*/adjustment_*/position_strategy_origin_* provenance is
+ # passed here: attribution is unavailable in this aggregated
+ # walk-forward reconstruction. The stitched out-of-sample series is
+ # rebuilt from per-fold weights, not from a single engine run's
+ # diagnostic frames -- each fold reruns the pipeline independently
+ # with its own warmup/fit, and those frames do not survive the
+ # cut/restitch across folds. `build_trade_log` leaves the reason
+ # columns `None`/`NaT` whenever these kwargs are omitted (its
+ # documented legacy/no-attribution path), which is correct here, not
+ # a bug to fix.
trades = build_trade_log(
accounting.executed_weights,
accounting.weight_changes,
@@ -1271,8 +1391,27 @@ def _build_oos_result(
"train_window": train_window,
"validation_window": validation_window,
"test_window": test_window,
+ "step": step,
"expanding": expanding,
},
+ # Consolidated view for full reproducibility -- mirrors the two
+ # keys just above (never a replacement for them: other code
+ # reads those by their own exact name, see e.g.
+ # `load_previous_walk_forward_robustness`). `result.save()`
+ # later adds `signal_price_type` to this same dict.
+ "resolved_config": {
+ "walk_forward": {
+ "train_window": train_window,
+ "validation_window": validation_window,
+ "test_window": test_window,
+ "step": step,
+ "expanding": expanding,
+ "parameter_grid": dict(grid),
+ "optimization_metric": str(
+ active_config.validation.optimization_metric
+ ),
+ }
+ },
}
# Mirrored under these exact keys because
# `load_previous_walk_forward_robustness` (quantlab.backtesting.
@@ -1548,6 +1687,90 @@ def _tradable_mask_if_mixed_calendar(
return tradable_mask_for(index, config.symbols, symbol_calendars)
+def _accounting_kwargs_for_config(config: ExperimentConfig) -> dict[str, Any]:
+ """The non-``tradable`` ``run_accounting`` keyword arguments a full run would use.
+
+ Mirrors :class:`~quantlab.backtesting.engine.BacktestEngine`'s own call
+ exactly (including its ``maximum_gross_exposure``/``maximum_leverage``
+ combined-cap convention). Every accounting call site in this module
+ must use this (candidate scoring, cost-only rescoring, final OOS
+ stitching) so walk-forward's own numbers stay in parity with the main
+ backtest under the same YAML, instead of silently ignoring stop-loss/
+ take-profit/position-groups/weight-drift/exposure-limit configuration.
+ """
+ strategy = build_strategy_from_config(config)
+ portfolio = config.portfolio
+ maximum_gross_exposure = (
+ min(portfolio.maximum_gross_exposure, portfolio.maximum_leverage)
+ if portfolio.maximum_gross_exposure is not None
+ else portfolio.maximum_leverage
+ )
+ return {
+ "stop_loss_pct": strategy.stop_loss_pct,
+ "take_profit_pct": strategy.take_profit_pct,
+ "position_groups": strategy.position_groups(),
+ "model_weight_drift": portfolio.model_weight_drift,
+ "maximum_weight": portfolio.maximum_weight,
+ "maximum_gross_exposure": maximum_gross_exposure,
+ "maximum_net_exposure": portfolio.maximum_net_exposure,
+ "long_only": portfolio.long_only,
+ "maximum_turnover": portfolio.maximum_turnover,
+ }
+
+
+def _rebalance_date_for_run_accounting(
+ decision_weights: pd.DataFrame,
+ frequency: RebalanceFrequency | str,
+ calendar: str | None,
+ tradable_mask: pd.DataFrame | None,
+ delay: int,
+) -> pd.DataFrame:
+ """Boolean, aligned exactly like `run_accounting`'s own `rebalance_date`.
+
+ Mirrors `BacktestEngine.run()`'s identical construction: a genuine
+ scheduled-rebalance-date flag, broadcast across every column at the
+ DECISION cadence, shifted onto the EXECUTED timeline the same way
+ ``decision_weights`` itself becomes executed (any extra configured
+ delay, then the mandatory look-ahead-barrier shift) -- required so
+ `apply_weight_drift`'s own anchor detection (see its docstring) can
+ catch a scheduled rebalance whose freshly-decided target happens to
+ numerically match the immediately preceding one, which plain
+ value-diffing `executed` against its own previous row cannot.
+ Per-column, NOT collapsed with `.any(axis=1)`: a closed instrument
+ must never be forced to anchor just because another instrument's own
+ schedule/value-change fires the same row.
+ """
+ schedule = compute_rebalance_dates(
+ pd.DatetimeIndex(decision_weights.index), frequency, calendar=calendar
+ )
+ is_rebalance_date = pd.DataFrame(
+ np.broadcast_to(
+ decision_weights.index.isin(schedule)[:, None], decision_weights.shape
+ ),
+ index=decision_weights.index,
+ columns=decision_weights.columns,
+ dtype=float,
+ )
+ if delay > 0:
+ is_rebalance_date = (
+ shift_respecting_tradability(is_rebalance_date, delay, tradable_mask)
+ if tradable_mask is not None
+ else is_rebalance_date.shift(delay)
+ ).fillna(0.0)
+ aligned = compute_executed_weights(is_rebalance_date, tradable=tradable_mask)
+ flag = aligned > 0.5
+ # `compute_executed_weights` is built for *weights*, where a closed row
+ # correctly repeats the last tradable row's value. Applied to a boolean
+ # flag, that same repetition would keep it True for every row a column
+ # stays closed after it lands True once -- wrong for a flag, which must
+ # describe THIS row's own event. AND with `tradable_mask` so a closed
+ # row's flag is always False, matching apply_weight_drift's own
+ # documented precondition.
+ if tradable_mask is not None:
+ flag = flag & tradable_mask
+ return flag
+
+
def _slice_between(
data: pd.DataFrame, start: pd.Timestamp, end: pd.Timestamp
) -> pd.DataFrame:
@@ -1625,12 +1848,31 @@ def _weights_and_returns_for_validation(
if tradable_mask is not None
else None
)
+ shared_calendar = uniform_calendar(
+ instrument.calendar for instrument in config.data.instruments
+ )
accounting = run_accounting(
window_weights,
asset_returns,
execution_model,
config.initial_capital,
tradable=aligned_tradable,
+ # `window_weights` (from `_weights_for_window`, via
+ # `run_backtest_from_config(..., execution_delay=execution_delay)`)
+ # already has `execution_delay` baked in -- it IS
+ # `BacktestResult.weights`, itself already `_apply_extra_delay`-ed.
+ # Passing `execution_delay` again here would shift it a SECOND
+ # time; `delay=0` matches the sibling call site at this module's
+ # own candidate-scoring loop, which passes the same kind of
+ # already-delayed frame the same way.
+ rebalance_date=_rebalance_date_for_run_accounting(
+ window_weights,
+ config.portfolio.rebalance_frequency,
+ shared_calendar,
+ aligned_tradable,
+ 0,
+ ),
+ **_accounting_kwargs_for_config(config),
)
return window_weights, accounting.net_returns
@@ -1689,7 +1931,7 @@ def _minimum_observations_for_executable_weight(config: ExperimentConfig) -> int
signal_observations = int(parameters["slow_window"])
elif strategy_name == "pairs_trading":
signal_observations = int(parameters["formation_window"]) + int(
- parameters["zscore_window"]
+ parameters["indicator_window"]
)
allocator_observations = 1
diff --git a/tests/integration/test_backtest_pipeline.py b/tests/integration/test_backtest_pipeline.py
index 2c61425..c9007ab 100644
--- a/tests/integration/test_backtest_pipeline.py
+++ b/tests/integration/test_backtest_pipeline.py
@@ -6,6 +6,8 @@
from __future__ import annotations
+from typing import Any
+
import numpy as np
import pandas as pd
import pytest
@@ -450,6 +452,500 @@ def test_all_configured_benchmark_kinds_run_end_to_end(benchmark_kind: str) -> N
assert not result.benchmark_returns.isna().any()
+def test_spy_portfolio_with_btcusdt_benchmark_runs_end_to_end() -> None:
+ """Regression guard for the originally-reported SPY portfolio /
+ BTCUSDT benchmark scenario (`MergeError: incompatible merge keys ...
+ dtype(' None:
+ """A BTCUSDT portfolio with a SPY benchmark must run cleanly even
+ though BTCUSDT trades on 2019-01-01, a date XNYS's own calendar marks
+ as a holiday closure -- SPY has no observation for it at all."""
+ btc_prices = geometric_series(45, mu=0.001, sigma=0.02, s0=3000.0, seed=21)
+ btc = make_ohlcv("BTCUSDT", btc_prices, start="2019-01-01", freq="D")
+ spy_prices = geometric_series(45, mu=0.0005, sigma=0.008, s0=300.0, seed=22)
+ spy = make_ohlcv("SPY", spy_prices, start="2019-01-02", freq="B")
+ data = pd.concat([btc, spy], ignore_index=True)
+
+ cfg = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "btc_spy_benchmark",
+ "data": {
+ "instruments": [
+ {"symbol": "BTCUSDT", "source": "binance", "calendar": "24/7"}
+ ],
+ "start_date": "2019-01-01",
+ "end_date": "2019-02-10",
+ },
+ "strategy": {"name": "buy_and_hold"},
+ "portfolio": {"allocator": "equal_weight"},
+ "execution": {},
+ "backtest": {
+ "benchmark_kind": "symbol",
+ "benchmark": {"symbol": "SPY", "source": "yahoo", "calendar": "XNYS"},
+ },
+ }
+ )
+
+ result = run_backtest_from_config(data, cfg)
+
+ assert result.benchmark_returns is not None
+ assert not result.benchmark_returns.isna().any()
+ assert result.benchmark_returns.iloc[0] == 0.0
+
+
+def test_model_weight_drift_never_trades_a_closed_instrument() -> None:
+ """A mixed-calendar portfolio (SPY on XNYS, BTCUSDT on 24/7) with
+ `model_weight_drift=True` must never record a trade for SPY on a date
+ XNYS's own calendar closes it -- even though BTCUSDT keeps trading,
+ and even on a monthly rebalance date that happens to land on a day
+ SPY itself is shut (e.g. 2019-01-01, New Year's Day). Regression
+ guard for the anchor-collapse bug: a scalar, whole-row anchor flag
+ used to force EVERY column to re-trade whenever ANY column anchored,
+ including a currently-closed one."""
+ btc_prices = geometric_series(150, mu=0.001, sigma=0.02, s0=3000.0, seed=31)
+ btc = make_ohlcv("BTCUSDT", btc_prices, start="2019-01-01", freq="D")
+ spy_prices = geometric_series(150, mu=0.0005, sigma=0.008, s0=300.0, seed=32)
+ spy = make_ohlcv("SPY", spy_prices, start="2019-01-01", freq="D")
+ data = pd.concat([btc, spy], ignore_index=True)
+
+ cfg = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "mixed_calendar_drift",
+ "data": {
+ "instruments": [
+ {"symbol": "SPY", "source": "yahoo", "calendar": "XNYS"},
+ {"symbol": "BTCUSDT", "source": "binance", "calendar": "24/7"},
+ ],
+ "start_date": "2019-01-01",
+ "end_date": "2019-05-30",
+ },
+ "strategy": {"name": "buy_and_hold"},
+ "portfolio": {
+ "allocator": "equal_weight",
+ "rebalance_frequency": "monthly",
+ "model_weight_drift": True,
+ },
+ "execution": {"commission_bps": 5.0},
+ "backtest": {"initial_capital": 100_000, "periods_per_year": 252},
+ }
+ )
+
+ result = run_backtest_from_config(data, cfg)
+
+ assert np.isfinite(result.equity_curve.to_numpy()).all()
+ spy_trades = result.trades[result.trades["symbol"] == "SPY"]
+ assert len(spy_trades) > 0 # sanity: SPY does trade on its own open days
+
+ from quantlab.data.calendar import is_session_day
+
+ spy_trade_dates = pd.DatetimeIndex(spy_trades["timestamp"])
+ open_mask = is_session_day("XNYS", spy_trade_dates)
+ assert open_mask.all(), (
+ f"SPY has trade log rows on closed dates: "
+ f"{spy_trade_dates[~open_mask].tolist()}"
+ )
+
+
+def test_rebalance_date_flag_is_never_true_on_a_closed_row(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Regression test: `engine.py`'s `_align_bool` reused `executed_weights`
+ (built for *weights*, where a closed row correctly repeats the last
+ tradable row's frozen value) to align the `rebalance_date` boolean flag
+ onto the executed timeline. Applied to a flag instead of a weight, that
+ same repetition kept the flag True for every row a column stayed closed
+ right after a landing -- `apply_weight_drift`'s own documented
+ precondition explicitly forbids this, since it re-anchors ordinary
+ debt to a stale target and executes an unscheduled trade the moment the
+ column reopens. `rebalance_frequency=daily` deterministically triggers
+ the closure-adjacent pattern via SPY's own weekend closures (no
+ reliance on a specific holiday landing)."""
+ btc_prices = geometric_series(150, mu=0.001, sigma=0.02, s0=3000.0, seed=31)
+ btc = make_ohlcv("BTCUSDT", btc_prices, start="2019-01-01", freq="D")
+ spy_prices = geometric_series(150, mu=0.0005, sigma=0.008, s0=300.0, seed=32)
+ spy = make_ohlcv("SPY", spy_prices, start="2019-01-01", freq="D")
+ data = pd.concat([btc, spy], ignore_index=True)
+
+ cfg = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "mixed_calendar_daily_rebalance",
+ "data": {
+ "instruments": [
+ {"symbol": "SPY", "source": "yahoo", "calendar": "XNYS"},
+ {"symbol": "BTCUSDT", "source": "binance", "calendar": "24/7"},
+ ],
+ "start_date": "2019-01-01",
+ "end_date": "2019-05-30",
+ },
+ "strategy": {"name": "buy_and_hold"},
+ "portfolio": {
+ "allocator": "equal_weight",
+ "rebalance_frequency": "daily",
+ "model_weight_drift": True,
+ },
+ "execution": {"commission_bps": 5.0},
+ "backtest": {"initial_capital": 100_000, "periods_per_year": 252},
+ }
+ )
+
+ import quantlab.backtesting.engine as engine_mod
+
+ captured: dict[str, pd.DataFrame] = {}
+ orig_run_accounting = engine_mod.run_accounting
+
+ def spy_run_accounting(*args: Any, **kwargs: Any) -> Any:
+ captured["tradable"] = kwargs["tradable"].copy()
+ captured["rebalance_date"] = kwargs["rebalance_date"].copy()
+ return orig_run_accounting(*args, **kwargs)
+
+ monkeypatch.setattr(engine_mod, "run_accounting", spy_run_accounting)
+
+ run_backtest_from_config(data, cfg)
+
+ violation = captured["rebalance_date"] & ~captured["tradable"]
+ assert not violation.to_numpy().any(), (
+ f"rebalance_date is True on a closed row: {violation[violation.any(axis=1)]}"
+ )
+
+
+def test_model_weight_drift_changes_returns_and_rebalances_on_schedule() -> None:
+ """`PortfolioConfig.model_weight_drift=True` (the default) must run
+ cleanly end-to-end through the full config-driven pipeline and produce
+ genuinely different (finite, sensible) equity from the legacy
+ constant-weight step function (`model_weight_drift=False`) -- the whole
+ point of the weight-drift feedback loop.
+
+ Turnover must be exactly zero between scheduled rebalance dates --
+ drift itself is never read back as a series of phantom trades, since
+ `apply_weight_drift`'s own `trade_changes` output separates real
+ trades from organic drift -- but NONZERO roughly once per scheduled
+ rebalance date, even for a buy-and-hold/equal_weight strategy whose
+ freshly-decided target NEVER numerically changes: real drift-driven
+ turnover snaps the portfolio back to target on schedule via
+ `rebalance_date`, rather than being silently absorbed into ongoing
+ drift just because the target number happens to match the previous
+ one. The legacy constant-weight baseline never rebalances at all past
+ its first anchor (a genuinely constant target has nothing to correct
+ back to), so drifted turnover is strictly higher in total.
+ """
+ data = _panel()
+ base_cfg = _config({"name": "buy_and_hold"})
+ legacy_cfg = base_cfg.revalidated_copy(
+ update={
+ "portfolio": base_cfg.portfolio.revalidated_copy(
+ update={"model_weight_drift": False}
+ )
+ }
+ )
+ drift_cfg = base_cfg.revalidated_copy(
+ update={
+ "portfolio": base_cfg.portfolio.revalidated_copy(
+ update={"model_weight_drift": True}
+ )
+ }
+ )
+
+ baseline = run_backtest_from_config(data, legacy_cfg)
+ drifted = run_backtest_from_config(data, drift_cfg)
+
+ assert np.isfinite(drifted.equity_curve.to_numpy()).all()
+ assert not drifted.equity_curve.equals(baseline.equity_curve)
+ assert drifted.metrics["annual_turnover"] > baseline.metrics["annual_turnover"]
+
+ from quantlab.portfolio.rebalancing import rebalance_dates
+
+ # One nonzero-turnover date per scheduled rebalance (the executed
+ # timeline is the decision timeline shifted forward by exactly one
+ # look-ahead-barrier row -- see quantlab.execution.orders.
+ # executed_weights) -- never one per drift row (that would be the
+ # phantom-turnover bug the drift/turnover separation in
+ # apply_weight_drift's own trade_changes output exists to prevent).
+ assert drifted.turnover is not None
+ schedule = rebalance_dates(
+ pd.DatetimeIndex(drifted.turnover.index),
+ base_cfg.portfolio.rebalance_frequency,
+ )
+ nonzero_turnover_dates = drifted.turnover[drifted.turnover.abs() > 1e-9].index
+ assert len(nonzero_turnover_dates) == len(schedule)
+
+
+@pytest.mark.parametrize(
+ ("frequency", "execution_delay"),
+ [
+ ("daily", 0),
+ ("weekly", 0),
+ ("monthly", 1),
+ ("quarterly", 0),
+ ],
+)
+def test_model_weight_drift_never_exceeds_maximum_turnover(
+ frequency: str, execution_delay: int
+) -> None:
+ """A drift-caused anchor catch-up (including a constant-target
+ schedule that never numerically changes -- see
+ `apply_weight_drift`'s own `rebalance_date`-forced-rebalance
+ mechanism) must never push realized turnover past
+ `PortfolioConfig.maximum_turnover`, across rebalance frequencies and
+ with a nonzero execution delay."""
+ data = _panel()
+ cfg = _config({"name": "buy_and_hold"})
+ cfg = cfg.revalidated_copy(
+ update={
+ "portfolio": cfg.portfolio.revalidated_copy(
+ update={
+ "model_weight_drift": True,
+ "rebalance_frequency": frequency,
+ "maximum_turnover": 0.05,
+ }
+ )
+ }
+ )
+ result = run_backtest_from_config(data, cfg, execution_delay=execution_delay)
+ assert result.turnover is not None
+ assert result.turnover.max() <= 0.05 + 1e-6
+
+
+def test_model_weight_drift_progresses_toward_the_true_target_never_backward() -> None:
+ """The executed position must climb monotonically toward the true
+ target at exactly the configured `maximum_turnover` cap rate, never
+ reversing, even once organic appreciation under
+ `model_weight_drift=True` has carried the real position past an
+ intermediate decision-level value. `maximum_turnover` must be applied
+ ONLY ONCE -- at the decision level (`rebalance_and_cap_turnover`) OR
+ in `apply_weight_drift`'s own ordinary-debt mechanism (the decision
+ layer's own cap is disabled whenever the drift layer owns it
+ instead), never both: double-applying it would hand the drift layer
+ an already-capped INTERMEDIATE target instead of the constant TRUE
+ target (1.0, for a single-instrument buy_and_hold), which the drift
+ layer would then treat as "the" target -- capable of trading the
+ portfolio BACKWARD, opposite the strategy's own direction, once
+ organic appreciation had already carried the real position past that
+ stale intermediate value."""
+ prices = geometric_series(150, mu=0.01, sigma=0.0, s0=100.0, seed=1)
+ data = make_ohlcv("AAA", prices, start="2019-01-01")
+ cfg = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "drift_no_reversal",
+ "data": {
+ "instruments": [{"symbol": "AAA", "source": "csv", "calendar": "XNYS"}],
+ "start_date": "2019-01-01",
+ "end_date": "2019-12-01",
+ },
+ "strategy": {"name": "buy_and_hold"},
+ "portfolio": {
+ "allocator": "equal_weight",
+ "maximum_turnover": 0.1,
+ "model_weight_drift": True,
+ "rebalance_frequency": "monthly",
+ },
+ "execution": {
+ "commission_bps": 0.0,
+ "spread_bps": 0.0,
+ "slippage_bps": 0.0,
+ },
+ "backtest": {"initial_capital": 100_000, "benchmark_kind": "cash"},
+ }
+ )
+
+ result = run_backtest_from_config(data, cfg)
+
+ # The decision-level target is the constant true target throughout --
+ # never a stale, already-capped intermediate value.
+ assert np.allclose(result.weights["AAA"].to_numpy(), 1.0)
+ positions = result.positions["AAA"]
+ assert positions.iloc[0] == pytest.approx(0.0)
+ # Every executed transaction progresses toward the true target: the
+ # position never decreases, and reaches it exactly once fully caught up.
+ assert (positions.diff().dropna() >= -1e-9).all()
+ assert positions.iloc[-1] == pytest.approx(1.0)
+ assert result.turnover is not None
+ assert result.turnover.max() <= 0.1 + 1e-6
+
+
+@pytest.mark.parametrize(
+ ("maximum_gross_exposure", "maximum_leverage", "expected_gross_cap"),
+ [
+ (None, 1.5, 1.5), # unset -> maximum_leverage alone
+ (0.8, 1.5, 0.8), # maximum_gross_exposure is the tighter cap
+ (2.0, 1.5, 1.5), # maximum_leverage is the tighter cap
+ ],
+)
+def test_model_weight_drift_forwards_the_combined_gross_exposure_cap(
+ maximum_gross_exposure: float | None,
+ maximum_leverage: float,
+ expected_gross_cap: float,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """engine.py combines `maximum_gross_exposure`/`maximum_leverage` via
+ `min(...)` (or falls back to `maximum_leverage` alone when `maximum_
+ gross_exposure` is unset) before forwarding into `run_accounting`'s own
+ `maximum_gross_exposure` -- the sole place the drift-compliance LP
+ reads it once `model_weight_drift` is active. Before this test, zero
+ coverage exercised this specific forwarding: a reversed ternary or a
+ wrong field would have passed every other test. Captures the actual
+ kwargs `run_accounting` is called with (mirroring
+ `test_rebalance_date_flag_is_never_true_on_a_closed_row`'s own
+ technique) rather than inferring the cap indirectly from whether a
+ breach happens to occur."""
+ data = _panel()
+ cfg = _config({"name": "buy_and_hold"})
+ portfolio_update: dict[str, Any] = {
+ "model_weight_drift": True,
+ "maximum_leverage": maximum_leverage,
+ }
+ if maximum_gross_exposure is not None:
+ portfolio_update["maximum_gross_exposure"] = maximum_gross_exposure
+ cfg = cfg.revalidated_copy(
+ update={"portfolio": cfg.portfolio.revalidated_copy(update=portfolio_update)}
+ )
+
+ import quantlab.backtesting.engine as engine_mod
+
+ captured: dict[str, Any] = {}
+ orig_run_accounting = engine_mod.run_accounting
+
+ def spy_run_accounting(*args: Any, **kwargs: Any) -> Any:
+ captured["maximum_gross_exposure"] = kwargs["maximum_gross_exposure"]
+ captured["maximum_weight"] = kwargs["maximum_weight"]
+ captured["maximum_net_exposure"] = kwargs["maximum_net_exposure"]
+ captured["long_only"] = kwargs["long_only"]
+ return orig_run_accounting(*args, **kwargs)
+
+ monkeypatch.setattr(engine_mod, "run_accounting", spy_run_accounting)
+
+ run_backtest_from_config(data, cfg)
+
+ assert captured["maximum_gross_exposure"] == pytest.approx(expected_gross_cap)
+ assert captured["maximum_weight"] == cfg.portfolio.maximum_weight
+ assert captured["maximum_net_exposure"] == cfg.portfolio.maximum_net_exposure
+ assert captured["long_only"] == cfg.portfolio.long_only
+
+
+def test_model_weight_drift_restores_a_breach_of_combined_caps_end_to_end() -> None:
+ """End-to-end (not a unit test of the LP directly): a two-asset
+ portfolio with `model_weight_drift=True`, a tight `maximum_weight`, a
+ long rebalance interval (quarterly, so drift has room to build up) and
+ one asset engineered to strongly outgrow the other must genuinely
+ breach `maximum_weight` from pure organic drift, and that breach must
+ never persist for more than the one documented row of look-ahead-free
+ lag (`apply_weight_drift`'s own temporal convention: a breach detected
+ at `t` queues a correction that lands at `t+1`, never retroactively
+ touching `t` itself) -- i.e. two CONSECUTIVE breaching rows for the
+ same symbol would mean a queued correction failed to land on schedule.
+ The trade log's `drift_compliance` adjustment code must also actually
+ appear, proving the LP genuinely fired rather than the test vacuously
+ passing because no breach ever occurred. This is exactly the
+ combination (`model_weight_drift` + these caps, through the full
+ `engine.py` pipeline) the prior integration tests never exercised
+ together."""
+ winner = geometric_series(260, mu=0.02, sigma=0.01, s0=100.0, seed=11)
+ loser = geometric_series(260, mu=-0.005, sigma=0.01, s0=100.0, seed=12)
+ data = pd.concat(
+ [
+ make_ohlcv("WIN", winner, start="2019-01-01"),
+ make_ohlcv("LOSE", loser, start="2019-01-01"),
+ ],
+ ignore_index=True,
+ )
+ cfg = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "drift_compliance_end_to_end",
+ "data": {
+ "instruments": [
+ {"symbol": "WIN", "source": "csv", "calendar": "XNYS"},
+ {"symbol": "LOSE", "source": "csv", "calendar": "XNYS"},
+ ],
+ "start_date": "2019-01-01",
+ "end_date": "2019-12-31",
+ },
+ "strategy": {"name": "buy_and_hold"},
+ "portfolio": {
+ "allocator": "equal_weight",
+ "rebalance_frequency": "quarterly",
+ "model_weight_drift": True,
+ "maximum_weight": 0.55,
+ "maximum_gross_exposure": 1.0,
+ "maximum_leverage": 1.0,
+ "long_only": True,
+ },
+ "execution": {
+ "commission_bps": 2.0,
+ "spread_bps": 3.0,
+ "slippage_bps": 2.0,
+ },
+ "backtest": {"initial_capital": 100_000, "periods_per_year": 252},
+ }
+ )
+
+ result = run_backtest_from_config(data, cfg)
+
+ # `positions` is `accounting.executed_weights` -- the REAL, post-drift,
+ # post-compliance-correction book (`weights` is the pre-drift decision
+ # timeline, which never breaches anything by construction and so would
+ # not exercise this at all).
+ executed = result.positions
+ assert executed is not None
+ breach = executed.abs() > 0.55 + 1e-6
+ assert breach.to_numpy().any(), (
+ "expected WIN's organic drift to actually breach maximum_weight at "
+ "least once -- otherwise this test does not engineer the scenario "
+ "it claims to"
+ )
+ consecutive_breach = breach & breach.shift(1, fill_value=False)
+ assert not consecutive_breach.to_numpy().any(), (
+ f"a breach persisted for 2+ consecutive rows (never restored on "
+ f"schedule): {consecutive_breach[consecutive_breach.any(axis=1)]}"
+ )
+ assert (executed.sum(axis=1).abs().to_numpy() <= 1.0 + 1e-6).all()
+ assert (executed.to_numpy() >= -1e-9).all() # long_only
+ codes = result.trades["adjustment_reason_codes"].fillna("")
+ assert codes.str.contains("drift_compliance").any(), (
+ "expected at least one drift_compliance-attributed correction -- "
+ "otherwise this test does not actually exercise the LP path"
+ )
+
+
def test_equal_weight_benchmark_ignores_data_outside_configured_universe() -> None:
data = _panel()
extra = make_ohlcv(
diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py
index 9096ac1..aa89fad 100644
--- a/tests/integration/test_cli.py
+++ b/tests/integration/test_cli.py
@@ -774,6 +774,32 @@ def test_cli_robustness_orchestrator_runs_only_enabled_techniques(
assert not (exp_dir / "sensitivity.csv").is_file()
+def test_cli_robustness_orchestrator_report_includes_strategy_diagnostics(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_reports_dir: Path
+) -> None:
+ """Regression test: `robustness` used to save a report with no Strategy
+ diagnostics section at all -- unlike `backtest`/`report`, it never
+ called `_strategy_diagnostics_robustness`. Reproduces the exact
+ "backtest, then robustness" sequence: the first save (via `backtest`)
+ would include diagnostics; `robustness` then overwrote that same
+ report.html without them, since neither `_ROBUSTNESS_ARTIFACT_FILES`
+ nor `load_previous_robustness_artifacts` carries that section forward
+ from a prior save. cross_sectional_momentum (this fixture's default
+ strategy) declares `results_diagnostics`, so it must appear here too."""
+ config_path, raw = _write_offline_experiment(
+ tmp_path,
+ extra={"robustness": {"bootstrap": {"enabled": True, "n_iterations": 30}}},
+ )
+ _patch_raw_dir(monkeypatch, raw)
+
+ result = runner.invoke(app, ["robustness", "--config", str(config_path)])
+
+ assert result.exit_code == 0, result.stdout
+ exp_dir = isolated_reports_dir / "cli_test"
+ report_html = (exp_dir / "report.html").read_text(encoding="utf-8")
+ assert "Strategy diagnostics" in report_html
+
+
def test_cli_robustness_orchestrator_warns_when_nothing_enabled(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
diff --git a/tests/unit/test_accounting.py b/tests/unit/test_accounting.py
index 70d5967..595349a 100644
--- a/tests/unit/test_accounting.py
+++ b/tests/unit/test_accounting.py
@@ -10,7 +10,11 @@
import pandas as pd
import pytest
-from quantlab.backtesting.accounting import compute_asset_returns, run_accounting
+from quantlab.backtesting.accounting import (
+ _detect_stop_loss_take_profit,
+ compute_asset_returns,
+ run_accounting,
+)
from quantlab.config import ExecutionConfig
from quantlab.exceptions import BacktestError
from quantlab.execution.execution_model import ExecutionModel
@@ -110,6 +114,111 @@ def test_invalid_initial_capital_raises(bad_capital: object) -> None:
run_accounting(held, asset_returns, _zero_cost_model(), bad_capital) # type: ignore[arg-type]
+@pytest.mark.parametrize(
+ ("kwargs", "match"),
+ [
+ ({"model_weight_drift": "yes"}, "model_weight_drift"),
+ ({"long_only": 1}, "long_only"),
+ ({"stop_loss_pct": -1.0}, "stop_loss_pct"),
+ ({"stop_loss_pct": 0.0}, "stop_loss_pct"),
+ ({"take_profit_pct": -0.5}, "take_profit_pct"),
+ ({"model_weight_drift": True, "maximum_weight": -0.1}, "maximum_weight"),
+ ({"model_weight_drift": True, "maximum_weight": 1.5}, "maximum_weight"),
+ (
+ {"model_weight_drift": True, "maximum_gross_exposure": -0.1},
+ "maximum_gross_exposure",
+ ),
+ (
+ {"model_weight_drift": True, "maximum_net_exposure": -0.1},
+ "maximum_net_exposure",
+ ),
+ (
+ {"model_weight_drift": True, "maximum_turnover": -0.1},
+ "maximum_turnover",
+ ),
+ (
+ {"model_weight_drift": True, "maximum_turnover": 0.0},
+ "maximum_turnover",
+ ),
+ ],
+)
+def test_run_accounting_rejects_invalid_direct_api_arguments(
+ kwargs: dict[str, object], match: str
+) -> None:
+ """A direct caller of `run_accounting` (bypassing PortfolioConfig's own
+ field validation and strategies.base.validate_risk_control_parameters
+ entirely) must not be able to silently pass a truthy non-bool flag, a
+ non-positive stop_loss_pct/take_profit_pct, or an out-of-range
+ exposure cap that would otherwise reach the drift-compliance LP as a
+ confusing "bug in the algorithm" error instead of a clear,
+ immediate one."""
+ idx = pd.date_range("2020-01-01", periods=3, freq="D")
+ held = pd.DataFrame({"AAA": [0.5, 0.5, 0.5]}, index=idx)
+ asset_returns = pd.DataFrame({"AAA": [np.nan, 0.01, 0.01]}, index=idx)
+
+ with pytest.raises(BacktestError, match=match):
+ run_accounting(
+ held,
+ asset_returns,
+ _zero_cost_model(),
+ 100_000.0,
+ **kwargs, # type: ignore[arg-type]
+ )
+
+
+def test_run_accounting_rejects_a_non_execution_model_instance() -> None:
+ """A wrong-type `execution_model` must raise `BacktestError` (per this
+ function's own documented contract), never let a missing `.compute`
+ attribute surface as a confusing `AttributeError` from deep inside
+ `_solve_accounting`."""
+ idx = pd.date_range("2020-01-01", periods=2, freq="D")
+ held = pd.DataFrame({"AAA": [0.5, 0.5]}, index=idx)
+ asset_returns = pd.DataFrame({"AAA": [np.nan, 0.01]}, index=idx)
+
+ with pytest.raises(BacktestError, match="execution_model"):
+ run_accounting(held, asset_returns, object(), 100_000.0) # type: ignore[arg-type]
+
+
+@pytest.mark.parametrize(
+ ("rebalance_date", "match"),
+ [
+ (
+ pd.DataFrame({"AAA": ["False", "True", "False"]}),
+ "boolean",
+ ),
+ (
+ pd.DataFrame({"AAA": [1, 0, 1]}),
+ "boolean",
+ ),
+ (
+ pd.DataFrame({"AAA": [True, None, False]}),
+ "missing values",
+ ),
+ ],
+)
+def test_run_accounting_rejects_a_non_boolean_rebalance_date(
+ rebalance_date: pd.DataFrame, match: str
+) -> None:
+ """A non-boolean `rebalance_date` column (e.g. the string `'False'`,
+ which Python/pandas would otherwise silently coerce to a truthy
+ non-empty string, or a `0`/`1` integer column) must raise, never
+ silently be treated as `True` and force a phantom rebalance."""
+ idx = pd.date_range("2020-01-01", periods=3, freq="D")
+ rebalance_date = rebalance_date.set_index(idx)
+ held = pd.DataFrame({"AAA": [0.5, 0.5, 0.5]}, index=idx)
+ asset_returns = pd.DataFrame({"AAA": [np.nan, 0.01, 0.01]}, index=idx)
+
+ with pytest.raises(BacktestError, match=match):
+ run_accounting(
+ held,
+ asset_returns,
+ _zero_cost_model(),
+ 100_000.0,
+ model_weight_drift=True,
+ rebalance_date=rebalance_date,
+ )
+
+
@pytest.mark.parametrize("invalid_input", ["weights", "returns"])
def test_non_finite_accounting_input_raises(invalid_input: str) -> None:
idx = pd.date_range("2020-01-01", periods=2, freq="D")
@@ -187,3 +296,312 @@ def test_tradable_must_cover_the_same_set_of_symbols_as_held_weights() -> None:
run_accounting(
held, asset_returns, _zero_cost_model(), 100_000.0, tradable=tradable
)
+
+
+# --------------------------------------------------------------------------- #
+# Stop-loss / take-profit -- operates on the REAL executed position, never a
+# raw strategy signal (see `_detect_stop_loss_take_profit`'s docstring).
+# --------------------------------------------------------------------------- #
+def test_stop_loss_take_profit_disabled_by_default_changes_nothing() -> None:
+ """The single most important non-regression guarantee: leaving both
+ thresholds at their `None` default must produce byte-identical
+ accounting to today's behavior, for any weights/returns."""
+ idx = pd.date_range("2020-01-01", periods=6, freq="D")
+ held = pd.DataFrame({"AAA": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0]}, index=idx)
+ asset_returns = pd.DataFrame(
+ {"AAA": [0.0, 0.0, -0.30, -0.30, 0.05, 0.05]}, index=idx
+ )
+ baseline = run_accounting(held, asset_returns, _zero_cost_model(), 100_000.0)
+ with_none = run_accounting(
+ held,
+ asset_returns,
+ _zero_cost_model(),
+ 100_000.0,
+ stop_loss_pct=None,
+ take_profit_pct=None,
+ position_groups=None,
+ )
+ pd.testing.assert_frame_equal(baseline.executed_weights, with_none.executed_weights)
+ pd.testing.assert_series_equal(baseline.equity, with_none.equity)
+ assert not with_none.stop_loss_triggered.to_numpy().any()
+ assert not with_none.take_profit_triggered.to_numpy().any()
+
+
+def test_stop_loss_forces_flat_the_bar_after_the_cumulative_breach() -> None:
+ """Hand-computed: long AAA throughout, -6% then another -6% (cumulative
+ 0.94*0.94-1 = -11.64%, past a 10% stop) -- the LOSS-REALIZING bar itself
+ keeps its return (no look-ahead: that loss already happened), and the
+ position is force-flattened starting the NEXT bar."""
+ idx = pd.date_range("2020-01-01", periods=6, freq="D")
+ held = pd.DataFrame({"AAA": [1.0] * 6}, index=idx)
+ asset_returns = pd.DataFrame({"AAA": [0.0, 0.0, -0.06, -0.06, 0.0, 0.0]}, index=idx)
+
+ acc = run_accounting(
+ held, asset_returns, _zero_cost_model(), 100_000.0, stop_loss_pct=0.10
+ )
+
+ assert acc.executed_weights["AAA"].tolist() == [0.0, 1.0, 1.0, 1.0, 0.0, 0.0]
+ # The trigger array marks only the FIRST force-flattened date (the
+ # "exit" event itself), not every date the position stays flat
+ # thereafter -- that ongoing status already lives in executed_weights
+ # (0.0) and would double as a "trigger" on every subsequent bar if not
+ # deliberately restricted to the transition.
+ assert acc.stop_loss_triggered["AAA"].tolist() == [
+ False,
+ False,
+ False,
+ False,
+ True,
+ False,
+ ]
+ assert not acc.take_profit_triggered.to_numpy().any()
+ # The bar that realized the breaching loss (index 3) is NOT itself
+ # force-flattened -- that loss had already happened.
+ assert acc.gross_returns.iloc[3] == pytest.approx(-0.06)
+
+
+def test_stop_loss_gates_the_real_drifted_position_not_a_step_function() -> None:
+ """Regression test: `model_weight_drift=True` is now the DEFAULT, so
+ stop-loss/take-profit combined with organic weight drift is close to
+ the ordinary path, not an exotic combination -- yet `_detect_stop_
+ loss_take_profit`'s `before_state = executed - weight_changes` gated-
+ turnover patch was previously never exercised with drift active.
+
+ A and B start 50/50; A loses value every day (cumulative loss exceeds
+ the 10% stop by the bar entering 2024-01-05); with weight drift on,
+ A's weight organically SHRINKS below its own anchor value each day
+ (never a step function) while B's grows to compensate (E shrinks from
+ A's losses alone). The stop-loss must gate the REAL drifted weight A
+ was actually sitting at when it flattens -- not the stale 0.5 anchor
+ -- and the resulting turnover must reflect exactly that real value."""
+ dates = pd.date_range("2024-01-01", periods=6, freq="D")
+ held = pd.DataFrame({"A": [0.5] * 6, "B": [0.5] * 6}, index=dates)
+ asset_returns = pd.DataFrame(
+ {
+ "A": [np.nan, -0.05, -0.05, -0.03, 0.0, 0.0],
+ "B": [np.nan, 0.0, 0.0, 0.0, 0.0, 0.0],
+ },
+ index=dates,
+ )
+
+ result = run_accounting(
+ held,
+ asset_returns,
+ _zero_cost_model(),
+ 100_000.0,
+ model_weight_drift=True,
+ stop_loss_pct=0.10,
+ )
+
+ # Organic drift entering rows 2-3 (never the flat 0.5 anchor) --
+ # confirms this scenario genuinely exercises drift, not a step
+ # function: dollar_A = 0.5*(1-0.05) = 0.475, E = 1+(0.5*-0.05) = 0.975,
+ # weight_A = 0.475/0.975.
+ assert result.executed_weights.loc[dates[2], "A"] == pytest.approx(
+ 0.475 / 0.975, rel=1e-6
+ )
+ assert result.executed_weights.loc[dates[2], "A"] != pytest.approx(0.5)
+
+ # Cumulative A return since anchor: 0.95*0.95*0.97 - 1 ≈ -12.46%, past
+ # the 10% stop -- triggers starting the bar after the breaching return
+ # is realized (index 4), B is entirely unaffected (independent group).
+ assert bool(result.stop_loss_triggered.loc[dates[4], "A"])
+ assert not bool(result.stop_loss_triggered.loc[dates[4], "B"])
+ assert not result.take_profit_triggered.to_numpy().any()
+ assert result.executed_weights.loc[dates[4], "A"] == pytest.approx(0.0)
+ assert result.executed_weights.loc[dates[4], "B"] == pytest.approx(
+ 0.533212, abs=1e-6
+ )
+
+ # The gated turnover must equal the REAL drifted weight A was sitting
+ # at just before this row's own forced flatten (0.466788 -- verified
+ # independently against the undisturbed drift path with no stop-loss
+ # configured at all), never the stale 0.5 anchor or a value from an
+ # earlier row.
+ assert result.turnover.loc[dates[4]] == pytest.approx(0.466788, abs=1e-6)
+ assert result.turnover.loc[dates[4]] != pytest.approx(0.5)
+
+ # No trade at all for B on the stop-loss row -- gating A must never
+ # spill into an unrelated, independent position group.
+ assert result.weight_changes.loc[dates[4], "B"] == pytest.approx(0.0)
+
+ assert np.isfinite(result.equity.to_numpy()).all()
+ assert np.isfinite(result.net_returns.to_numpy()).all()
+ assert np.isfinite(result.costs.total.to_numpy()).all()
+
+
+def test_take_profit_forces_flat_the_bar_after_the_cumulative_gain() -> None:
+ """Mirror of the stop-loss test, on the favorable side."""
+ idx = pd.date_range("2020-01-01", periods=6, freq="D")
+ held = pd.DataFrame({"AAA": [1.0] * 6}, index=idx)
+ asset_returns = pd.DataFrame({"AAA": [0.0, 0.0, 0.06, 0.06, 0.0, 0.0]}, index=idx)
+
+ acc = run_accounting(
+ held, asset_returns, _zero_cost_model(), 100_000.0, take_profit_pct=0.10
+ )
+
+ assert acc.executed_weights["AAA"].tolist() == [0.0, 1.0, 1.0, 1.0, 0.0, 0.0]
+ assert acc.take_profit_triggered["AAA"].tolist() == [
+ False,
+ False,
+ False,
+ False,
+ True,
+ False,
+ ]
+ assert not acc.stop_loss_triggered.to_numpy().any()
+
+
+def test_stop_loss_never_triggers_when_no_position_is_actually_executed() -> None:
+ """The test that would have failed the FIRST (rejected) design: a
+ strategy can emit a signal, or the allocator can decide a target, but
+ if it never becomes an actually-held (executed) position -- here
+ ``held`` is always 0 -- a stop-loss must never fire, no matter how
+ extreme the asset's own return is."""
+ idx = pd.date_range("2020-01-01", periods=6, freq="D")
+ held = pd.DataFrame({"AAA": [0.0] * 6}, index=idx)
+ asset_returns = pd.DataFrame({"AAA": [0.0, 0.0, -0.5, -0.5, 0.0, 0.0]}, index=idx)
+
+ acc = run_accounting(
+ held, asset_returns, _zero_cost_model(), 100_000.0, stop_loss_pct=0.10
+ )
+
+ assert not acc.stop_loss_triggered.to_numpy().any()
+ assert (acc.executed_weights["AAA"] == 0.0).all()
+
+
+def test_stop_loss_does_not_immediately_reenter_at_a_rebased_price() -> None:
+ """Once stopped, the position stays flat until the NEXT flat-to-non-
+ flat transition of the (still-nonzero) held weight -- not an
+ immediate re-entry the following bar, matching mean_reversion's own
+ stop_threshold re-entry convention."""
+ idx = pd.date_range("2020-01-01", periods=8, freq="D")
+ # Held stays long throughout (the raw held target never goes back to
+ # flat) -- a naive design might re-enter as soon as the position
+ # "recovers"; the real one must not, since the group never actually
+ # returned to flat.
+ held = pd.DataFrame({"AAA": [1.0] * 8}, index=idx)
+ asset_returns = pd.DataFrame(
+ {"AAA": [0.0, 0.0, -0.20, 0.0, 0.20, 0.20, 0.20, 0.20]}, index=idx
+ )
+
+ acc = run_accounting(
+ held, asset_returns, _zero_cost_model(), 100_000.0, stop_loss_pct=0.10
+ )
+
+ # Stopped once at index 2 (-20% > 10% stop), forced flat from index 3
+ # onward for the REST of the series -- even though later returns are
+ # strongly positive, there is no re-entry since held never returns to 0.
+ assert acc.executed_weights["AAA"].tolist() == [
+ 0.0,
+ 1.0,
+ 1.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ ]
+
+
+def test_position_groups_use_combined_pair_pnl_not_each_leg_separately() -> None:
+ """Hand-computed pair scenario with a rebalance that HALVES both legs'
+ magnitude mid-hold (simulating a hedge-ratio/position-size change):
+ executed A=[1.0, 0.5, 0.25], B=[-1.0, -0.5, -0.25] (fixed 1:1 ratio,
+ scaled down), returns A=-20%/-20%/-20%, B=0%/0%/0%. The GROUP return
+ per unit of gross exposure is a CONSTANT -10% every period regardless
+ of the scaling (gross_exposure exactly cancels the position-size
+ change) -- this is the whole point of normalizing by realized
+ exposure rather than dollar contribution. Cumulative: 0.90 -> 0.81
+ (-19%, past a 15% stop, set after that bar) -> forces BOTH legs flat
+ starting the next bar."""
+ idx = pd.date_range("2020-01-01", periods=4, freq="D")
+ executed = pd.DataFrame(
+ {"A": [0.0, 1.0, 0.5, 0.25], "B": [0.0, -1.0, -0.5, -0.25]}, index=idx
+ )
+ asset_returns = pd.DataFrame(
+ {"A": [0.0, -0.20, -0.20, -0.20], "B": [0.0, 0.0, 0.0, 0.0]}, index=idx
+ )
+
+ gated, stop_loss, take_profit, _ = _detect_stop_loss_take_profit(
+ executed, asset_returns, [("A", "B")], 0.15, None
+ )
+
+ assert gated["A"].tolist() == [0.0, 1.0, 0.5, 0.0]
+ assert gated["B"].tolist() == [0.0, -1.0, -0.5, 0.0]
+ assert stop_loss["A"].tolist() == [False, False, False, True]
+ assert stop_loss["B"].tolist() == [False, False, False, True]
+ assert not take_profit.to_numpy().any()
+
+
+def test_position_groups_default_to_one_independent_group_per_symbol() -> None:
+ """`position_groups=None` (the default) must behave identically to
+ declaring every symbol its own singleton group -- two unrelated
+ symbols' stop-losses must never interact."""
+ idx = pd.date_range("2020-01-01", periods=4, freq="D")
+ executed = pd.DataFrame(
+ {"A": [0.0, 1.0, 1.0, 1.0], "B": [0.0, 1.0, 1.0, 1.0]}, index=idx
+ )
+ # A breaches a 10% stop; B never does.
+ asset_returns = pd.DataFrame(
+ {"A": [0.0, -0.06, -0.06, 0.0], "B": [0.0, 0.01, 0.01, 0.01]}, index=idx
+ )
+
+ gated, stop_loss, _, _ = _detect_stop_loss_take_profit(
+ executed, asset_returns, None, 0.10, None
+ )
+
+ assert gated["A"].tolist() == [0.0, 1.0, 1.0, 0.0]
+ assert gated["B"].tolist() == [0.0, 1.0, 1.0, 1.0]
+ assert stop_loss["A"].tolist() == [False, False, False, True]
+ assert not stop_loss["B"].any()
+
+
+def test_direct_long_to_short_reversal_starts_a_fresh_episode() -> None:
+ """A same-bar sign flip (long directly to short, no intermediate flat
+ row) must start a brand-new stop-loss episode for the new direction --
+ it must NOT inherit the opposite-direction position's already-
+ triggered stop and stay force-flattened forever, since the reversed
+ position was never actually the one that breached.
+
+ Long AAA, -20% breaches a 10% stop (forced flat from index 2). At
+ index 3 the raw signal reverses directly to short (skipping flat) --
+ the new short position must be allowed to hold, since -5%/-5% moves
+ against it never breach 10% on their OWN fresh cumulative return."""
+ idx = pd.date_range("2020-01-01", periods=5, freq="D")
+ executed = pd.DataFrame({"AAA": [1.0, 1.0, 1.0, -1.0, -1.0]}, index=idx)
+ asset_returns = pd.DataFrame({"AAA": [0.0, -0.20, 0.0, 0.05, 0.05]}, index=idx)
+
+ gated, stop_loss, _, _ = _detect_stop_loss_take_profit(
+ executed, asset_returns, None, 0.10, None
+ )
+
+ assert gated["AAA"].tolist() == [1.0, 1.0, 0.0, -1.0, -1.0]
+ assert stop_loss["AAA"].tolist() == [False, False, True, False, False]
+
+
+def test_position_groups_reject_malformed_declarations() -> None:
+ """An empty group, a self-duplicate, an unknown symbol, or two groups
+ claiming the same symbol must raise loudly -- silently accepting any
+ of these would double-process (or mis-key) a symbol across two
+ different entry-timing episodes instead of failing fast."""
+ idx = pd.date_range("2020-01-01", periods=2, freq="D")
+ executed = pd.DataFrame(
+ {"A": [0.0, 1.0], "B": [0.0, 1.0], "C": [0.0, 1.0]}, index=idx
+ )
+ asset_returns = pd.DataFrame(
+ {"A": [0.0, 0.01], "B": [0.0, 0.01], "C": [0.0, 0.01]}, index=idx
+ )
+
+ with pytest.raises(BacktestError, match="empty group"):
+ _detect_stop_loss_take_profit(executed, asset_returns, [()], 0.10, None)
+ with pytest.raises(BacktestError, match="repeats a symbol"):
+ _detect_stop_loss_take_profit(executed, asset_returns, [("A", "A")], 0.10, None)
+ with pytest.raises(BacktestError, match="not present"):
+ _detect_stop_loss_take_profit(
+ executed, asset_returns, [("A", "ZZZ")], 0.10, None
+ )
+ with pytest.raises(BacktestError, match="overlaps symbol"):
+ _detect_stop_loss_take_profit(
+ executed, asset_returns, [("A", "B"), ("B", "C")], 0.10, None
+ )
diff --git a/tests/unit/test_benchmark.py b/tests/unit/test_benchmark.py
index 479966a..490ab1d 100644
--- a/tests/unit/test_benchmark.py
+++ b/tests/unit/test_benchmark.py
@@ -6,7 +6,7 @@
import pandas as pd
import pytest
-from quantlab.backtesting.benchmark import build_benchmark, cash_returns
+from quantlab.backtesting.benchmark import _align_returns, build_benchmark, cash_returns
from quantlab.config import ExperimentConfig
from quantlab.constants import (
ADJUSTED_CLOSE,
@@ -69,6 +69,58 @@ def test_missing_benchmark_return_after_initial_period_raises() -> None:
build_benchmark(data, portfolio_index, benchmark_symbol="AAA")
+def test_align_returns_tolerates_a_coarser_datetime_resolution_than_the_schedule() -> (
+ None
+):
+ """Coverage gap the calendar-management research surfaced: `_align_
+ returns`'s `calendar=` branch previously had zero test coverage at all
+ (no call in this file passed `calendar=`). Guard it against a
+ lower-resolution `datetime64` benchmark index, mirroring the existing
+ `session_labels` regression test for the same class of issue."""
+ benchmark_index = pd.date_range("2024-01-02", periods=3, freq="B").astype(
+ "datetime64[ms]"
+ )
+ portfolio_index = pd.date_range("2024-01-02", periods=3, freq="B")
+ series = pd.Series([np.nan, 0.01, 0.02], index=benchmark_index)
+
+ result = _align_returns(series, portfolio_index, calendar="XNYS")
+
+ assert not result.isna().any()
+ assert result.tolist() == pytest.approx([0.0, 0.01, 0.02])
+
+
+def test_align_returns_seeds_a_leading_verified_closure_of_the_benchmark_calendar() -> (
+ None
+):
+ """BTCUSDT (24/7) trading on 2019-01-01 -- a date XNYS's own calendar
+ marks as a holiday closure, not a session -- must not raise just
+ because the benchmark has no observation for a date its own calendar
+ says isn't a session at all."""
+ portfolio_index = pd.DatetimeIndex(["2019-01-01", "2019-01-02", "2019-01-03"])
+ benchmark_index = pd.DatetimeIndex(["2019-01-02", "2019-01-03"])
+ series = pd.Series([np.nan, 0.02], index=benchmark_index)
+
+ result = _align_returns(series, portfolio_index, calendar="XNYS")
+
+ assert result.tolist() == pytest.approx([0.0, 0.0, 0.02])
+
+
+def test_align_returns_still_raises_when_a_leading_missing_date_is_not_a_closure() -> (
+ None
+):
+ """The seed above must not overreach: a leading date missing for a
+ genuine reason (not a verified closure of the benchmark's own
+ calendar) must still raise, exactly as before."""
+ # 2019-01-02 is a real XNYS trading session; the benchmark's own data
+ # simply skips it (a genuine gap, not a holiday).
+ portfolio_index = pd.DatetimeIndex(["2019-01-02", "2019-01-03", "2019-01-04"])
+ benchmark_index = pd.DatetimeIndex(["2019-01-03", "2019-01-04"])
+ series = pd.Series([np.nan, 0.02], index=benchmark_index)
+
+ with pytest.raises(BacktestError, match="missing on portfolio dates"):
+ _align_returns(series, portfolio_index, calendar="XNYS")
+
+
def test_first_benchmark_period_is_zero() -> None:
index = pd.date_range("2024-01-01", periods=3, freq="D")
data = _market_data({"AAA": [100.0, 110.0, 121.0]}, index)
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 11da2bc..ac0be59 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -71,6 +71,33 @@ def test_walk_forward_parameter_grid_is_validated_at_config_load() -> None:
ExperimentConfig.from_dict(invalid)
+def test_walk_forward_parameter_grid_rejects_duplicate_candidates_at_load() -> None:
+ """Regression test: `validation.parameter_grid.lookback_period: [126,
+ 126]` used to load fine and only fail later, deep inside
+ `walk_forward.py`'s own execution-time duplicate check (`Grid
+ parameter 'lookback_period' must not contain duplicate values`) --
+ caught here at YAML load time instead, mirroring
+ `StressTestSettings`'s own established convention."""
+ base: dict[str, Any] = {
+ "experiment_name": "yaml_grid_dupe",
+ "data": {
+ "instruments": [{"symbol": "AAA", "source": "csv", "calendar": "XNYS"}],
+ "start_date": "2020-01-01",
+ "end_date": "2022-01-01",
+ },
+ "strategy": {
+ "name": "mean_reversion",
+ "parameters": {"lookback_period": 20},
+ },
+ "validation": {
+ "method": "walk_forward",
+ "parameter_grid": {"lookback_period": [126, 126]},
+ },
+ }
+ with pytest.raises(InvalidConfigurationError, match="duplicate"):
+ ExperimentConfig.from_dict(base)
+
+
def test_parameter_grid_is_rejected_for_non_walk_forward_validation() -> None:
with pytest.raises(ValueError, match="applies only"):
ValidationConfig.model_validate(
@@ -140,11 +167,228 @@ def test_robustness_bootstrap_n_iterations_must_be_positive(
)
+def test_validation_step_defaults_to_none_and_accepts_override() -> None:
+ config = ExperimentConfig.from_dict(_robustness_base_dict())
+ assert config.validation.step is None
+ with_step = ExperimentConfig.from_dict(
+ {
+ **_robustness_base_dict(),
+ "validation": {"method": "walk_forward", "step": 5},
+ }
+ )
+ assert with_step.validation.step == 5
+
+
+def test_validation_walk_forward_fields_rejected_outside_walk_forward_method() -> None:
+ with pytest.raises(InvalidConfigurationError, match="walk_forward"):
+ ExperimentConfig.from_dict(
+ {**_robustness_base_dict(), "validation": {"step": 5}}
+ )
+
+
+@pytest.mark.parametrize("bad_step", [0, -1])
+def test_validation_step_must_be_positive(bad_step: int) -> None:
+ with pytest.raises(InvalidConfigurationError):
+ ExperimentConfig.from_dict(
+ {**_robustness_base_dict(), "validation": {"step": bad_step}}
+ )
+
+
+def test_validation_step_exceeding_test_window_is_rejected() -> None:
+ """A larger step than test_window would skip dates between consecutive
+ folds' test blocks entirely -- documented as forbidden (see
+ ValidationConfig.step's own docstring) but previously unenforced at
+ config-load time, only failing much later inside the walk-forward
+ validator itself."""
+ with pytest.raises(InvalidConfigurationError, match="step"):
+ ExperimentConfig.from_dict(
+ {
+ **_robustness_base_dict(),
+ "validation": {
+ "method": "walk_forward",
+ "test_window": 10,
+ "step": 11,
+ },
+ }
+ )
+
+
+def test_validation_step_equal_to_test_window_is_accepted() -> None:
+ config = ExperimentConfig.from_dict(
+ {
+ **_robustness_base_dict(),
+ "validation": {"method": "walk_forward", "test_window": 10, "step": 10},
+ }
+ )
+ assert config.validation.step == 10
+
+
+def test_validation_step_exceeding_default_test_window_is_rejected() -> None:
+ """test_window omitted -> resolve_walk_forward_windows()'s own 126
+ default applies; the same check must account for it, not only an
+ explicitly-set test_window."""
+ with pytest.raises(InvalidConfigurationError, match="step"):
+ ExperimentConfig.from_dict(
+ {
+ **_robustness_base_dict(),
+ "validation": {"method": "walk_forward", "step": 200},
+ }
+ )
+
+
+def test_bootstrap_confidence_level_default_and_override() -> None:
+ config = ExperimentConfig.from_dict(_robustness_base_dict())
+ assert config.robustness.bootstrap.confidence_level == pytest.approx(0.90)
+ custom = ExperimentConfig.from_dict(
+ _robustness_base_dict(bootstrap={"confidence_level": 0.95})
+ )
+ assert custom.robustness.bootstrap.confidence_level == pytest.approx(0.95)
+
+
+@pytest.mark.parametrize("bad_level", [0.0, 1.0, -0.1, 1.5])
+def test_bootstrap_confidence_level_must_be_strictly_between_0_and_1(
+ bad_level: float,
+) -> None:
+ with pytest.raises(InvalidConfigurationError):
+ ExperimentConfig.from_dict(
+ _robustness_base_dict(bootstrap={"confidence_level": bad_level})
+ )
+
+
+def test_stress_test_settings_default_lists_reproduce_original_scenarios() -> None:
+ config = ExperimentConfig.from_dict(_robustness_base_dict())
+ settings = config.robustness.stress_test
+ assert settings.commission_multipliers == [2.0, 5.0]
+ assert settings.slippage_multipliers == [2.0]
+ assert settings.execution_delays == [1]
+ assert settings.best_days_removed == [10]
+ assert settings.reduce_universe_by == [1]
+
+
+def test_stress_test_settings_accept_custom_lists() -> None:
+ config = ExperimentConfig.from_dict(
+ _robustness_base_dict(
+ stress_test={
+ "commission_multipliers": [3.0],
+ "slippage_multipliers": [],
+ "execution_delays": [1, 2, 3],
+ "best_days_removed": [5, 20],
+ "reduce_universe_by": [],
+ }
+ )
+ )
+ settings = config.robustness.stress_test
+ assert settings.commission_multipliers == [3.0]
+ assert settings.slippage_multipliers == []
+ assert settings.execution_delays == [1, 2, 3]
+ assert settings.best_days_removed == [5, 20]
+ assert settings.reduce_universe_by == []
+
+
+@pytest.mark.parametrize(
+ "field",
+ [
+ "commission_multipliers",
+ "slippage_multipliers",
+ "execution_delays",
+ "best_days_removed",
+ "reduce_universe_by",
+ ],
+)
+def test_stress_test_settings_reject_duplicate_values(field: str) -> None:
+ with pytest.raises(InvalidConfigurationError):
+ ExperimentConfig.from_dict(_robustness_base_dict(stress_test={field: [2, 2]}))
+
+
+@pytest.mark.parametrize(
+ "field",
+ [
+ "commission_multipliers",
+ "slippage_multipliers",
+ "execution_delays",
+ "best_days_removed",
+ "reduce_universe_by",
+ ],
+)
+def test_stress_test_settings_reject_non_positive_values(field: str) -> None:
+ with pytest.raises(InvalidConfigurationError):
+ ExperimentConfig.from_dict(_robustness_base_dict(stress_test={field: [0]}))
+
+
+def test_strategy_signal_price_type_default_and_override() -> None:
+ config = ExperimentConfig.from_dict(_robustness_base_dict())
+ assert config.strategy.signal_price_type == "adjusted_close"
+ custom = ExperimentConfig.from_dict(
+ {
+ **_robustness_base_dict(),
+ "strategy": {
+ "name": "mean_reversion",
+ "parameters": {"lookback_period": 20},
+ "signal_price_type": "close",
+ },
+ }
+ )
+ assert custom.strategy.signal_price_type == "close"
+
+
+def test_strategy_signal_price_type_rejects_invalid_value() -> None:
+ with pytest.raises(InvalidConfigurationError):
+ ExperimentConfig.from_dict(
+ {
+ **_robustness_base_dict(),
+ "strategy": {
+ "name": "mean_reversion",
+ "parameters": {"lookback_period": 20},
+ "signal_price_type": "vwap",
+ },
+ }
+ )
+
+
+def test_research_question_and_hypothesis_default_none_and_accept_override() -> None:
+ config = ExperimentConfig.from_dict(_robustness_base_dict())
+ assert config.research_question is None
+ assert config.hypothesis is None
+ custom = ExperimentConfig.from_dict(
+ {
+ **_robustness_base_dict(),
+ "research_question": "Is this strategy any good?",
+ "hypothesis": "H1: yes. H0: no.",
+ }
+ )
+ assert custom.research_question == "Is this strategy any good?"
+ assert custom.hypothesis == "H1: yes. H0: no."
+
+
+def test_output_config_defaults_and_accepts_override() -> None:
+ config = ExperimentConfig.from_dict(_robustness_base_dict())
+ assert config.output.directory is None
+ assert config.output.save_html_report is True
+ assert config.output.save_figures is True
+ custom = ExperimentConfig.from_dict(
+ {
+ **_robustness_base_dict(),
+ "output": {
+ "directory": "/tmp/custom",
+ "save_html_report": False,
+ "save_figures": False,
+ },
+ }
+ )
+ assert custom.output.directory == "/tmp/custom"
+ assert custom.output.save_html_report is False
+ assert custom.output.save_figures is False
+
+
@pytest.mark.parametrize(
"parameters",
[
{"lookback_period": [10, 20]},
- {"lookback_period": [10, 20], "entry_zscore": [1.0, 2.0], "exit_zscore": [0.5]},
+ {
+ "lookback_period": [10, 20],
+ "entry_threshold": [1.0, 2.0],
+ "exit_threshold": [0.5],
+ },
],
)
def test_robustness_sensitivity_parameters_must_have_exactly_two_keys(
@@ -161,7 +405,29 @@ def test_robustness_sensitivity_parameters_rejects_empty_candidate_list() -> Non
ExperimentConfig.from_dict(
_robustness_base_dict(
sensitivity={
- "parameters": {"lookback_period": [10, 20], "entry_zscore": []}
+ "parameters": {"lookback_period": [10, 20], "entry_threshold": []}
+ }
+ )
+ )
+
+
+def test_robustness_sensitivity_parameters_rejects_duplicate_candidates_at_load() -> (
+ None
+):
+ """Regression test: `robustness.sensitivity.parameters.lookback_
+ period: [126, 126]` used to load fine and only fail later, deep
+ inside `parameter_sensitivity.py`'s own execution-time duplicate
+ check (`Values for 'lookback_period' must not contain duplicates`) --
+ caught here at YAML load time instead, mirroring `StressTestSettings`/
+ `validation.parameter_grid`'s own established convention."""
+ with pytest.raises(InvalidConfigurationError, match="duplicate"):
+ ExperimentConfig.from_dict(
+ _robustness_base_dict(
+ sensitivity={
+ "parameters": {
+ "lookback_period": [126, 126],
+ "entry_threshold": [1.0, 2.0],
+ }
}
)
)
@@ -205,7 +471,7 @@ def test_robustness_sensitivity_parameters_accepts_two_valid_keys() -> None:
"enabled": True,
"parameters": {
"lookback_period": [10, 20],
- "entry_zscore": [1.0, 2.0],
+ "entry_threshold": [1.0, 2.0],
},
}
)
@@ -213,7 +479,7 @@ def test_robustness_sensitivity_parameters_accepts_two_valid_keys() -> None:
assert config.robustness.sensitivity.enabled is True
assert config.robustness.sensitivity.parameters == {
"lookback_period": [10, 20],
- "entry_zscore": [1.0, 2.0],
+ "entry_threshold": [1.0, 2.0],
}
@@ -248,7 +514,7 @@ def test_robustness_sensitivity_parameters_rejects_an_invalid_candidate_value()
"enabled": True,
"parameters": {
"lookback_period": [0],
- "entry_zscore": [1.0, 2.0],
+ "entry_threshold": [1.0, 2.0],
},
}
)
@@ -262,15 +528,15 @@ def test_robustness_sensitivity_parameters_rejects_an_invalid_value_combination(
*combination* across the two axes still isn't -- this must be checked
against the strategy's own combined-parameter validator, not just each
axis's values in isolation."""
- with pytest.raises(InvalidConfigurationError, match="exit_zscore"):
+ with pytest.raises(InvalidConfigurationError, match="entry_threshold"):
ExperimentConfig.from_dict(
_robustness_base_dict(
sensitivity={
"enabled": True,
"parameters": {
- # mean_reversion requires exit_zscore < entry_zscore.
- "entry_zscore": [1.0],
- "exit_zscore": [2.0],
+ # mean_reversion requires exit_threshold < entry_threshold.
+ "entry_threshold": [1.0],
+ "exit_threshold": [2.0],
},
}
)
diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_dashboard.py
index 5442b6c..59cf2a0 100644
--- a/tests/unit/test_dashboard.py
+++ b/tests/unit/test_dashboard.py
@@ -15,13 +15,17 @@
from types import SimpleNamespace
from typing import Any, cast
+import numpy as np
import pandas as pd
import pytest
from quantlab.dashboard.components import (
+ _is_missing,
_monthly_return_pivot,
render_gross_net_comparison,
render_metric_cards,
+ render_pair_diagnostics,
+ render_trade_table,
)
pytest.importorskip("streamlit")
@@ -72,6 +76,26 @@ def _sidebar_multiselect_by_key(at: AppTest, key: str) -> Any:
return next(ms for ms in at.sidebar.multiselect if ms.key == key)
+def _run_button(at: AppTest) -> Any:
+ """The "Run backtest"/"Run walk-forward" button, found by its stable
+ key rather than by position -- the sidebar can render OTHER buttons
+ before it (e.g. "Load Binance symbols", gated behind its own explicit
+ click -- see `_load_binance_universe`), so it is not reliably
+ ``at.sidebar.button[0]``."""
+ return next(b for b in at.sidebar.button if b.key == "run_button")
+
+
+def _load_binance_universe(at: AppTest) -> AppTest:
+ """Click "Load Binance symbols" so the ``binance_symbols`` multiselect
+ actually exists -- fetching Binance's universe is gated behind this
+ explicit button precisely so it is NOT called on every dashboard load
+ (see `_binance_symbols_picker` in app.py)."""
+ next(
+ b for b in at.sidebar.button if b.key == "binance_universe_load_button"
+ ).click().run()
+ return at
+
+
def _configure_offline_pairs_trade(at: AppTest) -> AppTest:
"""Point the dashboard at locally cached CSV data for SPY/QQQ."""
_sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY, QQQ").run()
@@ -167,6 +191,12 @@ def fake_universe(self: BinanceDataSource) -> list[SymbolSuggestion]:
at.run()
assert not at.exception
+ # Not fetched on initial load -- see _load_binance_universe's docstring
+ # and test_dashboard_initial_load_never_calls_binance below.
+ assert fetches == []
+ _load_binance_universe(at)
+ assert fetches == [None]
+
assert not any(field.label == "Search" for field in at.sidebar.text_input)
picker = _sidebar_multiselect_by_key(at, "binance_symbols")
assert picker.options == ["BTCUSDT — BTC/USDT", "ETHUSDT — ETH/USDT"]
@@ -180,6 +210,37 @@ def fake_universe(self: BinanceDataSource) -> list[SymbolSuggestion]:
assert fetches == [None]
+def test_dashboard_initial_load_never_calls_binance(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Regression test: a plain `st.expander` still runs its body every
+ rerun even while collapsed, so the "Binance" section previously called
+ Binance's real API on every dashboard load -- including a load that
+ immediately switches to Strategies mode, which never touches the
+ (hidden) sidebar at all. Fetching Binance's universe must only ever
+ happen after the explicit "Load Binance symbols" button is clicked."""
+ from quantlab.data.base import SymbolSuggestion
+ from quantlab.data.binance import BinanceDataSource
+
+ fetches: list[None] = []
+
+ def fake_universe(self: BinanceDataSource) -> list[SymbolSuggestion]:
+ fetches.append(None)
+ return [SymbolSuggestion(symbol="BTCUSDT", description="BTC/USDT")]
+
+ monkeypatch.setattr(BinanceDataSource, "list_trading_symbols", fake_universe)
+ st.cache_data.clear() # avoid a real universe cached by an earlier test/run
+
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ assert not at.exception
+ assert fetches == []
+
+ _switch_to_strategies_mode(at)
+ assert not at.exception
+ assert fetches == []
+
+
def test_yahoo_symbols_picker_is_an_instant_dropdown_over_the_bundled_universe() -> (
None
):
@@ -273,6 +334,7 @@ def test_binance_shows_no_incomplete_suggestions_note(
assert not any(
"Not every symbol is suggested" in c.value for c in at.sidebar.caption
)
+ _load_binance_universe(at)
picker = _sidebar_multiselect_by_key(at, "binance_symbols")
assert "Not every symbol is suggested" not in picker.proto.help
@@ -283,6 +345,7 @@ def test_binance_symbols_picker_rejects_symbols_outside_the_universe() -> None:
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
+ _load_binance_universe(at)
picker = _sidebar_multiselect_by_key(at, "binance_symbols")
assert picker.proto.accept_new_options is False
@@ -300,6 +363,35 @@ def test_monthly_return_pivot_preserves_a_month_without_observations() -> None:
assert pivot.loc[2024, 3] == pytest.approx(-0.05)
+@pytest.mark.parametrize(
+ ("value", "expected"),
+ [
+ (None, True),
+ (pd.NaT, True),
+ (pd.NA, True),
+ (float("nan"), True),
+ (np.float64("nan"), True),
+ (np.float32("nan"), True),
+ (np.float16("nan"), True),
+ ("some string", False),
+ ("trigger+exit", False),
+ (0.0, False),
+ (0, False),
+ (["a", "b"], False),
+ ],
+)
+def test_is_missing_handles_every_missing_scalar_type(
+ value: object, expected: bool
+) -> None:
+ """A prior `isinstance(value, float) and pd.isna(value)` implementation
+ missed `pd.NA` (not a `float` subclass) and non-float64 NaN scalars
+ like `numpy.float32('nan')` (only `numpy.float64` is itself a `float`
+ subclass, via CPython's numpy integration) -- either would then reach
+ `parse_adjustment_codes(str(value))` as a bogus `""`/`"nan"` code
+ and raise `BacktestError` instead of being treated as missing."""
+ assert _is_missing(value) is expected
+
+
def test_metric_cards_use_a_four_column_grid_and_explain_cost_units() -> None:
class FakeColumn:
def __init__(self, sink: list[tuple[str, str, dict[str, object]]]) -> None:
@@ -328,7 +420,7 @@ def columns(self, n: int) -> list[FakeColumn]:
# 4 columns: eight cards divide evenly into two full, aligned rows --
# any count that doesn't evenly divide 8 leaves a ragged last row (e.g.
- # 3 columns strands "Total costs"/"Number of trades" alone on a
+ # 3 columns strands "Total costs"/"Number of fills" alone on a
# half-empty row, visually detached from the grid above).
assert fake.columns_requested == 4
assert len(fake.metrics) == 8
@@ -337,6 +429,357 @@ def columns(self, n: int) -> list[FakeColumn]:
assert "currency units" in str(costs[2]["help"])
+class _FakeColumnConfig:
+ # Names mirror st.column_config's actual (PascalCase) API.
+ def TextColumn(self, *args: object, **kwargs: object) -> dict[str, object]: # noqa: N802
+ return {"args": args, "kwargs": kwargs}
+
+ def NumberColumn(self, *args: object, **kwargs: object) -> dict[str, object]: # noqa: N802
+ return {"args": args, "kwargs": kwargs}
+
+ def DateColumn(self, *args: object, **kwargs: object) -> dict[str, object]: # noqa: N802
+ return {"args": args, "kwargs": kwargs}
+
+ def DatetimeColumn(self, *args: object, **kwargs: object) -> dict[str, object]: # noqa: N802
+ return {"args": args, "kwargs": kwargs}
+
+
+class _FakeStreamlit:
+ def __init__(self) -> None:
+ self.dataframe_calls: list[tuple[pd.DataFrame, dict[str, object]]] = []
+ self.caption_calls: list[tuple[object, dict[str, object]]] = []
+ self.column_config = _FakeColumnConfig()
+
+ def info(self, *args: object, **kwargs: object) -> None:
+ pass
+
+ def dataframe(self, frame: pd.DataFrame, **kwargs: object) -> None:
+ self.dataframe_calls.append((frame, kwargs))
+
+ def caption(self, text: object, **kwargs: object) -> None:
+ self.caption_calls.append((text, kwargs))
+
+ def download_button(self, *args: object, **kwargs: object) -> None:
+ pass
+
+
+def _trade_row(**overrides: object) -> dict[str, object]:
+ """One full 21-field trade-log record, defaulted to a plain,
+ unattributed fill -- callers override only the fields their scenario
+ cares about."""
+ row: dict[str, object] = {
+ "timestamp": pd.Timestamp("2024-01-02"),
+ "symbol": "AAA",
+ "previous_weight": 0.0,
+ "new_weight": 0.5,
+ "weight_change": 0.5,
+ "side": "buy",
+ "action": "entry_long",
+ "trigger_reason_code": None,
+ "trigger_reason_detail_code": None,
+ "trigger_reason_details": None,
+ "adjustment_reason_codes": None,
+ "adjustment_reason_details": None,
+ "position_strategy_origin_timestamp": pd.NaT,
+ "position_strategy_origin_code": None,
+ "position_strategy_origin_details": None,
+ "reference_price": 100.0,
+ "traded_notional": 1000.0,
+ "commission": 0.5,
+ "spread_cost": 0.3,
+ "slippage_cost": 0.2,
+ "total_cost": 1.0,
+ }
+ row.update(overrides)
+ return row
+
+
+def test_trade_table_shows_separate_columns_when_no_row_has_a_detail_code() -> None:
+ """Regression test: when every trigger_reason_detail_code is None (no
+ strategy sub-code fired on this run), pandas can infer that all-None
+ column as plain "object" while trigger_reason_code (real string
+ values) infers its new "str" extension dtype -- assigning into the
+ detail-code column without first casting it to plain "object" used
+ to raise/silently corrupt data. Every trigger/adjustment/position-
+ origin value is shown separate and unmodified (no Python-side string
+ concatenation into a single column, no cell blanked for
+ "compactness") -- each must stay exactly what build_trade_log
+ produced, just under its human-readable display column."""
+ trades = pd.DataFrame.from_records(
+ [
+ _trade_row(
+ trigger_reason_code="strategy_signal",
+ trigger_reason_details="signal 0.0000 -> 1.0000 since last rebalance",
+ position_strategy_origin_timestamp=pd.Timestamp("2024-01-02"),
+ )
+ ]
+ )
+ result: Any = SimpleNamespace(
+ trades=trades,
+ config=SimpleNamespace(experiment_name="test", strategy_parameters={}),
+ )
+ fake = _FakeStreamlit()
+
+ render_trade_table(fake, result)
+
+ displayed = fake.dataframe_calls[0][0]
+ assert displayed["Trigger"].tolist() == ["strategy_signal"]
+ assert displayed["Trigger detail"].tolist() == [None]
+ assert displayed["Adjustments"].tolist() == [None]
+ assert displayed["Details"].tolist() == [
+ "Trigger: signal 0.0000 -> 1.0000 since last rebalance"
+ ]
+ # Position origin is always present with its raw value, never blanked.
+ assert displayed["Position origin date"].tolist() == [pd.Timestamp("2024-01-02")]
+
+
+def test_trade_table_adjustment_codes_display_spaces_out_the_plus_join() -> None:
+ """adjustment_reason_codes keeps its compact "+" form in the
+ underlying data (see trade_log.serialize_adjustment_codes) but is
+ reformatted with spaces for on-screen readability in the Adjustments
+ column -- every other value, including Trigger, must be untouched
+ (no "code (detail)" fusion)."""
+ trades = pd.DataFrame.from_records(
+ [
+ _trade_row(
+ action="increase_long",
+ adjustment_reason_codes="maximum_weight+maximum_gross_exposure",
+ adjustment_reason_details=(
+ "maximum_weight: 0.7 -> 0.5; maximum_gross_exposure: 1.0 -> 0.8"
+ ),
+ )
+ ]
+ )
+ result: Any = SimpleNamespace(
+ trades=trades,
+ config=SimpleNamespace(experiment_name="test", strategy_parameters={}),
+ )
+ fake = _FakeStreamlit()
+
+ render_trade_table(fake, result)
+
+ displayed = fake.dataframe_calls[0][0]
+ assert displayed["Trigger"].tolist() == [None]
+ assert displayed["Adjustments"].tolist() == [
+ "maximum_weight + maximum_gross_exposure"
+ ]
+ assert displayed["Details"].tolist() == [
+ "Adjustment: maximum_weight: 0.7 -> 0.5; maximum_gross_exposure: 1.0 -> 0.8"
+ ]
+ # The underlying result.trades DataFrame itself must stay untouched.
+ assert trades["adjustment_reason_codes"].tolist() == [
+ "maximum_weight+maximum_gross_exposure"
+ ]
+
+
+def test_trade_table_display_columns_are_identical_across_strategies() -> None:
+ """The visible table's column set and order must never depend on
+ which strategy produced the result -- only the VALUES may differ.
+ Simulates two very different strategies' trade logs (one with a
+ constraint adjustment and a real position origin, one bare) and
+ asserts both render the exact same 15 columns, in the exact same
+ order (`_TRADE_TABLE_DISPLAY_COLUMNS`)."""
+ from quantlab.dashboard.components import _TRADE_TABLE_DISPLAY_COLUMNS
+
+ trend_following_trades = pd.DataFrame.from_records(
+ [
+ _trade_row(
+ symbol="SPY",
+ action="entry_long",
+ trigger_reason_code="strategy_signal",
+ trigger_reason_detail_code="bullish_crossover",
+ position_strategy_origin_timestamp=pd.Timestamp("2024-01-02"),
+ position_strategy_origin_code="bullish_crossover",
+ )
+ ]
+ )
+ buy_and_hold_trades = pd.DataFrame.from_records(
+ [
+ _trade_row(
+ symbol="QQQ",
+ action="entry_long",
+ trigger_reason_code="strategy_signal",
+ trigger_reason_detail_code="price_became_available",
+ adjustment_reason_codes="maximum_weight",
+ adjustment_reason_details="maximum_weight: 0.9 -> 0.6",
+ position_strategy_origin_timestamp=pd.Timestamp("2024-01-02"),
+ position_strategy_origin_code="price_became_available",
+ )
+ ]
+ )
+
+ displayed_columns = []
+ for trades in (trend_following_trades, buy_and_hold_trades):
+ result: Any = SimpleNamespace(
+ trades=trades,
+ config=SimpleNamespace(experiment_name="test", strategy_parameters={}),
+ )
+ fake = _FakeStreamlit()
+ render_trade_table(fake, result)
+ displayed_columns.append(list(fake.dataframe_calls[0][0].columns))
+
+ assert displayed_columns[0] == displayed_columns[1] == _TRADE_TABLE_DISPLAY_COLUMNS
+
+
+def test_trade_table_empty_position_origin_means_no_origin_not_masking() -> None:
+ """A blank `Position origin` cell must mean only one thing -- no
+ strategic position is currently active (decision_proxy is flat) --
+ never "hidden because it duplicates Trigger". A row whose Trigger
+ equals a real value but whose position_strategy_origin_code is
+ genuinely None (e.g. a walk-forward result with no attribution, or a
+ real exit-to-flat trade) must still render the Position origin
+ column, blank, never dropped."""
+ trades = pd.DataFrame.from_records(
+ [
+ _trade_row(
+ action="exit_long",
+ trigger_reason_code="strategy_signal",
+ trigger_reason_detail_code="mean_reversion_exit",
+ position_strategy_origin_timestamp=pd.NaT,
+ position_strategy_origin_code=None,
+ )
+ ]
+ )
+ result: Any = SimpleNamespace(
+ trades=trades,
+ config=SimpleNamespace(experiment_name="test", strategy_parameters={}),
+ )
+ fake = _FakeStreamlit()
+
+ render_trade_table(fake, result)
+
+ displayed = fake.dataframe_calls[0][0]
+ assert "Position origin" in displayed.columns
+ assert "Position origin date" in displayed.columns
+ assert displayed["Position origin"].tolist() == [None]
+ assert pd.isna(displayed["Position origin date"].iloc[0])
+ # Trigger, on the same row, is a real value -- confirms the blank
+ # Position origin is not a side effect of an otherwise-empty row.
+ assert displayed["Trigger"].tolist() == ["strategy_signal"]
+
+
+def test_trade_table_csv_export_always_has_the_full_21_column_schema() -> None:
+ """The CSV download must always use the raw `result.trades` frame --
+ its schema must never depend on, or be narrowed by, the display
+ view above it."""
+ from quantlab.backtesting.trade_log import TRADE_LOG_COLUMNS
+
+ trades = pd.DataFrame.from_records([_trade_row()])
+ assert list(trades.columns) == TRADE_LOG_COLUMNS
+ result: Any = SimpleNamespace(
+ trades=trades,
+ config=SimpleNamespace(experiment_name="test", strategy_parameters={}),
+ )
+ fake = _FakeStreamlit()
+
+ exported: dict[str, object] = {}
+
+ def _capture_download_button(*args: object, **kwargs: object) -> None:
+ exported["csv_bytes"] = args[1] if len(args) > 1 else kwargs.get("data")
+
+ fake.download_button = _capture_download_button # type: ignore[method-assign]
+
+ render_trade_table(fake, result)
+
+ assert "csv_bytes" in exported
+ exported_csv = cast(bytes, exported["csv_bytes"]).decode("utf-8")
+ header = exported_csv.splitlines()[0]
+ assert header.split(",") == TRADE_LOG_COLUMNS
+
+
+def test_trade_table_stop_loss_take_profit_caption_shown_even_with_zero_triggers() -> (
+ None
+):
+ """Both thresholds configured but neither ever fired (0 triggers) must
+ still show the caption -- a configured-but-never-triggered stop/target
+ is meaningful information, not the same as "not configured at all"."""
+ trades = pd.DataFrame.from_records([_trade_row()])
+ result: Any = SimpleNamespace(
+ trades=trades,
+ config=SimpleNamespace(
+ experiment_name="test",
+ strategy_parameters={"stop_loss_pct": 0.1, "take_profit_pct": 0.2},
+ ),
+ )
+ fake = _FakeStreamlit()
+
+ render_trade_table(fake, result)
+
+ assert len(fake.caption_calls) == 1
+ text = str(fake.caption_calls[0][0])
+ assert "Stop-loss affected" in text
+ assert "take-profit affected" in text
+
+
+def test_trade_table_caption_only_mentions_stop_loss_when_take_profit_disabled() -> (
+ None
+):
+ """Enabling only stop_loss_pct must produce a caption about stop-loss
+ alone -- it must never imply take-profit was also active at 0."""
+ trades = pd.DataFrame.from_records([_trade_row()])
+ result: Any = SimpleNamespace(
+ trades=trades,
+ config=SimpleNamespace(
+ experiment_name="test",
+ strategy_parameters={"stop_loss_pct": 0.1, "take_profit_pct": None},
+ ),
+ )
+ fake = _FakeStreamlit()
+
+ render_trade_table(fake, result)
+
+ assert len(fake.caption_calls) == 1
+ text = str(fake.caption_calls[0][0])
+ # The trailing counting-convention caveat generically names both terms
+ # regardless of which is configured -- only the leading clause (before
+ # it) must be scoped to what's actually enabled.
+ leading_clause = text.split(" -- counted", 1)[0]
+ assert "Stop-loss affected" in leading_clause
+ assert "take-profit" not in leading_clause.lower()
+
+
+def test_trade_table_caption_only_mentions_take_profit_when_stop_loss_disabled() -> (
+ None
+):
+ """Symmetric case: only take_profit_pct configured -- the caption must
+ lead with "Take-profit" (capitalized, since it is now the first
+ clause) and never mention stop-loss."""
+ trades = pd.DataFrame.from_records([_trade_row()])
+ result: Any = SimpleNamespace(
+ trades=trades,
+ config=SimpleNamespace(
+ experiment_name="test",
+ strategy_parameters={"stop_loss_pct": None, "take_profit_pct": 0.2},
+ ),
+ )
+ fake = _FakeStreamlit()
+
+ render_trade_table(fake, result)
+
+ assert len(fake.caption_calls) == 1
+ text = str(fake.caption_calls[0][0])
+ leading_clause = text.split(" -- counted", 1)[0]
+ assert text.startswith("Take-profit affected")
+ assert "stop-loss" not in leading_clause.lower()
+
+
+def test_trade_table_no_caption_when_neither_stop_loss_nor_take_profit_configured() -> (
+ None
+):
+ """Neither threshold configured -- no caption at all, matching a
+ strategy that never declared either parameter."""
+ trades = pd.DataFrame.from_records([_trade_row()])
+ result: Any = SimpleNamespace(
+ trades=trades,
+ config=SimpleNamespace(experiment_name="test", strategy_parameters={}),
+ )
+ fake = _FakeStreamlit()
+
+ render_trade_table(fake, result)
+
+ assert fake.caption_calls == []
+
+
@pytest.mark.parametrize(
("signal_scaling", "expected_options"),
[
@@ -378,6 +821,46 @@ def test_allocator_options_match_time_series_momentum_scaling(
assert allocator.value in expected_options
+@pytest.mark.parametrize(
+ ("signal_scaling", "expected_options"),
+ [
+ (
+ "binary",
+ [
+ "equal_weight",
+ "signal_proportional",
+ "inverse_volatility",
+ "volatility_targeting",
+ ],
+ ),
+ (
+ "continuous",
+ ["signal_proportional", "inverse_volatility", "volatility_targeting"],
+ ),
+ ],
+)
+def test_allocator_options_match_cross_sectional_momentum_scaling(
+ signal_scaling: str, expected_options: list[str]
+) -> None:
+ """Regression test: the dashboard must mirror ExperimentConfig's own
+ validator (config.py's "Non-binary cross_sectional_momentum signals
+ require an allocator that preserves signal magnitude; equal_weight
+ keeps only signs") -- equal_weight was newly reachable in the UI for
+ 'continuous' scaling even though the backend has always rejected it,
+ failing only after "Run backtest" instead of narrowing the choice
+ up front, exactly like the sibling time_series_momentum case already
+ does."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _sidebar_selectbox(at, "Strategy").set_value("cross_sectional_momentum").run()
+ _sidebar_selectbox(at, "Signal scaling").set_value(signal_scaling).run()
+ assert not at.exception
+
+ allocator = _sidebar_selectbox(at, "Allocator")
+ assert allocator.options == expected_options
+ assert allocator.value in expected_options
+
+
def test_pairs_trading_symbol_inputs_render_without_crash() -> None:
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
@@ -415,7 +898,7 @@ def test_bundled_demo_csvs_require_an_explicit_dashboard_opt_in() -> None:
_configure_offline_pairs_trade(at)
_sidebar_toggle(at, "Allow bundled synthetic demo data").set_value(True).run()
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
assert not at.error
@@ -464,13 +947,17 @@ def test_strategy_widget_dispatch_uses_exact_registered_names(
_sidebar_selectbox(at, "Strategy").set_value(custom_name).run()
assert not at.exception
- assert not any(slider.label == "Entry z-score" for slider in at.sidebar.slider)
+ assert not any(slider.label == "Entry threshold" for slider in at.sidebar.slider)
@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"])
def test_reversion_exit_slider_stays_strictly_below_entry(
strategy_name: str,
) -> None:
+ """Both mean_reversion and pairs_trading use the generic 'Entry/Exit
+ threshold' labels -- both now support the same zscore/rsi/percentile
+ indicator choice, not only a z-score."""
+ entry_label, exit_label = "Entry threshold", "Exit threshold"
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
if strategy_name == "pairs_trading":
@@ -479,13 +966,9 @@ def test_reversion_exit_slider_stays_strictly_below_entry(
).run()
_sidebar_selectbox(at, "Strategy").set_value(strategy_name).run()
- entry = next(
- slider for slider in at.sidebar.slider if slider.label == "Entry z-score"
- )
+ entry = next(slider for slider in at.sidebar.slider if slider.label == entry_label)
entry.set_value(1.0).run()
- exit_ = next(
- slider for slider in at.sidebar.slider if slider.label == "Exit z-score"
- )
+ exit_ = next(slider for slider in at.sidebar.slider if slider.label == exit_label)
exit_max = exit_.proto.max
entry_value = cast(float, entry.value)
@@ -493,6 +976,155 @@ def test_reversion_exit_slider_stays_strictly_below_entry(
assert exit_max < entry_value
+@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"])
+@pytest.mark.parametrize("indicator", ["rsi", "percentile"])
+def test_entry_at_max_never_crashes_the_stop_slider(
+ strategy_name: str, indicator: str
+) -> None:
+ """For rsi/percentile, entry_threshold's own slider max equals
+ stop_threshold's max -- dragging entry all the way up must not push the
+ stop slider's min (entry + step) past its own max and crash it."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ if strategy_name == "pairs_trading":
+ _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value(
+ "AAA, BBB"
+ ).run()
+ _sidebar_selectbox(at, "Strategy").set_value(strategy_name).run()
+ _sidebar_selectbox(at, "Indicator").set_value(indicator).run()
+
+ entry = next(
+ slider for slider in at.sidebar.slider if slider.label == "Entry threshold"
+ )
+ entry.set_value(entry.proto.max).run()
+
+ assert not at.exception
+ stop = next(
+ slider for slider in at.sidebar.slider if slider.label == "Stop threshold"
+ )
+ # Streamlit's own slider requires min STRICTLY less than max (min ==
+ # max raises too) -- the off-by-one this test originally missed.
+ assert stop.proto.min < stop.proto.max
+
+
+@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"])
+@pytest.mark.parametrize("indicator", ["rsi", "percentile"])
+def test_entry_at_min_never_crashes_the_exit_slider(
+ strategy_name: str, indicator: str
+) -> None:
+ """Dragging entry all the way down to its own min must never crash --
+ for `percentile`, entry's own min equals the exit slider's step, which
+ pushes the exit slider's own max (entry - step) down to exactly 0.0:
+ a degenerate range Streamlit's slider would reject (min == max), so
+ the dashboard shows a fixed 0.0 caption instead of rendering a slider
+ at all. For `rsi`, entry's min stays comfortably above `step`, so an
+ ordinary, non-degenerate exit slider still renders."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ if strategy_name == "pairs_trading":
+ _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value(
+ "AAA, BBB"
+ ).run()
+ _sidebar_selectbox(at, "Strategy").set_value(strategy_name).run()
+ _sidebar_selectbox(at, "Indicator").set_value(indicator).run()
+
+ entry = next(
+ slider for slider in at.sidebar.slider if slider.label == "Entry threshold"
+ )
+ entry.set_value(entry.proto.min).run()
+
+ assert not at.exception
+ exit_sliders = [s for s in at.sidebar.slider if s.label == "Exit threshold"]
+ if indicator == "percentile":
+ # Degenerate case: entry.min == step, so exit_threshold's only
+ # valid value (0.0) is shown as a caption, never a slider.
+ assert exit_sliders == []
+ assert any(
+ "Exit threshold: 0.0" in caption.value for caption in at.sidebar.caption
+ )
+ else:
+ assert len(exit_sliders) == 1
+ assert exit_sliders[0].proto.min < exit_sliders[0].proto.max
+
+
+@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"])
+def test_entry_threshold_reaches_its_own_step_sized_minimum(
+ strategy_name: str,
+) -> None:
+ """`percentile`'s own YAML/Python-valid minimum, 0.01 (== its step),
+ must be reachable through the dashboard slider -- an earlier version
+ unconditionally floored entry's own min at `2 * step` (0.02) to dodge
+ the exit slider's degenerate near-zero case, silently making a valid
+ configuration value unreachable through the UI."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ if strategy_name == "pairs_trading":
+ _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value(
+ "AAA, BBB"
+ ).run()
+ _sidebar_selectbox(at, "Strategy").set_value(strategy_name).run()
+ _sidebar_selectbox(at, "Indicator").set_value("percentile").run()
+
+ entry = next(
+ slider for slider in at.sidebar.slider if slider.label == "Entry threshold"
+ )
+ assert entry.proto.min == pytest.approx(0.01)
+
+
+@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"])
+@pytest.mark.parametrize(
+ ("indicator", "full_max"), [("rsi", 50.0), ("percentile", 0.49)]
+)
+def test_entry_domain_keeps_its_full_range_when_stop_is_disabled(
+ strategy_name: str, indicator: str, full_max: float
+) -> None:
+ """Entry threshold's own max must only be narrowed to protect the stop
+ slider (min == entry + step must stay < stop_max) WHILE that slider
+ actually exists -- with "Enable stop threshold" off, entry must keep
+ its full original range (rsi up to 50, percentile up to 0.49), not
+ silently lose reachable values guarding a slider that isn't rendered."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ if strategy_name == "pairs_trading":
+ _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value(
+ "AAA, BBB"
+ ).run()
+ _sidebar_selectbox(at, "Strategy").set_value(strategy_name).run()
+ _sidebar_selectbox(at, "Indicator").set_value(indicator).run()
+ stop_checkbox = next(
+ box
+ for box in at.sidebar.checkbox
+ if box.label in ("Enable stop threshold", "stop_threshold enabled")
+ )
+ stop_checkbox.set_value(False).run()
+
+ entry = next(
+ slider for slider in at.sidebar.slider if slider.label == "Entry threshold"
+ )
+ assert entry.proto.max == pytest.approx(full_max)
+
+
+@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"])
+def test_entry_domain_is_narrowed_when_stop_is_enabled(strategy_name: str) -> None:
+ """The mirror of the above: with "Enable stop threshold" on (the
+ default), entry's max IS narrowed below rsi's full 50 -- proving the
+ restriction is actually conditional on the checkbox, not just always
+ off."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ if strategy_name == "pairs_trading":
+ _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value(
+ "AAA, BBB"
+ ).run()
+ _sidebar_selectbox(at, "Strategy").set_value(strategy_name).run()
+ _sidebar_selectbox(at, "Indicator").set_value("rsi").run()
+
+ entry = next(
+ slider for slider in at.sidebar.slider if slider.label == "Entry threshold"
+ )
+ assert entry.proto.max == pytest.approx(48.0)
+
+
def test_holdout_controls_do_not_claim_automatic_tuning() -> None:
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
@@ -531,7 +1163,7 @@ def test_dashboard_can_disable_volatility_targeting_and_set_risk_free_rate() ->
_configure_offline_pairs_trade(at)
_sidebar_toggle(at, "Enable volatility targeting").set_value(False).run()
_sidebar_number_input(at, "Risk-free rate (annual %)").set_value(3.5).run()
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
assert not at.error
@@ -549,12 +1181,12 @@ def test_failed_backtest_invalidates_previous_result() -> None:
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
_configure_offline_pairs_trade(at)
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert "result" in at.session_state
_sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY").run()
- assert at.sidebar.button[0].proto.disabled is False
- at.sidebar.button[0].click().run()
+ assert _run_button(at).proto.disabled is False
+ _run_button(at).click().run()
assert not at.exception
assert any("Backtest failed" in error.value for error in at.error)
@@ -568,7 +1200,7 @@ def test_successful_pairs_backtest_creates_expected_tab_navigation() -> None:
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
_configure_offline_pairs_trade(at)
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
assert not at.error
@@ -578,6 +1210,79 @@ def test_successful_pairs_backtest_creates_expected_tab_navigation() -> None:
assert len(at.tabs[0].metric) > 0
+def test_trades_tab_shows_action_and_separate_reason_columns() -> None:
+ """The Trades tab must show action/trigger/adjustment/position-origin
+ as separate, human-labelled columns in the fixed display order --
+ never a bare buy/sell-only table, and never a fused "code (detail)"
+ string (that silently dropped a detail code from this table's own
+ native CSV export icon -- a real regression a user hit)."""
+ from quantlab.dashboard.components import _TRADE_TABLE_DISPLAY_COLUMNS
+
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ # pairs_trading on this bundled synthetic (independent-random-walk) CSV
+ # data never finds a tradeable spread -- use mean_reversion instead,
+ # over a wide enough window to actually generate fills, so this test
+ # exercises a populated table, not the empty-state "no trades" message.
+ _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY, QQQ").run()
+ _sidebar_date_input(at, "End date").set_value(datetime.date(2020, 6, 1)).run()
+ _sidebar_selectbox(at, "Strategy").set_value("mean_reversion").run()
+ _run_button(at).click().run()
+ assert not at.exception
+
+ at.session_state["dashboard_active_tab"] = "Trades"
+ _run_button(at).click().run()
+ assert not at.exception
+
+ trades_tab = at.tabs[1]
+ assert trades_tab.label == "Trades"
+ assert len(trades_tab.dataframe) == 1
+ table = trades_tab.dataframe[0].value
+ # Fixed schema: same 15 columns, same order, regardless of strategy.
+ assert list(table.columns) == _TRADE_TABLE_DISPLAY_COLUMNS
+ assert len(table) > 0
+ # Display labels are the DataFrame's own column names for most
+ # columns; the few with extra column_config (formatting/help) still
+ # carry that same name as their label, never a different alias.
+ column_config = json.loads(trades_tab.dataframe[0].proto.columns)
+ assert column_config["Trigger"]["label"] == "Trigger"
+ assert column_config["Trigger detail"]["label"] == "Trigger detail"
+ assert column_config["Adjustments"]["label"] == "Adjustments"
+ assert column_config["Position origin"]["label"] == "Position origin"
+ assert column_config["Position origin date"]["label"] == "Position origin date"
+ # Every real action must resolve to one of _classify_action's own
+ # labels, never a leftover placeholder -- confirms the engine's new
+ # frames actually reached build_trade_log, not just that a "side"
+ # column silently carried the table alone.
+ from quantlab.backtesting.trade_log import _classify_action
+
+ valid_actions = {
+ _classify_action(p, n)
+ for p in (-1.0, -0.5, 0.0, 0.5, 1.0)
+ for n in (-1.0, -0.5, 0.0, 0.5, 1.0)
+ }
+ assert set(table["Action"]) <= valid_actions
+ # Trigger is never fused with Trigger detail -- no row's Trigger
+ # contains a literal "(" -- and at least one row has a precise
+ # Trigger detail (mean_reversion's own explain_signals()) plus at
+ # least one strategy_signal row's Details text keeps the generic
+ # "signal X -> Y since last rebalance" text. Also confirms the core
+ # fix: at least one entry keeps its strategy_signal trigger even
+ # though a real constraint also fired as an adjustment on it.
+ assert table["Trigger"].notna().any()
+ assert not any("(" in str(value) for value in table["Trigger"])
+ assert table["Trigger detail"].notna().any()
+ strategy_signal_details = table.loc[
+ table["Trigger"] == "strategy_signal", "Details"
+ ]
+ assert strategy_signal_details.notna().all()
+ assert all(
+ "since last rebalance" in str(value) for value in strategy_signal_details
+ )
+ entries = table[table["Action"] == "entry_long"]
+ assert (entries["Trigger"] == "strategy_signal").any()
+
+
def test_robustness_tab_shows_holdout_table_when_enabled() -> None:
"""With holdout ticked, the Robustness tab must show a real data table,
not just the "no holdout attached" placeholder."""
@@ -594,7 +1299,7 @@ def test_robustness_tab_shows_holdout_table_when_enabled() -> None:
# click, so the tab is selected directly via the widget's own session
# state key instead (see `app.py`'s `st.tabs(..., key=...)`).
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
robustness_tab = at.tabs[2]
@@ -615,7 +1320,7 @@ def test_robustness_tab_shows_placeholder_without_holdout() -> None:
at.run()
_configure_offline_pairs_trade(at)
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
robustness_tab = at.tabs[2]
@@ -629,7 +1334,7 @@ def test_failed_stress_run_clears_previous_stress_evidence() -> None:
at.run()
_configure_offline_pairs_trade(at)
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
result = at.session_state["result"]
@@ -656,7 +1361,7 @@ def test_stale_result_warning_shown_after_sidebar_change() -> None:
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
_configure_offline_pairs_trade(at)
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not any("configuration has changed" in w.value for w in at.warning)
_sidebar_selectbox(at, "Strategy").set_value("mean_reversion").run()
@@ -670,7 +1375,7 @@ def test_frequency_mismatch_shown_as_prominent_error_not_small_caption() -> None
_sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY, QQQ").run()
_sidebar_date_input(at, "End date").set_value(datetime.date(2019, 6, 1)).run()
_sidebar_selectbox(at, "Frequency").set_value("1h").run()
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
assert any("Frequency mismatch detected" in e.value for e in at.error)
@@ -690,7 +1395,7 @@ def test_only_the_open_tab_renders_its_content() -> None:
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
_configure_offline_pairs_trade(at)
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
results_tab = at.tabs[0]
@@ -711,7 +1416,7 @@ def test_switching_the_open_tab_renders_its_content_instead() -> None:
at.run()
_configure_offline_pairs_trade(at)
at.session_state["dashboard_active_tab"] = "Trades"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
trades_tab = at.tabs[1]
@@ -733,7 +1438,7 @@ def boom(_result: Any) -> Any:
at.run()
_configure_offline_pairs_trade(at)
at.session_state["dashboard_active_tab"] = "Report"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
report_tab = at.tabs[3]
@@ -749,7 +1454,7 @@ def test_report_tab_includes_stress_tests_run_in_robustness_tab() -> None:
at.run()
_configure_offline_pairs_trade(at)
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
robustness_tab = at.tabs[2]
@@ -798,7 +1503,7 @@ def test_advanced_data_settings_are_accepted_by_the_config() -> None:
_sidebar_number_input(at, "Forward-fill limit (consecutive bars)").set_value(
3
).run()
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
assert not at.error
@@ -832,7 +1537,7 @@ def test_advanced_execution_settings_are_accepted_by_the_config() -> None:
_configure_offline_pairs_trade(at)
_sidebar_selectbox(at, "Slippage model").set_value("volume").run()
_sidebar_number_input(at, "Volume impact coefficient").set_value(0.25).run()
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
assert not at.error
@@ -869,10 +1574,10 @@ def test_advanced_portfolio_constraints_are_accepted_by_the_config() -> None:
_sidebar_checkbox(at, "Cap gross exposure").set_value(True).run()
_sidebar_checkbox(at, "Cap net exposure").set_value(True).run()
_sidebar_checkbox(at, "Cap number of positions").set_value(True).run()
- _sidebar_checkbox(at, "Cap turnover per rebalance").set_value(True).run()
+ _sidebar_checkbox(at, "Cap turnover per period").set_value(True).run()
assert not at.exception
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
assert not at.error
portfolio = at.session_state["result"].config.portfolio
@@ -887,7 +1592,7 @@ def test_gross_vs_net_section_renders_with_real_values() -> None:
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
_sidebar_date_input(at, "End date").set_value(datetime.date(2019, 6, 1)).run()
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
assert not at.error
@@ -1088,7 +1793,7 @@ def test_walk_forward_run_populates_oos_result_and_tabs() -> None:
at = AppTest.from_file(APP_PATH, default_timeout=120)
at.run()
_configure_offline_walk_forward(at)
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
assert not at.error
@@ -1114,7 +1819,7 @@ def test_walk_forward_robustness_tab_shows_fold_table_and_stability() -> None:
at, "Candidate values for lookback_period (comma-separated)"
).set_value("10, 20").run()
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
robustness_tab = at.tabs[2]
@@ -1134,7 +1839,7 @@ def test_walk_forward_report_tab_includes_fold_evidence() -> None:
at.run()
_configure_offline_walk_forward(at)
at.session_state["dashboard_active_tab"] = "Report"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
report_tab = at.tabs[3]
@@ -1150,7 +1855,7 @@ def test_walk_forward_no_fitting_fold_shows_error_not_crash() -> None:
_sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY, QQQ").run()
_sidebar_date_input(at, "End date").set_value(datetime.date(2019, 6, 1)).run()
_sidebar_number_input(at, "Train window (periods)").set_value(10_000).run()
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
assert any("No walk-forward fold fit" in e.value for e in at.error)
@@ -1163,7 +1868,7 @@ def test_switching_to_backtest_mode_does_not_affect_a_stored_walk_forward_result
at = AppTest.from_file(APP_PATH, default_timeout=120)
at.run()
_configure_offline_walk_forward(at)
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
assert "wf_result" in at.session_state
@@ -1188,7 +1893,7 @@ def test_backtest_robustness_tab_bootstrap_runs_and_displays() -> None:
at.run()
_configure_offline_pairs_trade(at)
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
n_iterations = next(
@@ -1215,7 +1920,7 @@ def test_backtest_robustness_tab_permutation_test_runs_and_displays() -> None:
at.run()
_configure_offline_pairs_trade(at)
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
n_iterations = next(
@@ -1240,7 +1945,7 @@ def test_backtest_robustness_tab_sensitivity_runs_and_displays_heatmap() -> None
at.run()
_configure_offline_mean_reversion(at)
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
robustness_tab = at.tabs[2]
@@ -1259,7 +1964,7 @@ def test_backtest_robustness_tab_sensitivity_runs_and_displays_heatmap() -> None
y_select = next(
sb for sb in robustness_tab.selectbox if sb.label == "Parameter (y-axis)"
)
- y_select.set_value("entry_zscore").run()
+ y_select.set_value("entry_threshold").run()
robustness_tab = at.tabs[2]
y_values = next(
f
@@ -1290,7 +1995,7 @@ def test_walk_forward_robustness_tab_bootstrap_runs_and_displays() -> None:
at.run()
_configure_offline_walk_forward(at)
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
n_iterations = next(
@@ -1316,7 +2021,7 @@ def test_walk_forward_robustness_tab_permutation_test_runs_and_displays() -> Non
at.run()
_configure_offline_walk_forward(at)
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
n_iterations = next(
@@ -1340,7 +2045,7 @@ def test_walk_forward_robustness_tab_stress_tests_reruns_selection() -> None:
at.run()
_configure_offline_walk_forward(at)
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
stress_button = next(b for b in at.tabs[2].button if b.label == "Run stress tests")
@@ -1358,7 +2063,7 @@ def test_backtest_run_all_robustness_tests_populates_every_technique() -> None:
at.run()
_configure_offline_mean_reversion(at)
at.session_state["dashboard_active_tab"] = "Robustness"
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert not at.exception
robustness_tab = at.tabs[2]
@@ -1382,7 +2087,7 @@ def test_backtest_run_all_robustness_tests_populates_every_technique() -> None:
robustness_tab = at.tabs[2]
next(
sb for sb in robustness_tab.selectbox if sb.label == "Parameter (y-axis)"
- ).set_value("entry_zscore").run()
+ ).set_value("entry_threshold").run()
robustness_tab = at.tabs[2]
next(
f
@@ -1414,7 +2119,7 @@ def test_conflicting_symbol_across_pickers_blocks_submission_with_error() -> Non
in app.py)."""
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
- assert at.sidebar.button[0].proto.disabled is False
+ assert _run_button(at).proto.disabled is False
yahoo_ms = _sidebar_multiselect_by_key(at, "yahoo_symbols")
# SPY is already in CSV's default population (SPY, QQQ, TLT, GLD).
@@ -1422,7 +2127,7 @@ def test_conflicting_symbol_across_pickers_blocks_submission_with_error() -> Non
assert not at.exception
assert any("ambiguous" in e.value and "SPY" in e.value for e in at.sidebar.error)
- assert at.sidebar.button[0].proto.disabled is True
+ assert _run_button(at).proto.disabled is True
def test_instrument_source_comes_from_picker_provenance_not_a_heuristic(
@@ -1435,10 +2140,10 @@ def test_instrument_source_comes_from_picker_provenance_not_a_heuristic(
own table, so this exercises the real
sidebar -> `_collect_inputs` -> `build_config_from_inputs` pipeline.
- `run_dashboard_backtest` is monkeypatched to capture the config and
- raise immediately (before any data loading happens), so this stays
- fully offline — real Binance OHLCV data is never fetched, only its
- (also monkeypatched) symbol-suggestion list."""
+ `run_dashboard_backtest_with_data` is monkeypatched to capture the
+ config and raise immediately (before any data loading happens), so
+ this stays fully offline — real Binance OHLCV data is never fetched,
+ only its (also monkeypatched) symbol-suggestion list."""
import quantlab.dashboard.state as state_module
from quantlab.config import DataSourceName
from quantlab.data.base import SymbolSuggestion
@@ -1453,21 +2158,24 @@ def test_instrument_source_comes_from_picker_provenance_not_a_heuristic(
captured: dict[str, Any] = {}
- def fake_run_dashboard_backtest(config: Any) -> Any:
+ def fake_run_dashboard_backtest_with_data(config: Any) -> Any:
captured["config"] = config
raise RuntimeError("stop before real data loading")
monkeypatch.setattr(
- state_module, "run_dashboard_backtest", fake_run_dashboard_backtest
+ state_module,
+ "run_dashboard_backtest_with_data",
+ fake_run_dashboard_backtest_with_data,
)
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
_sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("").run()
+ _load_binance_universe(at)
binance_ms = _sidebar_multiselect_by_key(at, "binance_symbols")
binance_ms.set_value([binance_ms.options[0]]).run()
- assert at.sidebar.button[0].proto.disabled is False
- at.sidebar.button[0].click().run()
+ assert _run_button(at).proto.disabled is False
+ _run_button(at).click().run()
assert "config" in captured
instruments = captured["config"].data.instruments
@@ -1504,6 +2212,7 @@ def test_frequency_options_reflect_selected_sources(
)
assert freq.options == expected_csv_only
+ _load_binance_universe(at)
binance_ms = _sidebar_multiselect_by_key(at, "binance_symbols")
binance_ms.set_value([binance_ms.options[0]]).run()
@@ -1553,6 +2262,7 @@ def test_mixed_calendar_warning_and_periods_per_year_field_appear_together(
for f in at.sidebar.number_input
)
+ _load_binance_universe(at)
binance_ms = _sidebar_multiselect_by_key(at, "binance_symbols")
binance_ms.set_value([binance_ms.options[0]]).run()
@@ -1569,7 +2279,7 @@ def test_mixed_calendar_warning_and_periods_per_year_field_appear_together(
# so the business-day equity convention would understate its real
# trading frequency.
assert periods_field.value == 365
- assert at.sidebar.button[0].proto.disabled is False
+ assert _run_button(at).proto.disabled is False
def test_mixed_calendar_universe_excludes_intraday_frequency(
@@ -1594,6 +2304,7 @@ def test_mixed_calendar_universe_excludes_intraday_frequency(
freq = next(sb for sb in at.sidebar.selectbox if sb.label == "Frequency")
assert "1h" in freq.options # CSV's default population is all XNYS
+ _load_binance_universe(at)
binance_ms = _sidebar_multiselect_by_key(at, "binance_symbols")
binance_ms.set_value([binance_ms.options[0]]).run()
@@ -1611,9 +2322,9 @@ def test_periods_per_year_value_flows_into_the_built_config(
"""Complements the sidebar-only check above: an explicit
"Periods per year" value, entered while the universe spans more than
one calendar, actually reaches `BacktestConfig.periods_per_year` via
- `_collect_inputs` / `build_config_from_inputs`. `run_dashboard_backtest`
- is monkeypatched to capture the config before any data loading, keeping
- this offline."""
+ `_collect_inputs` / `build_config_from_inputs`.
+ `run_dashboard_backtest_with_data` is monkeypatched to capture the
+ config before any data loading, keeping this offline."""
import quantlab.dashboard.state as state_module
from quantlab.data.base import SymbolSuggestion
from quantlab.data.binance import BinanceDataSource
@@ -1627,16 +2338,19 @@ def test_periods_per_year_value_flows_into_the_built_config(
captured: dict[str, Any] = {}
- def fake_run_dashboard_backtest(config: Any) -> Any:
+ def fake_run_dashboard_backtest_with_data(config: Any) -> Any:
captured["config"] = config
raise RuntimeError("stop before real data loading")
monkeypatch.setattr(
- state_module, "run_dashboard_backtest", fake_run_dashboard_backtest
+ state_module,
+ "run_dashboard_backtest_with_data",
+ fake_run_dashboard_backtest_with_data,
)
at = AppTest.from_file(APP_PATH, default_timeout=60)
at.run()
+ _load_binance_universe(at)
binance_ms = _sidebar_multiselect_by_key(at, "binance_symbols")
binance_ms.set_value([binance_ms.options[0]]).run()
periods_field = next(
@@ -1645,7 +2359,7 @@ def fake_run_dashboard_backtest(config: Any) -> Any:
if f.label == "Periods per year (annualisation factor)"
)
periods_field.set_value(365).run()
- at.sidebar.button[0].click().run()
+ _run_button(at).click().run()
assert "config" in captured
assert captured["config"].backtest.periods_per_year == 365
@@ -1687,3 +2401,615 @@ def test_benchmark_symbol_not_matching_any_instrument_shows_source_and_calendar(
calendar = next(f for f in at.sidebar.text_input if f.label == "Benchmark calendar")
assert calendar.key == "benchmark_calendar_input"
assert calendar.value == "XNYS"
+
+
+def _switch_to_strategies_mode(at: AppTest) -> AppTest:
+ at.segmented_control[0].set_value("Strategies").run()
+ return at
+
+
+def test_strategies_mode_shows_gallery_with_one_card_per_strategy() -> None:
+ from quantlab.strategies.base import available_strategies
+
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _switch_to_strategies_mode(at)
+ assert not at.exception
+
+ strategies = available_strategies()
+ open_buttons = {
+ b.key for b in at.button if b.key and b.key.startswith("explorer_open_")
+ }
+ assert open_buttons == {f"explorer_open_{name}" for name in strategies}
+
+
+def test_strategies_mode_hides_the_sidebar() -> None:
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ assert len(at.sidebar.children) > 0 # Backtest mode: sidebar populated
+ _switch_to_strategies_mode(at)
+ assert len(at.sidebar.children) == 0
+
+
+@pytest.mark.parametrize(
+ "strategy_name",
+ [
+ "buy_and_hold",
+ "pairs_trading",
+ "mean_reversion",
+ "time_series_momentum",
+ "cross_sectional_momentum",
+ "trend_following",
+ ],
+)
+def test_strategy_detail_page_opens_with_every_section(strategy_name: str) -> None:
+ """Opening any registered strategy's detail page must not raise, and
+ must show every common documented section. References / Further
+ reading is optional -- required only when that strategy's profile
+ actually sets ``references_md`` (e.g. Buy & Hold deliberately has none;
+ see `test_buy_and_hold_detail_page_has_no_references_section` below).
+ The interactive lab lives in a lazy expander (see `detail.py`) and is
+ collapsed by default, so its own body does NOT run here -- see
+ `test_strategy_lab_opens_and_runs_without_exception` below for that."""
+ from quantlab.dashboard.explorer.profile import get_profile
+
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _switch_to_strategies_mode(at)
+ at.button(key=f"explorer_open_{strategy_name}").click().run()
+
+ assert not at.exception
+ assert at.session_state["explorer_strategy"] == strategy_name
+ expander_labels = {e.label for e in at.expander}
+ assert expander_labels >= {
+ "Overview",
+ "Economic intuition",
+ "Mathematical definition & signals",
+ "Assumptions",
+ "Diagnostics",
+ "Parameters",
+ "Interactive laboratory",
+ "Interpretation",
+ "Limitations & failure modes",
+ }
+ profile = get_profile(strategy_name)
+ assert profile is not None
+ has_references_section = "References / Further reading" in expander_labels
+ assert has_references_section == (profile.references_md is not None)
+
+
+def test_buy_and_hold_detail_page_has_no_references_section() -> None:
+ """Buy & Hold's profile deliberately sets ``references_md=None`` -- no
+ strategy-specific literature was genuinely indispensable for the
+ zero-skill baseline it describes -- so its detail page must not show a
+ References / Further reading section at all."""
+ from quantlab.dashboard.explorer.profile import get_profile
+
+ profile = get_profile("buy_and_hold")
+ assert profile is not None
+ assert profile.references_md is None
+
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _switch_to_strategies_mode(at)
+ at.button(key="explorer_open_buy_and_hold").click().run()
+ assert not at.exception
+ expander_labels = {e.label for e in at.expander}
+ assert "References / Further reading" not in expander_labels
+
+
+@pytest.mark.parametrize(
+ "strategy_name",
+ [
+ "buy_and_hold",
+ "pairs_trading",
+ "mean_reversion",
+ "time_series_momentum",
+ "cross_sectional_momentum",
+ "trend_following",
+ ],
+)
+def test_strategy_lab_opens_and_runs_without_exception(strategy_name: str) -> None:
+ """Actually opening each strategy's Interactive laboratory expander (not
+ just visiting the detail page -- see the lazy-expander note on the test
+ above) must run its full default body without exception, on real
+ bundled offline data."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _switch_to_strategies_mode(at)
+ at.button(key=f"explorer_open_{strategy_name}").click().run()
+ assert not at.exception
+
+ at.session_state[f"explorer_lab_expander_{strategy_name}"] = True
+ at.run()
+ assert not at.exception
+
+
+def _open_lab(at: AppTest, strategy_name: str) -> AppTest:
+ _switch_to_strategies_mode(at)
+ at.button(key=f"explorer_open_{strategy_name}").click().run()
+ at.session_state[f"explorer_lab_expander_{strategy_name}"] = True
+ at.run()
+ return at
+
+
+def test_lab_symbol_picker_defaults_to_csv_with_bundled_demo_data_on() -> None:
+ """Unlike the main sidebar (`use_bundled_demo_data` defaults to False),
+ a lab defaults it to True so it keeps working fully offline with no
+ setup -- see `render_symbol_and_source_picker`."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _open_lab(at, "mean_reversion")
+ assert not at.exception
+
+ source = next(r for r in at.radio if r.key == "explorer_mr_source")
+ assert source.value == "csv"
+ csv_input = next(
+ f for f in at.text_input if f.key == "explorer_mr_csv_symbols_input"
+ )
+ assert csv_input.value == "SPY, QQQ, TLT, GLD"
+ bundled_toggle = next(
+ t for t in at.toggle if t.key == "explorer_mr_use_bundled_demo_data"
+ )
+ assert bundled_toggle.value is True
+
+
+@pytest.mark.parametrize(
+ ("calendar", "expected"),
+ [("24/7", 365), ("XNYS", 252), ("XHKG", 252)],
+)
+def test_tsmom_lab_periods_per_year_derives_from_the_calendar(
+ calendar: str, expected: int
+) -> None:
+ """Regression test: the Time-Series Momentum lab's volatility_adjusted
+ panel used to annualise at a hardcoded 252 regardless of the selected
+ calendar -- for a 24/7 market this silently mis-annualised the
+ illustrative volatility, unlike the real strategy (`periods_per_year`
+ is injected from the experiment's own data frequency: 365 for daily
+ crypto). Tested directly (no Streamlit runtime needed) since AppTest
+ cannot introspect a rendered Plotly chart's own title/values in this
+ Streamlit version."""
+ from quantlab.dashboard.explorer.labs.time_series_momentum import (
+ _periods_per_year_for_calendar,
+ )
+
+ assert _periods_per_year_for_calendar(calendar) == expected
+
+
+def test_tsmom_lab_accepts_an_overridden_24_7_calendar_without_exception() -> None:
+ """End-to-end smoke check that the new editable "Calendar" field (see
+ render_symbol_and_source_picker) actually reaches the lab and the
+ 24/7 branch renders without exception -- avoids depending on live
+ Binance network access just to exercise this calendar."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _open_lab(at, "time_series_momentum")
+ assert not at.exception
+
+ calendar_input = next(
+ f for f in at.text_input if f.key == "explorer_tsmom_csv_calendar"
+ )
+ calendar_input.set_value("24/7").run()
+ assert not at.exception
+
+
+def test_lab_symbol_picker_switching_to_yahoo_shows_the_yahoo_picker() -> None:
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _open_lab(at, "mean_reversion")
+
+ source = next(r for r in at.radio if r.key == "explorer_mr_source")
+ at.session_state["explorer_lab_expander_mean_reversion"] = True
+ source.set_value("yahoo").run()
+ assert not at.exception
+ assert not any(f.key == "explorer_mr_csv_symbols_input" for f in at.text_input)
+ assert any(ms.key == "explorer_mr_yahoo_symbols" for ms in at.multiselect)
+
+
+def test_lab_csv_calendar_defaults_to_xnys_and_is_editable() -> None:
+ """Regression test: a lab's CSV symbol picker used to always assume
+ XNYS with no way to change it -- CSV data carries no calendar
+ information at all, so a non-XNYS local instrument (futures, a
+ non-US index) needs an explicit override, same as the main
+ dashboard's own per-instrument table."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _open_lab(at, "mean_reversion")
+ assert not at.exception
+
+ calendar_input = next(
+ f for f in at.text_input if f.key == "explorer_mr_csv_calendar"
+ )
+ assert calendar_input.value == "XNYS"
+
+
+def test_lab_yahoo_symbol_auto_detects_a_non_xnys_calendar() -> None:
+ """Regression test: a Yahoo symbol used to silently get XNYS regardless
+ of its own suffix -- "1211.HK" must auto-detect XHKG (see
+ `detect_calendar`), shown in an editable field the user can still
+ correct if the guess is wrong."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _open_lab(at, "mean_reversion")
+
+ source = next(r for r in at.radio if r.key == "explorer_mr_source")
+ at.session_state["explorer_lab_expander_mean_reversion"] = True
+ source.set_value("yahoo").run()
+
+ picker = next(ms for ms in at.multiselect if ms.key == "explorer_mr_yahoo_symbols")
+ at.session_state["explorer_lab_expander_mean_reversion"] = True
+ picker.set_value(["1211.HK"]).run()
+ assert not at.exception
+
+ calendar_input = next(
+ f for f in at.text_input if f.key == "explorer_mr_yahoo_calendar"
+ )
+ assert calendar_input.value == "XHKG"
+
+
+def test_lab_yahoo_multi_calendar_selection_is_rejected_with_a_clear_error() -> None:
+ """A lab computes on one flat price matrix and cannot represent more
+ than one calendar at once -- selecting symbols that need different
+ calendars (a US ticker and a Hong Kong one) must be rejected with a
+ clear error rather than silently picking one for all of them."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _open_lab(at, "mean_reversion")
+
+ source = next(r for r in at.radio if r.key == "explorer_mr_source")
+ at.session_state["explorer_lab_expander_mean_reversion"] = True
+ source.set_value("yahoo").run()
+
+ picker = next(ms for ms in at.multiselect if ms.key == "explorer_mr_yahoo_symbols")
+ at.session_state["explorer_lab_expander_mean_reversion"] = True
+ picker.set_value(["AAPL", "1211.HK"]).run()
+ assert not at.exception
+ assert not any(f.key == "explorer_mr_yahoo_calendar" for f in at.text_input)
+ assert any("different calendars" in e.value for e in at.error)
+
+
+def test_lab_symbol_picker_binance_requires_an_explicit_load_click() -> None:
+ """Mirrors the main sidebar's own Binance gate: fetching the universe
+ is never triggered just by selecting "binance" as the source."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _open_lab(at, "mean_reversion")
+
+ source = next(r for r in at.radio if r.key == "explorer_mr_source")
+ at.session_state["explorer_lab_expander_mean_reversion"] = True
+ source.set_value("binance").run()
+ assert not at.exception
+ assert not any(ms.key == "explorer_mr_binance_symbols" for ms in at.multiselect)
+ load_button = next(
+ b for b in at.button if b.key == "explorer_mr_binance_universe_load_button"
+ )
+
+ at.session_state["explorer_lab_expander_mean_reversion"] = True
+ load_button.click().run()
+ assert not at.exception
+ assert any(ms.key == "explorer_mr_binance_symbols" for ms in at.multiselect)
+
+
+def test_every_lab_uses_a_symbol_picker_key_prefix_unique_to_itself() -> None:
+ """Every lab's `render_symbol_and_source_picker(key_prefix=...)` must be
+ unique -- a shared/copy-pasted prefix would make two labs silently
+ read and write the same session_state entries (a picked Yahoo symbol
+ in one lab leaking into another)."""
+ import re
+ from pathlib import Path
+
+ import quantlab.dashboard.explorer.labs as labs_package
+
+ source_dir = Path(labs_package.__file__).parent
+ lab_files = (
+ "buy_and_hold.py",
+ "pairs_trading.py",
+ "mean_reversion.py",
+ "time_series_momentum.py",
+ "cross_sectional_momentum.py",
+ "trend_following.py",
+ )
+ found_prefixes = {}
+ for filename in lab_files:
+ text = (source_dir / filename).read_text(encoding="utf-8")
+ match = re.search(r'key_prefix="(explorer_\w+)"', text)
+ assert match is not None, f"{filename}: no key_prefix found"
+ found_prefixes[filename] = match.group(1)
+ assert len(set(found_prefixes.values())) == 6, found_prefixes
+
+
+def test_cross_sectional_momentum_lab_handles_long_only_top_fraction_above_half() -> (
+ None
+):
+ """Regression test: with long_short disabled and top_fraction=0.75, the
+ lab previously reused top_fraction verbatim as the comparison bottom
+ fraction, so 0.75 + 0.75 > 1 made select_top_bottom() raise inside
+ cross_sectional_momentum_persistence()."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _switch_to_strategies_mode(at)
+ at.button(key="explorer_open_cross_sectional_momentum").click().run()
+ at.session_state["explorer_lab_expander_cross_sectional_momentum"] = True
+ at.run()
+ assert not at.exception
+
+ at.session_state["explorer_lab_expander_cross_sectional_momentum"] = True
+ at.slider(key="explorer_csmom_top").set_value(0.75).run()
+ assert not at.exception
+
+
+def test_back_to_gallery_button_returns_to_the_gallery() -> None:
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _switch_to_strategies_mode(at)
+ at.button(key="explorer_open_buy_and_hold").click().run()
+ assert at.session_state["explorer_strategy"] == "buy_and_hold"
+
+ at.button(key="explorer_back").click().run()
+ assert not at.exception
+ assert "explorer_strategy" not in at.session_state
+ open_buttons = [
+ b.key for b in at.button if b.key and b.key.startswith("explorer_open_")
+ ]
+ assert "explorer_open_buy_and_hold" in open_buttons
+
+
+def test_pairs_trading_backtest_results_tab_shows_pair_diagnostics() -> None:
+ """The generic Strategy Explorer results-diagnostics dispatch (declared
+ only by the pairs_trading profile) surfaces its section in the Results
+ tab -- and only there, never for a strategy without one."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _configure_offline_pairs_trade(at)
+ _run_button(at).click().run()
+ assert not at.exception
+
+ subheaders = {s.value for s in at.subheader}
+ assert "Pair relationship diagnostics" in subheaders
+
+
+def test_non_pairs_strategy_backtest_results_tab_has_no_pair_diagnostics() -> None:
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _run_button(at).click().run()
+ assert not at.exception
+
+ subheaders = {s.value for s in at.subheader}
+ assert "Pair relationship diagnostics" not in subheaders
+
+
+def test_backtest_downloaded_report_includes_pair_diagnostics() -> None:
+ """The HTML report downloaded from Backtest mode's Report tab must
+ include the same pair-diagnostics section visible live in Results --
+ `_collect_backtest_robustness_evidence` folds it in, keyed by the
+ profile's own `results_diagnostics.key`, converted via
+ `report_section()` exactly like the CLI does."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _configure_offline_pairs_trade(at)
+ at.session_state["dashboard_active_tab"] = "Report"
+ _run_button(at).click().run()
+ assert not at.exception
+
+ _, (report_tab_html, _warnings) = at.session_state["report_html"]
+ assert "Pair Diagnostics" in report_tab_html
+ assert "Strategy diagnostics" in report_tab_html
+ assert "
Robustness
" in report_tab_html
+ robustness_index = report_tab_html.index("
Robustness
")
+ diagnostics_index = report_tab_html.index("Strategy diagnostics")
+ assert diagnostics_index < robustness_index
+
+
+def test_downloaded_report_updates_when_a_live_diagnostic_slider_moves() -> None:
+ """Regression test: moving the Results tab's "Forward-return horizon"
+ slider used to leave the downloaded HTML report stuck showing the
+ fixed default (skip_period) value, because `_render_report_tab`'s
+ cache key was keyed off `id(diagnostics)`, which never changes when
+ only a live Results-tab widget choice changes -- `report_section()`
+ reads that widget straight from session_state instead (see
+ `_collect_backtest_robustness_evidence`). Reproduces with
+ long_short=True, matching the user-reported scenario exactly."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY, QQQ").run()
+ _sidebar_date_input(at, "End date").set_value(datetime.date(2019, 6, 1)).run()
+ _sidebar_selectbox(at, "Strategy").set_value("cross_sectional_momentum").run()
+ _sidebar_checkbox(at, "Long/short").set_value(True).run()
+ at.session_state["dashboard_active_tab"] = "Report"
+ _run_button(at).click().run()
+ assert not at.exception
+
+ _, (report_before, _warnings_before) = at.session_state["report_html"]
+
+ at.session_state["csmom_results_diag_holding_period"] = 5
+ at.run()
+ assert not at.exception
+
+ _, (report_after, _warnings_after) = at.session_state["report_html"]
+ assert report_after != report_before
+
+
+def test_walk_forward_diagnostics_note_shown_for_a_strategy_with_diagnostics() -> None:
+ """A strategy WITH declared `results_diagnostics` (every built-in
+ strategy except buy_and_hold, which is itself excluded from Walk-
+ forward mode entirely) gets an explanatory note instead of a silently
+ missing section (see `_render_walk_forward_diagnostics_note`: each
+ fold can select different parameters than the base config, so the
+ diagnostics visible in Backtest mode are intentionally absent here).
+ The `profile.results_diagnostics is None` branch itself (no note) is
+ covered structurally by test_dashboard_explorer_profiles.py, not by a
+ live strategy here -- every walk-forward-eligible strategy now
+ declares diagnostics."""
+ at_pairs = AppTest.from_file(APP_PATH, default_timeout=120)
+ at_pairs.run()
+ _switch_to_walk_forward_mode(at_pairs)
+ _sidebar_text_input(at_pairs, "CSV symbols (comma-separated)").set_value(
+ "SPY, QQQ"
+ ).run()
+ _sidebar_selectbox(at_pairs, "Strategy").set_value("pairs_trading").run()
+ # Small windows and a short date range -- this only needs ONE completed
+ # fold to reach the Results tab, not a realistic pairs-trading backtest
+ # (an unbounded end date here previously produced dozens of folds times
+ # a parameter grid search, timing out well past two minutes).
+ # formation_window must fit inside train_window for any fold to produce
+ # a weight at all.
+ _sidebar_date_input(at_pairs, "End date").set_value(
+ datetime.date(2019, 7, 15)
+ ).run()
+ formation_window = next(
+ s for s in at_pairs.sidebar.slider if s.label == "Formation window (periods)"
+ )
+ formation_window.set_value(60).run()
+ _sidebar_number_input(at_pairs, "Train window (periods)").set_value(90).run()
+ _sidebar_number_input(at_pairs, "Validation window (periods)").set_value(20).run()
+ _sidebar_number_input(at_pairs, "Test window (periods)").set_value(20).run()
+ _run_button(at_pairs).click().run()
+ assert not at_pairs.exception
+ pairs_captions = [c.value for c in at_pairs.caption]
+ assert any("results diagnostics" in c for c in pairs_captions)
+ assert "wf_strategy_diagnostics" not in at_pairs.session_state
+
+
+def test_pairs_trading_lab_widget_interaction_recomputes_without_exception() -> None:
+ """Moving a lab parameter must trigger a real Streamlit rerun that
+ recomputes and re-renders -- not just render once with default values."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _switch_to_strategies_mode(at)
+ at.button(key="explorer_open_pairs_trading").click().run()
+ assert not at.exception
+ # The lab lives in a lazy expander (see `detail.py`) -- collapsed by
+ # default, so it must be opened before its widgets exist. AppTest does
+ # not persist a directly-assigned session_state value for a tracked
+ # container widget across an unrelated interaction's own rerun, so it
+ # must be re-asserted before every subsequent `.run()` below.
+ at.session_state["explorer_lab_expander_pairs_trading"] = True
+ at.run()
+ assert not at.exception
+
+ at.session_state["explorer_lab_expander_pairs_trading"] = True
+ at.slider(key="explorer_pairs_formation").set_value(250).run()
+ assert not at.exception
+ assert at.slider(key="explorer_pairs_formation").value == 250
+
+ at.session_state["explorer_lab_expander_pairs_trading"] = True
+ at.checkbox(key="explorer_pairs_dynamic").set_value(False).run()
+ assert not at.exception
+
+
+def test_mean_reversion_lab_widget_interaction_recomputes_without_exception() -> None:
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _switch_to_strategies_mode(at)
+ at.button(key="explorer_open_mean_reversion").click().run()
+ assert not at.exception
+ at.session_state["explorer_lab_expander_mean_reversion"] = True
+ at.run()
+ assert not at.exception
+
+ at.session_state["explorer_lab_expander_mean_reversion"] = True
+ at.checkbox(key="explorer_mr_use_zscore").set_value(False).run()
+ assert not at.exception
+ at.session_state["explorer_lab_expander_mean_reversion"] = True
+ at.slider(key="explorer_mr_rsi_window").set_value(30).run()
+ assert not at.exception
+
+
+def test_strategy_lab_does_not_run_while_its_expander_is_collapsed() -> None:
+ """Regression test: a plain `st.expander` still runs its body every
+ rerun while collapsed. The Interactive laboratory expander must use
+ the stateful/lazy variant so opening the detail page (or interacting
+ with any OTHER widget on it) does not silently re-trigger the lab's
+ full computation (data load, OLS fits, ADF/cointegration tests)."""
+ at = AppTest.from_file(APP_PATH, default_timeout=60)
+ at.run()
+ _switch_to_strategies_mode(at)
+ at.button(key="explorer_open_pairs_trading").click().run()
+ assert not at.exception
+ # The lab's own widgets must not exist yet -- its body never ran.
+ assert not any(s.key and s.key.startswith("explorer_pairs_") for s in at.slider)
+
+ at.session_state["explorer_lab_expander_pairs_trading"] = True
+ at.run()
+ assert not at.exception
+ assert any(s.key == "explorer_pairs_formation" for s in at.slider)
+
+
+def test_render_pair_diagnostics_isolated_component(
+ two_symbol_panel: pd.DataFrame,
+) -> None:
+ """Isolated (no real Streamlit, no dashboard) component test for the
+ Results-tab pair-diagnostics renderer, driven by a real
+ ``PairDiagnostics`` computed on the cointegrated-by-construction
+ ``two_symbol_panel`` fixture -- complements the AppTest-based
+ integration coverage above."""
+ from quantlab.data.base import price_matrix
+ from quantlab.features.pairs_diagnostics import compute_pair_diagnostics
+
+ class FakeColumn:
+ def __init__(self, sink: list[tuple[str, str]]) -> None:
+ self._sink = sink
+
+ def metric(self, label: str, value: str) -> None:
+ self._sink.append((label, value))
+
+ class FakeStreamlit:
+ def __init__(self) -> None:
+ self.subheaders: list[str] = []
+ self.captions: list[str] = []
+ self.infos: list[str] = []
+ self.plotly_chart_calls = 0
+ self.metrics: list[tuple[str, str]] = []
+
+ def subheader(self, text: str) -> None:
+ self.subheaders.append(text)
+
+ def caption(self, text: str) -> None:
+ self.captions.append(text)
+
+ def info(self, text: str) -> None:
+ self.infos.append(text)
+
+ def plotly_chart(self, fig: object, **kwargs: object) -> None:
+ self.plotly_chart_calls += 1
+
+ def columns(self, n: int) -> list[FakeColumn]:
+ return [FakeColumn(self.metrics) for _ in range(n)]
+
+ diagnostics = compute_pair_diagnostics(
+ price_matrix(two_symbol_panel),
+ "EWA",
+ "EWB",
+ formation_window=100,
+ indicator_window=20,
+ dynamic_hedge_ratio=True,
+ )
+ fake = FakeStreamlit()
+
+ render_pair_diagnostics(
+ fake,
+ diagnostics,
+ entry_threshold=2.0,
+ exit_threshold=0.5,
+ stop_threshold=4.0,
+ adf_pvalue_threshold=0.10,
+ )
+
+ assert fake.subheaders == ["Pair relationship diagnostics"]
+ assert any("EWA / EWB" in caption for caption in fake.captions)
+ # Hedge ratio, spread, the centered-indicator threshold-overlay chart,
+ # plus a rolling-ADF-p-value chart (the pair is cointegrated by
+ # construction, so this last chart is present).
+ assert fake.plotly_chart_calls == 4
+ # Two stationarity cards (ADF, Engle-Granger cointegration), 3 metrics
+ # each, plus the Half-life/Hedge-ratio-stability metric pair.
+ assert len(fake.metrics) == 8
+ labels = {label for label, _ in fake.metrics}
+ assert labels == {
+ "Statistic",
+ "p-value",
+ "Verdict",
+ "Half-life",
+ "Hedge-ratio stability (std of beta)",
+ }
+ assert not fake.infos # both ADF and cointegration results are conclusive
diff --git a/tests/unit/test_dashboard_explorer_profiles.py b/tests/unit/test_dashboard_explorer_profiles.py
new file mode 100644
index 0000000..d904fc1
--- /dev/null
+++ b/tests/unit/test_dashboard_explorer_profiles.py
@@ -0,0 +1,632 @@
+"""Structural guarantees for the Strategy Explorer content registry.
+
+These are not content-quality tests (no automated check can judge whether
+a markdown explanation is actually good) -- they verify the invariants the
+approved plan named explicitly: every registered strategy has a profile,
+every profile documents exactly the parameters its strategy accepts
+(including structurally-injected ones like ``price_type``/
+``periods_per_year``, never silently excluded), every markdown field is
+non-empty, and the dispatch mechanism stays name-free outside the profile
+files themselves.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING, cast
+
+import pytest
+
+if TYPE_CHECKING:
+ import pandas as pd
+
+import quantlab.dashboard.explorer.profiles # noqa: F401 (registration side effect)
+from quantlab.dashboard.explorer.profile import (
+ StrategyProfile,
+ available_profiles,
+ get_profile,
+)
+from quantlab.strategies.base import available_strategies, strategy_parameter_names
+
+_SRC = Path(__file__).resolve().parents[2] / "src" / "quantlab"
+
+_MARKDOWN_FIELDS = (
+ "overview_md",
+ "economic_intuition_md",
+ "mathematical_definition_md",
+ "assumptions_md",
+ "diagnostics_md",
+ "interpretation_md",
+ "limitations_md",
+)
+
+
+def test_every_registered_strategy_has_a_profile() -> None:
+ missing = set(available_strategies()) - set(available_profiles())
+ assert not missing, f"No profile registered for: {sorted(missing)}"
+
+
+def test_available_profiles_are_all_real_strategies() -> None:
+ """A profile registered under a name the strategy registry doesn't
+ recognise would silently never be reachable from the gallery."""
+ unknown = set(available_profiles()) - set(available_strategies())
+ assert not unknown, f"Profile registered for unknown strategy: {sorted(unknown)}"
+
+
+@pytest.mark.parametrize("strategy_name", available_strategies())
+def test_profile_documents_exactly_the_strategy_constructor_parameters(
+ strategy_name: str,
+) -> None:
+ """No parameter is missing, and none is documented that doesn't exist --
+ including ``price_type``/``periods_per_year`` structurally injected by
+ the runner, which must never be excluded just because they aren't
+ passed explicitly in YAML."""
+ profile = get_profile(strategy_name)
+ assert profile is not None
+ documented = {parameter.name for parameter in profile.parameters}
+ expected = strategy_parameter_names(strategy_name)
+ assert documented == expected, (
+ f"{strategy_name}: documented={sorted(documented)} vs "
+ f"expected={sorted(expected)}"
+ )
+
+
+@pytest.mark.parametrize("strategy_name", available_strategies())
+def test_profile_markdown_fields_are_non_empty(strategy_name: str) -> None:
+ profile = get_profile(strategy_name)
+ assert profile is not None
+ for field in _MARKDOWN_FIELDS:
+ value = getattr(profile, field)
+ assert isinstance(value, str)
+ assert value.strip(), f"{strategy_name}.{field} is empty"
+
+
+@pytest.mark.parametrize("strategy_name", available_strategies())
+def test_profile_lab_is_callable(strategy_name: str) -> None:
+ profile = get_profile(strategy_name)
+ assert profile is not None
+ assert callable(profile.lab)
+
+
+@pytest.mark.parametrize("strategy_name", available_strategies())
+def test_profile_display_name_and_category_are_set(strategy_name: str) -> None:
+ profile = get_profile(strategy_name)
+ assert profile is not None
+ assert profile.display_name.strip()
+ assert profile.category.strip()
+
+
+def test_registered_profile_is_a_strategy_profile_instance() -> None:
+ for name in available_profiles():
+ assert isinstance(get_profile(name), StrategyProfile)
+
+
+def test_html_report_never_names_a_specific_strategy() -> None:
+ """`html_report.py`'s Robustness rendering dispatches a strategy's own
+ results diagnostics generically, by `isinstance(value, DiagnosticsSection)`
+ (see `_render_robustness`) -- never by strategy name. If a strategy name
+ ever appears in this file, that architectural guarantee has been broken.
+ """
+ source = (_SRC / "reporting" / "html_report.py").read_text(encoding="utf-8")
+ for strategy_name in available_strategies():
+ assert strategy_name not in source, (
+ f"html_report.py must not name '{strategy_name}' directly -- "
+ "dispatch strategy-specific report content via "
+ "quantlab.reporting.sections.DiagnosticsSection instead."
+ )
+
+
+def test_pairs_trading_diagnostics_respects_signal_price_type() -> None:
+ """The pairs_trading profile's ``results_diagnostics.compute`` must price
+ the diagnostics on whichever series the strategy itself actually trades
+ (``strategy.signal_price_type`` -- ``strategy.parameters.price_type`` is
+ rejected at config validation and can never override it) -- never
+ silently default to adjusted_close regardless of what the config says,
+ which would show a hedge ratio/spread that does not match what the
+ backtest itself traded.
+ """
+ import numpy as np
+ import pandas as pd
+
+ from quantlab.config import ExperimentConfig
+ from quantlab.constants import (
+ ADJUSTED_CLOSE,
+ CLOSE,
+ HIGH,
+ LOW,
+ OPEN,
+ SYMBOL,
+ TIMESTAMP,
+ VOLUME,
+ )
+
+ idx = pd.date_range("2020-01-01", periods=60, freq="B")
+ rng = np.random.default_rng(0)
+ close_a = 100.0 + np.cumsum(rng.normal(0.0, 1.0, size=60))
+ close_b = 50.0 + 0.5 * (close_a - 100.0) + rng.normal(0.0, 0.1, size=60)
+ # Deliberately offset from close, as if a corporate action had occurred,
+ # so the two price choices produce numerically different diagnostics.
+ adjusted_a = close_a * 0.5
+ adjusted_b = close_b * 0.5
+
+ def _frame(symbol: str, close: np.ndarray, adjusted: np.ndarray) -> pd.DataFrame:
+ return pd.DataFrame(
+ {
+ TIMESTAMP: idx,
+ SYMBOL: symbol,
+ OPEN: close,
+ HIGH: close * 1.01,
+ LOW: close * 0.99,
+ CLOSE: close,
+ ADJUSTED_CLOSE: adjusted,
+ VOLUME: 1_000_000.0,
+ }
+ )
+
+ data = pd.concat(
+ [_frame("AAA", close_a, adjusted_a), _frame("BBB", close_b, adjusted_b)],
+ ignore_index=True,
+ )
+ cfg_close = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "price_type_test",
+ "data": {
+ "instruments": [
+ {"symbol": "AAA", "source": "csv", "calendar": "XNYS"},
+ {"symbol": "BBB", "source": "csv", "calendar": "XNYS"},
+ ],
+ "start_date": "2020-01-01",
+ "end_date": "2020-04-01",
+ },
+ "portfolio": {"allocator": "signal_proportional"},
+ "strategy": {
+ "name": "pairs_trading",
+ "signal_price_type": "close",
+ "parameters": {
+ "symbol_a": "AAA",
+ "symbol_b": "BBB",
+ "formation_window": 20,
+ "indicator_window": 5,
+ },
+ },
+ }
+ )
+ cfg_adjusted = cfg_close.revalidated_copy(
+ update={
+ "strategy": cfg_close.strategy.revalidated_copy(
+ update={"signal_price_type": "adjusted_close"}
+ )
+ }
+ )
+
+ profile = get_profile("pairs_trading")
+ assert profile is not None
+ assert profile.results_diagnostics is not None
+ diagnostics_close = profile.results_diagnostics.compute(data, cfg_close)
+ diagnostics_adjusted = profile.results_diagnostics.compute(data, cfg_adjusted)
+
+ close_spread = diagnostics_close.diagnostics.spread.dropna()
+ adjusted_spread = diagnostics_adjusted.diagnostics.spread.dropna()
+ assert not close_spread.equals(adjusted_spread)
+
+ # Directly verify against a manual computation on the raw close prices,
+ # rather than only checking "the two differ".
+ from quantlab.data.base import price_matrix
+ from quantlab.features.pairs_diagnostics import compute_pair_diagnostics
+
+ expected_close = compute_pair_diagnostics(
+ price_matrix(data, adjusted=False),
+ "AAA",
+ "BBB",
+ formation_window=20,
+ indicator_window=5,
+ dynamic_hedge_ratio=True,
+ )
+ pd.testing.assert_series_equal(close_spread, expected_close.spread.dropna())
+
+
+def _momentum_universe_data(n: int = 650) -> pd.DataFrame:
+ import numpy as np
+ import pandas as pd
+
+ from quantlab.constants import (
+ ADJUSTED_CLOSE,
+ CLOSE,
+ HIGH,
+ LOW,
+ OPEN,
+ SYMBOL,
+ TIMESTAMP,
+ VOLUME,
+ )
+
+ idx = pd.date_range("2019-01-01", periods=n, freq="B")
+ frames = []
+ for i, symbol in enumerate(["AAA", "BBB", "CCC"]):
+ prices = 100.0 + np.cumsum(np.random.default_rng(i).normal(0.0, 1.0, size=n))
+ prices = np.maximum(prices, 1.0)
+ frames.append(
+ pd.DataFrame(
+ {
+ TIMESTAMP: idx,
+ SYMBOL: symbol,
+ OPEN: prices,
+ HIGH: prices * 1.01,
+ LOW: prices * 0.99,
+ CLOSE: prices,
+ ADJUSTED_CLOSE: prices,
+ VOLUME: 1_000_000.0,
+ }
+ )
+ )
+ return pd.concat(frames, ignore_index=True)
+
+
+@pytest.mark.parametrize("skip_period", [0, 21, 500])
+def test_cross_sectional_momentum_diagnostics_handles_any_valid_skip_period(
+ skip_period: int,
+) -> None:
+ """Regression test: the diagnostic's own forward-return horizon used
+ to be fixed directly to `skip_period` -- `skip_period=0` is a
+ perfectly valid strategy config (0-21 is even the documented typical
+ range), but `holding_period=0` is rejected by
+ `cross_sectional_momentum_persistence` (must be >= 1); a `skip_period`
+ above 252 is also valid (only constrained to be < lookback_period) but
+ would put the Results-tab slider's default value outside its own
+ 1-252 range. `compute()` must succeed for every valid skip_period,
+ always defaulting the diagnostic's own horizon to a fixed,
+ skip_period-independent value within [1, 252]."""
+ from quantlab.config import ExperimentConfig
+
+ lookback_period = 600 if skip_period >= 252 else 100
+ data = _momentum_universe_data()
+ cfg = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "csmom_skip_period_test",
+ "data": {
+ "instruments": [
+ {"symbol": s, "source": "csv", "calendar": "XNYS"}
+ for s in ["AAA", "BBB", "CCC"]
+ ],
+ "start_date": "2019-01-01",
+ "end_date": "2021-06-01",
+ },
+ "portfolio": {"allocator": "equal_weight"},
+ "strategy": {
+ "name": "cross_sectional_momentum",
+ "parameters": {
+ "lookback_period": lookback_period,
+ "skip_period": skip_period,
+ "top_fraction": 0.5,
+ },
+ },
+ }
+ )
+ profile = get_profile("cross_sectional_momentum")
+ assert profile is not None
+ assert profile.results_diagnostics is not None
+
+ result = profile.results_diagnostics.compute(data, cfg)
+
+ assert 1 <= result.holding_period <= 252
+ assert result.skip_period == skip_period
+
+
+@pytest.mark.parametrize("skip_period", [0, 21, 500])
+def test_time_series_momentum_diagnostics_handles_any_valid_skip_period(
+ skip_period: int,
+) -> None:
+ """Same regression as the cross-sectional case above, for
+ time_series_momentum's own diagnostic."""
+ from quantlab.config import ExperimentConfig
+
+ lookback_period = 600 if skip_period >= 252 else 100
+ data = _momentum_universe_data()
+ cfg = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "tsmom_skip_period_test",
+ "data": {
+ "instruments": [
+ {"symbol": s, "source": "csv", "calendar": "XNYS"}
+ for s in ["AAA", "BBB", "CCC"]
+ ],
+ "start_date": "2019-01-01",
+ "end_date": "2021-06-01",
+ },
+ "portfolio": {"allocator": "equal_weight"},
+ "strategy": {
+ "name": "time_series_momentum",
+ "parameters": {
+ "lookback_period": lookback_period,
+ "skip_period": skip_period,
+ },
+ },
+ }
+ )
+ profile = get_profile("time_series_momentum")
+ assert profile is not None
+ assert profile.results_diagnostics is not None
+
+ result = profile.results_diagnostics.compute(data, cfg)
+
+ assert 1 <= result.holding_period <= 252
+ assert result.skip_period == skip_period
+
+
+@pytest.mark.parametrize("slow_window", [2, 3, 4])
+def test_trend_following_results_er_slider_handles_a_slow_window_below_five(
+ slow_window: int,
+) -> None:
+ """Regression test: `fast_window=1, slow_window=2` (and similarly
+ small windows) are valid strategy configs -- only `fast_window <
+ slow_window`, both `>= 1`, are enforced -- but the Results tab's
+ Efficiency Ratio slider declares a fixed [5, 200] range. Its default
+ value used to be `min(slow_window, 200)` directly, which could fall
+ below 5 and put the slider's own default outside its declared bounds.
+ Uses a real `AppTest` (not a fake streamlit stand-in) to exercise the
+ actual widget construction, since the dashboard's own sidebar cannot
+ reach a `slow_window` this small (its slider is bounded at 30) --
+ the only way to observe this is a direct, config-driven scenario like
+ a hand-written YAML config passed straight to the CLI."""
+ pytest.importorskip("streamlit")
+ from streamlit.testing.v1 import AppTest
+
+ script = f"""
+import pandas as pd
+import streamlit as st
+from quantlab.dashboard.explorer.profiles.trend_following import (
+ TrendFollowingDiagnostics,
+ _render_diagnostics,
+)
+
+prices = {{"AAA": pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])}}
+summary = pd.DataFrame(
+ {{"Whipsaw (flips / 126p, latest)": [0.0], "Median Efficiency Ratio": [0.5]}},
+ index=pd.Index(["AAA"], name="Symbol"),
+)
+result = TrendFollowingDiagnostics(
+ summary=summary,
+ prices=prices,
+ fast_ma=prices,
+ slow_ma=prices,
+ signal=prices,
+ slow_window={slow_window},
+)
+_render_diagnostics(st, result)
+"""
+ at = AppTest.from_string(script, default_timeout=30)
+ at.run()
+
+ assert not at.exception
+ er_slider = next(s for s in at.slider if s.label == "Efficiency Ratio window")
+ assert 5 <= cast(int, er_slider.value) <= 200
+
+
+def _minimal_profile(**overrides: object) -> StrategyProfile:
+ """Build an otherwise-valid StrategyProfile, letting a test override
+ just the field(s) it wants to test the registration guard for."""
+ fields: dict[str, object] = {
+ "strategy_name": "buy_and_hold",
+ "display_name": "Test",
+ "category": "Test",
+ "overview_md": "x",
+ "economic_intuition_md": "x",
+ "mathematical_definition_md": "x",
+ "assumptions_md": "x",
+ "diagnostics_md": "x",
+ "interpretation_md": "x",
+ "limitations_md": "x",
+ "parameters": [],
+ "lab": lambda st: None,
+ }
+ fields.update(overrides)
+ return StrategyProfile(**fields) # type: ignore[arg-type]
+
+
+def _isolated_registry(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Swap in a throwaway copy of the module-level registry so a test's
+ own `register_profile()` calls can't leak into other tests -- restored
+ automatically by monkeypatch's own teardown."""
+ import quantlab.dashboard.explorer.profile as profile_module
+
+ monkeypatch.setattr(profile_module, "_REGISTRY", dict(profile_module._REGISTRY))
+
+
+@pytest.mark.parametrize(
+ "field",
+ [
+ "overview_md",
+ "economic_intuition_md",
+ "mathematical_definition_md",
+ "assumptions_md",
+ "diagnostics_md",
+ "interpretation_md",
+ "limitations_md",
+ ],
+)
+def test_register_profile_rejects_an_empty_markdown_field(
+ monkeypatch: pytest.MonkeyPatch, field: str
+) -> None:
+ from quantlab.dashboard.explorer.profile import register_profile
+
+ _isolated_registry(monkeypatch)
+ profile = _minimal_profile(**{field: " "})
+ with pytest.raises(ValueError, match=field):
+ register_profile(profile, replace=True)
+
+
+def test_register_profile_rejects_empty_display_name(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from quantlab.dashboard.explorer.profile import register_profile
+
+ _isolated_registry(monkeypatch)
+ with pytest.raises(ValueError, match="display_name"):
+ register_profile(_minimal_profile(display_name=""), replace=True)
+
+
+def test_register_profile_rejects_duplicate_parameter_names(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from quantlab.dashboard.explorer.profile import ParameterDoc, register_profile
+
+ _isolated_registry(monkeypatch)
+ duplicate = ParameterDoc(
+ name="lookback_period",
+ what="x",
+ where="x",
+ why="x",
+ default="x",
+ typical_range="x",
+ effect_increase="x",
+ effect_decrease="x",
+ tradeoffs="x",
+ )
+ profile = _minimal_profile(parameters=[duplicate, duplicate])
+ with pytest.raises(ValueError, match="duplicate"):
+ register_profile(profile, replace=True)
+
+
+@pytest.mark.parametrize(
+ "field",
+ [
+ "name",
+ "what",
+ "where",
+ "why",
+ "default",
+ "typical_range",
+ "effect_increase",
+ "effect_decrease",
+ "tradeoffs",
+ ],
+)
+def test_register_profile_rejects_an_empty_parameter_doc_field(
+ monkeypatch: pytest.MonkeyPatch, field: str
+) -> None:
+ from quantlab.dashboard.explorer.profile import ParameterDoc, register_profile
+
+ _isolated_registry(monkeypatch)
+ fields = {
+ "name": "lookback_period",
+ "what": "x",
+ "where": "x",
+ "why": "x",
+ "default": "x",
+ "typical_range": "x",
+ "effect_increase": "x",
+ "effect_decrease": "x",
+ "tradeoffs": "x",
+ }
+ fields[field] = " "
+ profile = _minimal_profile(parameters=[ParameterDoc(**fields)])
+ with pytest.raises(ValueError, match=field):
+ register_profile(profile, replace=True)
+
+
+def test_register_profile_allows_an_empty_parameter_doc_interactions(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """`interactions` defaults to '' for a parameter with none to report --
+ unlike every other ParameterDoc field, an empty one must not be rejected."""
+ from quantlab.dashboard.explorer.profile import ParameterDoc, register_profile
+
+ _isolated_registry(monkeypatch)
+ parameter = ParameterDoc(
+ name="lookback_period",
+ what="x",
+ where="x",
+ why="x",
+ default="x",
+ typical_range="x",
+ effect_increase="x",
+ effect_decrease="x",
+ tradeoffs="x",
+ interactions="",
+ )
+ register_profile(_minimal_profile(parameters=[parameter]), replace=True)
+
+
+def test_register_profile_rejects_an_unregistered_strategy_name(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from quantlab.dashboard.explorer.profile import register_profile
+
+ _isolated_registry(monkeypatch)
+ profile = _minimal_profile(strategy_name="not_a_real_strategy")
+ with pytest.raises(ValueError, match="not_a_real_strategy"):
+ register_profile(profile, replace=True)
+
+
+def test_register_profile_rejects_a_non_callable_results_diagnostics_field(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from quantlab.dashboard.explorer.profile import ResultsDiagnostics, register_profile
+
+ _isolated_registry(monkeypatch)
+ diagnostics = ResultsDiagnostics(
+ key="k",
+ compute="not_callable", # type: ignore[arg-type]
+ render=lambda st, result: None,
+ report_section=lambda result: None, # type: ignore[arg-type,return-value]
+ )
+ profile = _minimal_profile(results_diagnostics=diagnostics)
+ with pytest.raises(ValueError, match="compute"):
+ register_profile(profile, replace=True)
+
+
+def test_register_profile_rejects_an_empty_results_diagnostics_key(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from quantlab.dashboard.explorer.profile import ResultsDiagnostics, register_profile
+
+ _isolated_registry(monkeypatch)
+ diagnostics = ResultsDiagnostics(
+ key=" ",
+ compute=lambda data, cfg: None,
+ render=lambda st, result: None,
+ report_section=lambda result: None, # type: ignore[arg-type,return-value]
+ )
+ profile = _minimal_profile(results_diagnostics=diagnostics)
+ with pytest.raises(ValueError, match="key"):
+ register_profile(profile, replace=True)
+
+
+def test_register_profile_rejects_a_colliding_results_diagnostics_key(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """`results_diagnostics.key` doubles as the robustness-dict/session_state
+ key -- a collision between two profiles would let them silently clobber
+ each other's diagnostics."""
+ from quantlab.dashboard.explorer.profile import ResultsDiagnostics, register_profile
+
+ _isolated_registry(monkeypatch)
+ diagnostics = ResultsDiagnostics(
+ key="shared_key",
+ compute=lambda data, cfg: None,
+ render=lambda st, result: None,
+ report_section=lambda result: None, # type: ignore[arg-type,return-value]
+ )
+ first = _minimal_profile(
+ strategy_name="buy_and_hold", results_diagnostics=diagnostics
+ )
+ second = _minimal_profile(
+ strategy_name="mean_reversion", results_diagnostics=diagnostics
+ )
+ register_profile(first, replace=True)
+ with pytest.raises(ValueError, match="shared_key"):
+ register_profile(second, replace=True)
+
+
+def test_gallery_and_detail_pages_never_name_a_specific_strategy() -> None:
+ """The gallery/detail pages are driven entirely by `available_strategies()`
+ / `get_profile()` -- a strategy name appearing in either file would mean
+ a new strategy needs a dashboard code change beyond its own profile file,
+ which is exactly what the registry pattern exists to avoid."""
+ explorer_dir = _SRC / "dashboard" / "explorer"
+ for filename in ("gallery.py", "detail.py"):
+ source = (explorer_dir / filename).read_text(encoding="utf-8")
+ for strategy_name in available_strategies():
+ assert strategy_name not in source, (
+ f"{filename} must not name '{strategy_name}' directly."
+ )
diff --git a/tests/unit/test_dashboard_state.py b/tests/unit/test_dashboard_state.py
index 8afa8e5..0691fb1 100644
--- a/tests/unit/test_dashboard_state.py
+++ b/tests/unit/test_dashboard_state.py
@@ -10,6 +10,7 @@
from quantlab.dashboard.state import (
build_config_from_inputs,
estimate_walk_forward_backtest_count,
+ run_dashboard_backtest_with_data,
)
from quantlab.validation.parameter_grid import parse_parameter_grid_values
@@ -185,3 +186,41 @@ def _fake_run(self, data, **kwargs): # type: ignore[no-untyped-def]
run_dashboard_walk_forward(config)
assert captured["checkpoint_path"] == _checkpoint_path(config, "walk_forward")
+
+
+def test_run_dashboard_backtest_with_data_returns_the_exact_frame_it_ran_on(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The returned frame must be the SAME object the backtest itself ran
+ on (identity, not just equality) -- a Strategy Explorer results
+ diagnostic reusing anything else (e.g. a fresh, independent reload)
+ could silently observe different data than the displayed result for a
+ remote source that changed, or a cache that refreshed, between the two
+ loads."""
+ from types import SimpleNamespace
+
+ import quantlab.dashboard.state as state_module
+
+ config = build_config_from_inputs(_base_inputs(experiment_name="identity_check"))
+ the_frame = pd.DataFrame({"marker": [1, 2, 3]})
+ monkeypatch.setattr(
+ state_module.DataLoader,
+ "load",
+ lambda self, cfg: (the_frame, SimpleNamespace(warnings=["w"])),
+ )
+ captured: dict[str, object] = {}
+
+ def fake_run_backtest_from_config(data, cfg, *, data_quality_report=None): # type: ignore[no-untyped-def]
+ captured["data_seen_by_backtest"] = data
+ return "fake-result"
+
+ monkeypatch.setattr(
+ state_module, "run_backtest_from_config", fake_run_backtest_from_config
+ )
+
+ result, warnings, returned_data = run_dashboard_backtest_with_data(config)
+
+ assert result == "fake-result"
+ assert warnings == ["w"]
+ assert returned_data is the_frame
+ assert captured["data_seen_by_backtest"] is the_frame
diff --git a/tests/unit/test_drift_compliance.py b/tests/unit/test_drift_compliance.py
new file mode 100644
index 0000000..181419c
--- /dev/null
+++ b/tests/unit/test_drift_compliance.py
@@ -0,0 +1,549 @@
+"""Direct tests for the drift-compliance LP (`restore_drift_compliance`).
+
+Each test asserts against a hand-derived closed-form minimal-L1-distance
+solution, not just "no crash" -- this is the riskiest piece of math behind
+weight drift, so its correctness must be nailed down before it is wired
+into `accounting.py`.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+import pytest
+
+from quantlab.exceptions import BacktestError
+from quantlab.portfolio.drift_compliance import restore_drift_compliance
+
+
+def test_lone_maximum_weight_breach_clips_exactly_to_the_cap() -> None:
+ result = restore_drift_compliance(
+ np.array([0.5]),
+ ["A"],
+ np.array([True]),
+ [("A",)],
+ maximum_weight=0.3,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ assert not result.pending
+ assert result.corrected[0] == pytest.approx(0.3)
+
+
+def test_lone_gross_exposure_breach_clips_the_only_column() -> None:
+ result = restore_drift_compliance(
+ np.array([0.5]),
+ ["A"],
+ np.array([True]),
+ [("A",)],
+ maximum_weight=None,
+ maximum_gross_exposure=0.3,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ assert not result.pending
+ assert result.corrected[0] == pytest.approx(0.3)
+
+
+def test_lone_long_only_breach_snaps_to_zero() -> None:
+ """Nearest point on `w >= 0` to a negative drifted value is exactly 0."""
+ result = restore_drift_compliance(
+ np.array([-0.2]),
+ ["A"],
+ np.array([True]),
+ [("A",)],
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=True,
+ )
+ assert not result.pending
+ assert result.corrected[0] == pytest.approx(0.0)
+
+
+def test_closed_long_tradable_short_counterexample_pushes_short_more_negative() -> None:
+ """The scenario that disproves "clip then scale tradable columns toward
+ 0" as a general solution: a large UNTRADABLE long position plus a
+ TRADABLE short breaching maximum_net_exposure needs the short pushed
+ MORE negative (away from 0), not scaled toward 0."""
+ drifted = np.array([0.9, -0.1]) # A untradable, B tradable
+ result = restore_drift_compliance(
+ drifted,
+ ["A", "B"],
+ np.array([False, True]),
+ [("A",), ("B",)],
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=0.5,
+ long_only=False,
+ )
+ assert not result.pending
+ # A is untradable -- unchanged.
+ assert result.corrected[0] == pytest.approx(0.9)
+ # Feasible region for B: 0.9 + w_B in [-0.5, 0.5] => w_B in [-1.4, -0.4].
+ # Nearest point to drifted B=-0.1 is -0.4 -- MORE negative, not toward 0.
+ assert result.corrected[1] == pytest.approx(-0.4)
+ assert result.corrected[1] < drifted[1]
+
+
+def test_uninvolved_compliant_column_is_never_liquidated_by_an_unrelated_breach() -> (
+ None
+):
+ """An uninvolved, already-compliant column must never be moved just
+ because an UNRELATED constraint violation is being fixed elsewhere in
+ the row: a single-stage slack-only relaxation that minimizes ONLY the
+ constraint-violation slacks, with no term penalizing movement of such
+ a column, would let the solver pick any optimal vertex, including one
+ that arbitrarily liquidates B even though `maximum_weight` (a purely
+ per-column cap) doesn't even reference B. A closed at 0.6 alone
+ already breaches `maximum_weight=0.5` (tradability-caused, pending);
+ B, open and compliant at 0.4, must stay exactly where it is -- the
+ two-stage lexicographic fix (fix the minimal violation, then minimize
+ L1 deviation among solutions achieving it) has no reason to move a
+ column the violation doesn't involve."""
+ result = restore_drift_compliance(
+ np.array([0.6, 0.4]),
+ ["A", "B"],
+ np.array([False, True]),
+ [("A",), ("B",)],
+ maximum_weight=0.5,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ assert result.pending
+ assert result.corrected[0] == pytest.approx(0.6)
+ assert result.corrected[1] == pytest.approx(0.4)
+
+
+def test_never_opens_a_new_position_on_a_column_drifted_to_exactly_zero() -> None:
+ """Regression test: the LP must never invent a brand-new position
+ (long OR short) on a column the drifted book does not already hold,
+ even when doing so would have been the cheapest (or only) way to
+ restore compliance. Before sign/support-preservation, this exact
+ scenario would have opened a short on B (the strict LP was feasible
+ by setting w_B=-0.1) -- a hedge the strategy never asked for and
+ portfolio.long_only=False alone would have silently allowed. B must
+ now stay fixed at exactly 0, and since the untradable A alone (0.6)
+ already exceeds the 0.5 cap, this becomes a tradability-caused
+ pending correction instead -- an honest "still breaching, waiting
+ for A to reopen" rather than a silently manufactured hedge."""
+ drifted = np.array([0.6, 0.0]) # A untradable long 0.6, B tradable AT ZERO
+ result = restore_drift_compliance(
+ drifted,
+ ["A", "B"],
+ np.array([False, True]),
+ [("A",), ("B",)],
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=0.5,
+ long_only=False,
+ )
+ assert result.pending
+ assert result.corrected[0] == pytest.approx(0.6)
+ assert result.corrected[1] == pytest.approx(0.0)
+
+
+def test_existing_long_may_not_flip_to_a_new_short_or_vice_versa() -> None:
+ """A currently-LONG column may shrink toward 0 (or grow further long)
+ but must never cross into short territory, and vice versa -- crossing
+ zero is just as much "inventing a position the drifted book didn't
+ hold" as starting from exactly zero would be."""
+ # A long-only column (0.4) would need to go negative to satisfy this
+ # net cap alongside an untradable 0.3 -- but it may only shrink to 0.
+ drifted = np.array([0.3, 0.4]) # A untradable, B tradable LONG
+ result = restore_drift_compliance(
+ drifted,
+ ["A", "B"],
+ np.array([False, True]),
+ [("A",), ("B",)],
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=0.2,
+ long_only=False,
+ )
+ # 0.3 (fixed) alone already exceeds the 0.2 cap -- tradability-caused,
+ # B shrinks to its floor of 0 (not below), still pending.
+ assert result.pending
+ assert result.corrected[0] == pytest.approx(0.3)
+ assert result.corrected[1] == pytest.approx(0.0)
+
+
+def test_position_group_moves_both_legs_via_one_shared_scalar() -> None:
+ """A declared group's legs must move together (`k_g`), never as
+ independent free variables -- a per-column LP could satisfy the
+ objective by moving only one leg, breaking the pair's hedge ratio."""
+ drifted = np.array([0.6, -0.3]) # X, Y -- one group, both tradable
+ result = restore_drift_compliance(
+ drifted,
+ ["X", "Y"],
+ np.array([True, True]),
+ [("X", "Y")],
+ maximum_weight=0.5,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ assert not result.pending
+ # k_g <= 0.5/0.6 = 0.8333... (from X's own maximum_weight bound);
+ # minimal-L1 picks k_g as close to 1 as feasible => k_g = 0.8333...
+ expected_k = 0.5 / 0.6
+ assert result.corrected[0] == pytest.approx(expected_k * 0.6)
+ assert result.corrected[1] == pytest.approx(expected_k * -0.3)
+ # The ratio between the two legs is exactly preserved (coherent move).
+ assert result.corrected[0] / result.corrected[1] == pytest.approx(
+ drifted[0] / drifted[1]
+ )
+
+
+def test_position_group_with_one_untradable_leg_is_fixed_entirely() -> None:
+ """A group is only eligible to move when EVERY leg is tradable -- one
+ untradable leg must fix `k_g` at exactly 1 (the whole group frozen at
+ its drifted proportions), never let the tradable leg move alone. Only
+ the trivial both-tradable/both-untradable cases were tested before
+ this: X alone (untradable, 0.6) already exceeds `maximum_weight=0.5`,
+ so this is tradability-caused and pending -- the group stays exactly
+ at its drifted values rather than Y moving independently."""
+ drifted = np.array([0.6, -0.3]) # X untradable, Y tradable, one group
+ result = restore_drift_compliance(
+ drifted,
+ ["X", "Y"],
+ np.array([False, True]),
+ [("X", "Y")],
+ maximum_weight=0.5,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ assert result.pending
+ assert result.corrected[0] == pytest.approx(0.6)
+ assert result.corrected[1] == pytest.approx(-0.3)
+
+
+def test_position_group_gross_exposure_cap_scales_k_g() -> None:
+ """Group + `maximum_gross_exposure` interaction -- only per-leg
+ `maximum_weight`/net-exposure combos with a group were tested before
+ this. Both legs tradable, gross = |0.6| + |-0.3| = 0.9 at k_g=1;
+ capped to 0.6 forces k_g <= 0.6/0.9 = 0.6667, and minimal-L1 picks the
+ largest feasible k_g (closest to 1)."""
+ drifted = np.array([0.6, -0.3])
+ result = restore_drift_compliance(
+ drifted,
+ ["X", "Y"],
+ np.array([True, True]),
+ [("X", "Y")],
+ maximum_weight=None,
+ maximum_gross_exposure=0.6,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ assert not result.pending
+ expected_k = 0.6 / 0.9
+ assert result.corrected[0] == pytest.approx(expected_k * 0.6)
+ assert result.corrected[1] == pytest.approx(expected_k * -0.3)
+ assert result.corrected[0] / result.corrected[1] == pytest.approx(
+ drifted[0] / drifted[1]
+ )
+
+
+def test_untradable_independent_column_negative_under_long_only_is_pending() -> None:
+ """Regression test: `long_only` was only ever baked into a TRADABLE
+ independent column's own lower bound -- silently a no-op for an
+ UNTRADABLE (fixed) column, whose bound is pinned at its drifted value
+ regardless of sign. Before the fix, the strict LP had no constraint
+ that could ever reject a fixed negative value under long_only, so it
+ trivially "succeeded" (`pending=False`) over a row that still
+ genuinely violated long_only -- exactly the silent formulation bug
+ this module exists to avoid elsewhere. A fixed column alone violating
+ long_only must be diagnosed as tradability-caused (mirroring
+ `_fixed_positions_alone_violate`'s own long_only branch, previously
+ unreachable) and returned as a `pending`, best-effort (here: fully
+ unchanged, since nothing else is free) correction."""
+ from quantlab.portfolio.rebalancing import _compliance_violations
+
+ result = restore_drift_compliance(
+ np.array([-0.2]),
+ ["A"],
+ np.array([False]),
+ [("A",)],
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=True,
+ )
+ assert result.pending
+ assert result.corrected[0] == pytest.approx(-0.2)
+ assert _compliance_violations(
+ result.corrected,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=True,
+ ) == ["long_only"]
+
+
+def test_untradable_group_with_a_negative_leg_under_long_only_is_pending() -> None:
+ """Same bug as the independent-column case above, for a group: an
+ untradable group fixes `k_g=1`, and a negative leg at k_g=1 was
+ silently accepted as "compliant" before this constraint existed."""
+ result = restore_drift_compliance(
+ np.array([-0.2, 0.1]),
+ ["X", "Y"],
+ np.array([False, False]),
+ [("X", "Y")],
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=True,
+ )
+ assert result.pending
+ assert result.corrected[0] == pytest.approx(-0.2)
+ assert result.corrected[1] == pytest.approx(0.1)
+
+
+def test_position_group_can_grow_past_one_when_genuinely_minimal() -> None:
+ """`k_g` is bounded only by `k_g >= 0`, never capped at 1 -- capping it
+ would incorrectly exclude a real minimal-L1 solution that requires
+ growing a group. Here an untradable, fixed column `C` alone already
+ pushes net exposure to 0.8; the only way to bring it back within
+ `maximum_net_exposure=0.5` is to grow the (net-negative) hedge group
+ past its own drifted proportions, offsetting C -- shrinking or leaving
+ it at k_g=1 cannot satisfy the constraint at all, since C cannot move.
+ """
+ drifted = np.array([0.8, 0.1, -0.3]) # group net (at k_g=1) = -0.2
+ result = restore_drift_compliance(
+ drifted,
+ ["C", "X", "Y"],
+ np.array([False, True, True]),
+ [("C",), ("X", "Y")],
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=0.5,
+ long_only=False,
+ )
+ assert not result.pending
+ # C is untradable and stays fixed; solving 0.8 - 0.2*k_g == 0.5 (the
+ # nearest feasible net exposure to k_g=1) gives k_g == 1.5.
+ expected_k = 1.5
+ assert result.corrected[0] == pytest.approx(0.8)
+ assert result.corrected[1] == pytest.approx(expected_k * 0.1)
+ assert result.corrected[2] == pytest.approx(expected_k * -0.3)
+ assert result.corrected[1] / result.corrected[2] == pytest.approx(
+ drifted[1] / drifted[2]
+ )
+
+
+def test_tradability_caused_infeasibility_uses_slack_relaxation_and_flags_pending() -> (
+ None
+):
+ """An untradable column's own drifted value already violates
+ maximum_weight -- no amount of free-column movement can fix it. The
+ slack-relaxation fallback must fire (not raise) and flag `pending`."""
+ result = restore_drift_compliance(
+ np.array([0.9]),
+ ["A"],
+ np.array([False]),
+ [("A",)],
+ maximum_weight=0.5,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ assert result.pending
+ # Nothing free exists to help -- the untradable column is unchanged.
+ assert result.corrected[0] == pytest.approx(0.9)
+
+
+def test_tradability_caused_infeasibility_uses_free_columns_to_help() -> None:
+ """When some OTHER column is free, the slack-relaxation solve should
+ still let it move to reduce the aggregate breach as much as
+ achievable, even though the untradable column itself can't be fixed."""
+ drifted = np.array([0.9, 0.3]) # A untradable & breaches alone; B tradable
+ result = restore_drift_compliance(
+ drifted,
+ ["A", "B"],
+ np.array([False, True]),
+ [("A",), ("B",)],
+ maximum_weight=None,
+ maximum_gross_exposure=0.5,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ assert result.pending
+ assert result.corrected[0] == pytest.approx(0.9)
+ # B is free and should be pulled toward 0 to minimize the slack needed.
+ assert result.corrected[1] < drifted[1]
+
+
+def test_always_feasible_and_never_pending_when_everything_is_tradable() -> None:
+ """With every column tradable, 0 is always a feasible point for every
+ free variable, so the strict LP can never be genuinely infeasible for
+ a reason other than tradability -- this is what makes the "loud raise"
+ branch an unreachable defensive invariant (mirroring `_assert_
+ holdings_compliant`'s identical philosophy) under any valid,
+ non-negative constraint configuration."""
+ rng = np.random.default_rng(7)
+ for _ in range(20):
+ drifted = rng.normal(scale=1.5, size=4)
+ result = restore_drift_compliance(
+ drifted,
+ ["A", "B", "C", "D"],
+ np.array([True, True, True, True]),
+ [("A",), ("B",), ("C",), ("D",)],
+ maximum_weight=0.4,
+ maximum_gross_exposure=1.0,
+ maximum_net_exposure=0.6,
+ long_only=False,
+ )
+ assert not result.pending
+ assert np.all(np.abs(result.corrected) <= 0.4 + 1e-6)
+ assert np.sum(np.abs(result.corrected)) <= 1.0 + 1e-6
+ assert abs(np.sum(result.corrected)) <= 0.6 + 1e-6
+
+
+def test_untradable_columns_are_bit_for_bit_unchanged_by_the_lp() -> None:
+ drifted = np.array([0.2, 0.9, -0.3])
+ result = restore_drift_compliance(
+ drifted,
+ ["A", "B", "C"],
+ np.array([True, False, True]),
+ [("A",), ("B",), ("C",)],
+ maximum_weight=0.5,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ assert result.corrected[1] == drifted[1]
+
+
+def test_shape_mismatch_raises() -> None:
+ with pytest.raises(BacktestError):
+ restore_drift_compliance(
+ np.array([0.1, 0.2]),
+ ["A"],
+ np.array([True]),
+ [("A",)],
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+
+
+def _fake_linprog_result(status: int) -> Any:
+ from scipy.optimize import OptimizeResult
+
+ return OptimizeResult(x=np.zeros(1), status=status, message="synthetic status")
+
+
+def test_strict_lp_non_infeasible_solver_failure_raises_not_silently_diagnosed(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A HiGHS status that is neither optimal (0) nor infeasible (2) -- an
+ iteration limit, an unbounded report, or numerical difficulties -- is a
+ genuine solver failure, never the ordinary "tradability caused this"
+ infeasibility the caller's diagnosis branch expects. Folding it into
+ the same `None` return the real infeasible case uses would let a
+ solver hiccup silently masquerade as an expected, best-effort
+ correction (or an unrelated 'bug in the algorithm' report) instead of
+ surfacing loudly with the solver's own status."""
+ import quantlab.portfolio.drift_compliance as drift_compliance_mod
+
+ monkeypatch.setattr(
+ drift_compliance_mod, "linprog", lambda *a, **k: _fake_linprog_result(4)
+ )
+
+ with pytest.raises(BacktestError, match="non-infeasible, non-optimal"):
+ restore_drift_compliance(
+ np.array([0.5]),
+ ["A"],
+ np.array([True]),
+ [("A",)],
+ maximum_weight=0.3,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+
+
+def test_slack_stage1_non_infeasible_solver_failure_raises() -> None:
+ """Same principle as the strict-LP case, for stage 1 of the slack-mode
+ solve: only genuine infeasibility (status 2) is treated as the
+ expected 'nothing to relax' case the always-feasible slack LP should
+ never actually hit; every other non-optimal status is a real failure
+ and must raise, not vanish into the caller's generic 'unexpectedly
+ infeasible' message."""
+ from scipy.optimize import linprog as real_linprog
+
+ import quantlab.portfolio.drift_compliance as drift_compliance_mod
+
+ calls = {"n": 0}
+
+ def fake_linprog(*args: Any, **kwargs: Any) -> Any:
+ calls["n"] += 1
+ if calls["n"] == 1:
+ # The strict LP: force genuine infeasibility so the caller
+ # proceeds to the tradability diagnosis and slack mode.
+ return _fake_linprog_result(2)
+ # Stage 1 of the slack-mode solve.
+ return _fake_linprog_result(1)
+
+ drift_compliance_mod.linprog = fake_linprog
+ try:
+ with pytest.raises(BacktestError, match="stage 1 failed"):
+ # A alone (untradable) already violates maximum_weight -- a
+ # genuine tradability-caused breach, so the strict LP's forced
+ # infeasibility above is consistent with the real diagnosis.
+ restore_drift_compliance(
+ np.array([0.6]),
+ ["A"],
+ np.array([False]),
+ [("A",)],
+ maximum_weight=0.5,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ finally:
+ drift_compliance_mod.linprog = real_linprog
+
+
+def test_slack_stage2_failure_raises_not_stage1s_unpenalized_solution() -> None:
+ """Stage 1 only minimizes total slack, with zero cost on every other
+ free variable -- if stage 2 (which adds the real L1-deviation
+ objective) unexpectedly fails to solve, silently substituting stage
+ 1's own solution could return an arbitrary, needlessly destructive
+ correction (e.g. liquidating an uninvolved column) with no indication
+ the fallback path was taken. This must raise instead."""
+ from scipy.optimize import linprog as real_linprog
+
+ import quantlab.portfolio.drift_compliance as drift_compliance_mod
+
+ calls = {"n": 0}
+
+ def fake_linprog(*args: Any, **kwargs: Any) -> Any:
+ calls["n"] += 1
+ if calls["n"] <= 2:
+ # Strict LP (infeasible) then stage 1 (slack) -- both real, so
+ # stage 1's own solution is genuinely feasible for stage 2.
+ return real_linprog(*args, **kwargs)
+ # Stage 2.
+ return _fake_linprog_result(4)
+
+ drift_compliance_mod.linprog = fake_linprog
+ try:
+ with pytest.raises(BacktestError, match="stage 2 unexpectedly failed"):
+ restore_drift_compliance(
+ np.array([0.6]),
+ ["A"],
+ np.array([False]),
+ [("A",)],
+ maximum_weight=0.5,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ finally:
+ drift_compliance_mod.linprog = real_linprog
diff --git a/tests/unit/test_features.py b/tests/unit/test_features.py
index 42f719c..dcc229f 100644
--- a/tests/unit/test_features.py
+++ b/tests/unit/test_features.py
@@ -14,8 +14,18 @@
cross_sectional_zscore,
select_top_bottom,
)
-from quantlab.features.mean_reversion import half_life, rolling_zscore, rsi
-from quantlab.features.momentum import ma_crossover_signal, momentum
+from quantlab.features.mean_reversion import (
+ half_life,
+ rolling_percentile_rank,
+ rolling_zscore,
+ rsi,
+)
+from quantlab.features.momentum import (
+ cross_sectional_momentum_persistence,
+ ma_crossover_signal,
+ momentum,
+ momentum_persistence,
+)
from quantlab.features.pipeline import FeaturePipeline
from quantlab.features.returns import (
cumulative_returns,
@@ -74,6 +84,91 @@ def test_momentum_rejects_bad_skip() -> None:
momentum(pd.Series([1.0, 2.0, 3.0]), lookback_period=5, skip_period=5)
+def test_momentum_persistence_pairs_past_score_with_future_return() -> None:
+ prices = pd.Series(np.linspace(100, 200, 100))
+ paired = momentum_persistence(
+ prices, lookback_period=20, skip_period=0, holding_period=5
+ )
+ assert list(paired.columns) == ["past_momentum", "future_return"]
+ assert not paired.empty
+ # A strictly rising series must show positive past momentum for every
+ # row that survives dropna() (the trailing/leading warm-up is excluded).
+ assert (paired["past_momentum"] > 0).all()
+ assert (paired["future_return"] > 0).all()
+
+
+def test_cross_sectional_momentum_persistence_with_known_future_ranking() -> None:
+ """5 assets with distinct constant growth rates: the momentum ranking
+ (by construction) exactly matches the future-return ranking at every
+ date, so the Spearman rank correlation must be (near) perfect and the
+ top-minus-bottom spread must be strictly positive throughout -- the
+ concrete counter-example to a single-asset past-vs-future-return
+ scatter, which cannot even express a cross-sectional ranking claim."""
+ n = 120
+ growth_rates = [0.0001, 0.0005, 0.0010, 0.0015, 0.0020]
+ prices = pd.DataFrame(
+ {
+ f"A{i}": 100.0 * (1.0 + rate) ** np.arange(n)
+ for i, rate in enumerate(growth_rates)
+ },
+ index=pd.date_range("2020-01-01", periods=n, freq="B"),
+ )
+ result = cross_sectional_momentum_persistence(
+ prices,
+ lookback_period=20,
+ skip_period=0,
+ holding_period=5,
+ top_fraction=0.2,
+ bottom_fraction=0.2,
+ )
+ assert list(result.columns) == [
+ "rank_correlation",
+ "top_return",
+ "bottom_return",
+ "top_minus_bottom",
+ ]
+ assert not result.empty
+ assert (result["rank_correlation"] > 0.99).all()
+ assert (result["top_minus_bottom"] > 0).all()
+
+
+def test_cross_sectional_momentum_persistence_rejects_non_dataframe() -> None:
+ with pytest.raises(TypeError, match="pandas DataFrame"):
+ cross_sectional_momentum_persistence(
+ pd.Series([1.0, 2.0]), # type: ignore[arg-type]
+ lookback_period=5,
+ skip_period=0,
+ holding_period=1,
+ )
+
+
+def test_cross_sectional_momentum_persistence_skips_dates_with_too_few_assets() -> None:
+ """A date with fewer than 3 scored assets cannot support a meaningful
+ rank correlation -- it must be excluded entirely, not produce a NaN row."""
+ n = 60
+ prices = pd.DataFrame(
+ {
+ "A": 100.0 * (1.01 ** np.arange(n)),
+ "B": 100.0 * (1.02 ** np.arange(n)),
+ },
+ index=pd.date_range("2020-01-01", periods=n, freq="B"),
+ )
+ result = cross_sectional_momentum_persistence(
+ prices, lookback_period=10, skip_period=0, holding_period=5
+ )
+ assert result.empty
+
+
+def test_momentum_persistence_rejects_non_series_input() -> None:
+ with pytest.raises(TypeError, match="pandas Series"):
+ momentum_persistence(
+ pd.DataFrame({"a": [1.0, 2.0]}), # type: ignore[arg-type]
+ lookback_period=5,
+ skip_period=0,
+ holding_period=1,
+ )
+
+
def test_ma_crossover_sign() -> None:
prices = pd.Series(np.linspace(100, 200, 60))
sig = ma_crossover_signal(prices, fast_window=5, slow_window=20)
@@ -110,6 +205,29 @@ def test_rsi_range() -> None:
assert r.between(0, 100).all()
+def test_rolling_percentile_rank_is_one_for_a_new_high() -> None:
+ prices = pd.Series(np.arange(1.0, 31.0)) # strictly increasing.
+ rank = rolling_percentile_rank(prices, window=20)
+ # The last observation of a strictly increasing window is its max.
+ assert rank.iloc[-1] == pytest.approx(1.0)
+
+
+def test_rolling_percentile_rank_is_lowest_for_a_new_low() -> None:
+ prices = pd.Series(np.arange(30.0, 0.0, -1.0)) # strictly decreasing.
+ rank = rolling_percentile_rank(prices, window=20)
+ # pandas' rank(pct=True) is 1-indexed, so the minimum of a 20-window
+ # scores 1/20, not exactly 0 -- still the lowest rank in that window.
+ assert rank.iloc[-1] == pytest.approx(1.0 / 20.0)
+
+
+def test_rolling_percentile_rank_is_bounded_and_nan_during_warmup() -> None:
+ rng = np.random.default_rng(3)
+ prices = pd.Series(100 + np.cumsum(rng.normal(0, 1, 100)).clip(min=-90))
+ rank = rolling_percentile_rank(prices, window=20)
+ assert rank.iloc[:19].isna().all()
+ assert rank.dropna().between(0.0, 1.0).all()
+
+
def test_half_life_detects_mean_reversion() -> None:
# AR(1) with phi < 1 mean-reverts; half-life should be finite and positive.
rng = np.random.default_rng(2)
@@ -167,3 +285,35 @@ def test_feature_pipeline_records_metadata() -> None:
assert isinstance(out.columns, pd.MultiIndex)
meta = pipe.metadata()
assert meta[0]["generated_nans"] >= 20
+
+
+# --------------------------------------------------------------------------- #
+# Public API (quantlab.features.__all__)
+# --------------------------------------------------------------------------- #
+def test_features_public_api_names_are_all_importable() -> None:
+ """Every name in `quantlab.features.__all__` must actually resolve on
+ the package -- in particular the stationarity/correlation/pairs-
+ diagnostics/efficiency-ratio/momentum-persistence surface added
+ alongside the Strategy Explorer feature, which `__init__.py` had
+ stopped re-exporting even though its own docstring calls it the
+ public API."""
+ import quantlab.features as features
+
+ assert features.__all__, "features.__all__ must not be empty"
+ for name in features.__all__:
+ assert hasattr(features, name), f"quantlab.features.{name} is missing"
+
+ for name in (
+ "efficiency_ratio",
+ "momentum_persistence",
+ "cross_sectional_momentum_persistence",
+ "correlation_matrix",
+ "ADFResult",
+ "CointegrationResult",
+ "adf_test",
+ "cointegration_test",
+ "hurst_exponent",
+ "PairDiagnostics",
+ "compute_pair_diagnostics",
+ ):
+ assert name in features.__all__, f"{name} missing from features.__all__"
diff --git a/tests/unit/test_features_correlation.py b/tests/unit/test_features_correlation.py
new file mode 100644
index 0000000..382c214
--- /dev/null
+++ b/tests/unit/test_features_correlation.py
@@ -0,0 +1,55 @@
+"""Tests for the multi-asset correlation matrix diagnostic."""
+
+from __future__ import annotations
+
+from typing import cast
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from quantlab.features.correlation import correlation_matrix
+
+
+def test_correlation_matrix_diagonal_is_one() -> None:
+ index = pd.date_range("2020-01-01", periods=100, freq="D")
+ rng = np.random.default_rng(0)
+ prices = pd.DataFrame(
+ {
+ "A": 100.0 * np.cumprod(1.0 + rng.normal(0.0005, 0.01, 100)),
+ "B": 100.0 * np.cumprod(1.0 + rng.normal(0.0003, 0.01, 100)),
+ },
+ index=index,
+ )
+ matrix = correlation_matrix(prices)
+ assert cast(float, matrix.loc["A", "A"]) == pytest.approx(1.0)
+ assert cast(float, matrix.loc["B", "B"]) == pytest.approx(1.0)
+ assert cast(float, matrix.loc["A", "B"]) == pytest.approx(
+ cast(float, matrix.loc["B", "A"])
+ )
+
+
+def test_correlation_matrix_detects_strongly_correlated_assets() -> None:
+ index = pd.date_range("2020-01-01", periods=200, freq="D")
+ rng = np.random.default_rng(1)
+ base_returns = rng.normal(0.0004, 0.01, 200)
+ a = 100.0 * np.cumprod(1.0 + base_returns)
+ b = 50.0 * np.cumprod(1.0 + base_returns + rng.normal(0.0, 0.0005, 200))
+ c = 100.0 * np.cumprod(1.0 + rng.normal(0.0002, 0.015, 200))
+ prices = pd.DataFrame({"A": a, "B": b, "C": c}, index=index)
+ matrix = correlation_matrix(prices)
+ ab = cast(float, matrix.loc["A", "B"])
+ ac = cast(float, matrix.loc["A", "C"])
+ assert ab > 0.9
+ assert abs(ac) < ab
+
+
+def test_correlation_matrix_rejects_unknown_method() -> None:
+ prices = pd.DataFrame({"A": [1.0, 2.0, 3.0]})
+ with pytest.raises(ValueError, match="method"):
+ correlation_matrix(prices, method="bogus") # type: ignore[arg-type]
+
+
+def test_correlation_matrix_rejects_non_dataframe() -> None:
+ with pytest.raises(TypeError, match="DataFrame"):
+ correlation_matrix(pd.Series([1.0, 2.0])) # type: ignore[arg-type]
diff --git a/tests/unit/test_features_extra.py b/tests/unit/test_features_extra.py
index bbb1757..5aa5f8f 100644
--- a/tests/unit/test_features_extra.py
+++ b/tests/unit/test_features_extra.py
@@ -38,6 +38,25 @@ def test_rolling_channels_and_donchian() -> None:
assert pos.between(-0.01, 1.01).all()
+def test_efficiency_ratio_is_high_for_a_clean_trend_and_low_for_noise() -> None:
+ trending = pd.Series(np.linspace(100, 200, 60))
+ rng = np.random.default_rng(0)
+ choppy = pd.Series(100.0 + np.cumsum(rng.normal(0.0, 1.0, 60)))
+ trending_ratio = T.efficiency_ratio(trending, 20).dropna()
+ choppy_ratio = T.efficiency_ratio(choppy, 20).dropna()
+ assert trending_ratio.between(0.0, 1.0).all()
+ assert choppy_ratio.between(0.0, 1.0).all()
+ # A perfectly monotonic trend's net move equals its total path length.
+ assert trending_ratio.iloc[-1] == pytest.approx(1.0)
+ assert trending_ratio.mean() > choppy_ratio.mean()
+
+
+def test_efficiency_ratio_is_neutral_for_a_flat_window() -> None:
+ flat = pd.Series(np.full(30, 100.0))
+ ratio = T.efficiency_ratio(flat, 10).dropna()
+ assert (ratio == 0.5).all()
+
+
def test_log_returns_and_equity_curve() -> None:
prices = pd.Series([100.0, 110.0, 99.0])
lr = log_returns(prices)
diff --git a/tests/unit/test_features_stationarity.py b/tests/unit/test_features_stationarity.py
new file mode 100644
index 0000000..e4f9d4c
--- /dev/null
+++ b/tests/unit/test_features_stationarity.py
@@ -0,0 +1,225 @@
+"""Tests for stationarity/cointegration/persistence diagnostics."""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from quantlab.features.stationarity import (
+ adf_test,
+ cointegration_test,
+ hurst_exponent,
+)
+
+
+def _mean_reverting_series(
+ n: int = 300, *, seed: int = 0, lam: float = 0.3
+) -> pd.Series:
+ """A strongly mean-reverting AR(1) series: x_t = (1 - lam) * x_{t-1} + noise."""
+ rng = np.random.default_rng(seed)
+ values = np.zeros(n)
+ for t in range(1, n):
+ values[t] = (1.0 - lam) * values[t - 1] + rng.normal(0.0, 1.0)
+ return pd.Series(values)
+
+
+def _random_walk(n: int = 300, *, seed: int = 0, drift: float = 0.0) -> pd.Series:
+ rng = np.random.default_rng(seed)
+ steps = rng.normal(drift, 1.0, n)
+ return pd.Series(np.cumsum(steps))
+
+
+# --------------------------------------------------------------------------- #
+# adf_test
+# --------------------------------------------------------------------------- #
+def test_adf_test_rejects_null_for_a_strongly_mean_reverting_series() -> None:
+ result = adf_test(_mean_reverting_series())
+ assert result is not None
+ assert result.reject_null is True
+ assert result.pvalue <= 0.05
+ assert "stationarity" in result.interpretation
+
+
+def test_adf_test_does_not_reject_null_for_a_random_walk() -> None:
+ result = adf_test(_random_walk())
+ assert result is not None
+ assert result.reject_null is False
+ assert result.pvalue > 0.05
+
+
+def test_adf_test_returns_none_for_too_few_observations() -> None:
+ assert adf_test(pd.Series(np.arange(10, dtype=float))) is None
+
+
+def test_adf_test_returns_none_for_a_constant_series() -> None:
+ assert adf_test(pd.Series(np.full(50, 3.0))) is None
+
+
+def test_adf_test_rejects_non_series_input() -> None:
+ with pytest.raises(TypeError, match="pandas Series"):
+ adf_test(pd.DataFrame({"a": [1.0, 2.0]})) # type: ignore[arg-type]
+
+
+@pytest.mark.parametrize("bad_level", [0.0, 1.0, -0.1, 1.5])
+def test_adf_test_rejects_bad_significance(bad_level: float) -> None:
+ with pytest.raises(ValueError, match="significance"):
+ adf_test(_random_walk(), significance=bad_level)
+
+
+def test_adf_test_critical_values_and_metadata_are_populated() -> None:
+ result = adf_test(_mean_reverting_series())
+ assert result is not None
+ assert set(result.critical_values) == {"1%", "5%", "10%"}
+ assert result.n_obs > 0
+ assert result.n_lags >= 0
+
+
+def test_adf_test_returns_none_for_a_non_finite_statistic(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A non-finite ADF statistic (even with a finite p-value) is a
+ numerically degenerate result -- must be treated as inconclusive."""
+
+ def _fake_adfuller(values: object, autolag: str) -> tuple[object, ...]:
+ return (float("inf"), 0.01, 1, 100, {"1%": -3.5, "5%": -2.9, "10%": -2.6}, 0.0)
+
+ monkeypatch.setattr(
+ "statsmodels.tsa.stattools.adfuller",
+ _fake_adfuller,
+ )
+ assert adf_test(_mean_reverting_series()) is None
+
+
+def test_adf_test_returns_none_for_a_non_finite_critical_value(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ def _fake_adfuller(values: object, autolag: str) -> tuple[object, ...]:
+ return (
+ -3.0,
+ 0.01,
+ 1,
+ 100,
+ {"1%": float("nan"), "5%": -2.9, "10%": -2.6},
+ 0.0,
+ )
+
+ monkeypatch.setattr(
+ "statsmodels.tsa.stattools.adfuller",
+ _fake_adfuller,
+ )
+ assert adf_test(_mean_reverting_series()) is None
+
+
+# --------------------------------------------------------------------------- #
+# cointegration_test
+# --------------------------------------------------------------------------- #
+def test_cointegration_test_detects_a_cointegrated_pair() -> None:
+ common_trend = _random_walk(seed=1)
+ noise = pd.Series(np.random.default_rng(2).normal(0.0, 0.5, len(common_trend)))
+ a = common_trend
+ b = common_trend * 1.5 + noise
+ result = cointegration_test(a, b)
+ assert result is not None
+ assert result.reject_null is True
+ assert result.pvalue <= 0.05
+
+
+def test_cointegration_test_does_not_reject_null_for_independent_walks() -> None:
+ a = _random_walk(seed=10)
+ b = _random_walk(seed=20)
+ result = cointegration_test(a, b)
+ assert result is not None
+ assert result.reject_null is False
+
+
+def test_cointegration_test_returns_none_for_near_perfect_collinearity() -> None:
+ """``b = 2 * a`` is (near-)perfectly collinear -- statsmodels itself
+ warns the test is numerically unreliable in this case (a spurious
+ ``statistic=-inf``/``pvalue=0.0`` "confident" result otherwise). Must
+ be treated as inconclusive (``None``), not returned as evidence of a
+ stable long-run relationship."""
+ a = _random_walk(seed=3)
+ b = a * 2.0
+ assert cointegration_test(a, b) is None
+
+
+def test_cointegration_test_returns_none_for_a_non_finite_critical_value(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A non-finite critical value is as numerically degenerate as a
+ non-finite statistic/p-value -- must be treated as inconclusive."""
+
+ def _fake_coint(a: object, b: object) -> tuple[float, float, list[float]]:
+ return (-3.0, 0.01, [float("nan"), -3.3, -3.0])
+
+ monkeypatch.setattr("statsmodels.tsa.stattools.coint", _fake_coint)
+ a = _random_walk(seed=1)
+ b = _random_walk(seed=2)
+ assert cointegration_test(a, b) is None
+
+
+def test_cointegration_test_returns_none_for_too_few_observations() -> None:
+ short_a = pd.Series(np.arange(10, dtype=float))
+ short_b = pd.Series(np.arange(10, dtype=float) * 2)
+ assert cointegration_test(short_a, short_b) is None
+
+
+def test_cointegration_test_rejects_non_series_input() -> None:
+ with pytest.raises(TypeError, match="pandas Series"):
+ cointegration_test(pd.DataFrame({"a": [1.0]}), pd.Series([1.0])) # type: ignore[arg-type]
+
+
+def test_cointegration_test_rejects_mismatched_axes() -> None:
+ a = pd.Series(np.arange(30, dtype=float))
+ b = pd.Series(np.arange(30, dtype=float), index=np.arange(30, 60))
+ with pytest.raises(ValueError, match="index"):
+ cointegration_test(a, b)
+
+
+# --------------------------------------------------------------------------- #
+# hurst_exponent
+# --------------------------------------------------------------------------- #
+def test_hurst_exponent_is_low_for_a_mean_reverting_series() -> None:
+ h = hurst_exponent(_mean_reverting_series(n=500, lam=0.5))
+ assert h < 0.4
+
+
+def test_hurst_exponent_is_near_half_for_a_random_walk() -> None:
+ h = hurst_exponent(_random_walk(n=2000))
+ assert 0.35 < h < 0.65
+
+
+def test_hurst_exponent_is_high_for_a_trending_series() -> None:
+ """A deterministic straight line isn't the right synthetic case here: its
+ lag-k differences are a constant plus noise, so their *variance* doesn't
+ grow with lag at all (the estimator reads that as H ~= 0, not high).
+ Genuine persistence needs positively autocorrelated *increments* (each
+ step likely continues the last one's direction) accumulated into a walk
+ -- the standard way to simulate trending/persistent fBm-like data."""
+ rng = np.random.default_rng(3)
+ increments = np.zeros(500)
+ for t in range(1, len(increments)):
+ increments[t] = 0.6 * increments[t - 1] + rng.normal(0.0, 1.0)
+ trend = pd.Series(np.cumsum(increments))
+ h = hurst_exponent(trend)
+ assert h > 0.6
+
+
+def test_hurst_exponent_is_nan_for_too_short_a_series() -> None:
+ assert np.isnan(hurst_exponent(pd.Series(np.arange(5, dtype=float)), max_lag=20))
+
+
+def test_hurst_exponent_is_nan_for_a_constant_series() -> None:
+ assert np.isnan(hurst_exponent(pd.Series(np.full(100, 5.0))))
+
+
+def test_hurst_exponent_rejects_non_series_input() -> None:
+ with pytest.raises(TypeError, match="pandas Series"):
+ hurst_exponent(pd.DataFrame({"a": [1.0, 2.0]})) # type: ignore[arg-type]
+
+
+@pytest.mark.parametrize("bad_lag", [0, 1, -5])
+def test_hurst_exponent_rejects_bad_max_lag(bad_lag: int) -> None:
+ with pytest.raises(ValueError, match="max_lag"):
+ hurst_exponent(_random_walk(), max_lag=bad_lag)
diff --git a/tests/unit/test_native_calendar.py b/tests/unit/test_native_calendar.py
new file mode 100644
index 0000000..5ba7833
--- /dev/null
+++ b/tests/unit/test_native_calendar.py
@@ -0,0 +1,188 @@
+"""Direct tests for `compute_native_then_align`, the shared helper that
+computes a rolling-window feature on each symbol's own native calendar
+before aligning it back onto a closure-padded combined timeline."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+import pandas as pd
+import pytest
+
+from quantlab.features.native_calendar import compute_native_then_align
+
+
+def _mean_of_last(window: int) -> Callable[[pd.DataFrame], pd.DataFrame]:
+ return lambda p: p.rolling(window, min_periods=window).mean()
+
+
+def test_symbol_calendars_none_short_circuits_to_compute_fn() -> None:
+ prices = pd.DataFrame(
+ {"AAA": [1.0, 2.0, 3.0]}, index=pd.date_range("2024-01-01", periods=3)
+ )
+ calls = []
+
+ def compute_fn(p: pd.DataFrame) -> pd.DataFrame:
+ calls.append(p)
+ return p * 2.0
+
+ result = compute_native_then_align(
+ compute_fn, prices, None, pd.DatetimeIndex(prices.index)
+ )
+
+ pd.testing.assert_frame_equal(result, prices * 2.0)
+ assert len(calls) == 1
+
+
+def test_no_calendar_for_any_column_short_circuits() -> None:
+ prices = pd.DataFrame(
+ {"AAA": [1.0, 2.0, 3.0]}, index=pd.date_range("2024-01-01", periods=3)
+ )
+ result = compute_native_then_align(
+ lambda p: p * 2.0, prices, {"ZZZ": "XNYS"}, pd.DatetimeIndex(prices.index)
+ )
+ pd.testing.assert_frame_equal(result, prices * 2.0)
+
+
+def test_no_actual_closure_in_range_short_circuits_byte_identical() -> None:
+ """A calendar is configured, but every row in `prices.index` happens to
+ be a real session on it (e.g. only business days present) -- must take
+ the fast path (compute once on the whole frame), byte-identical to the
+ single-calendar vectorized call."""
+ prices = pd.DataFrame(
+ {"AAA": [1.0, 2.0, 3.0, 4.0]},
+ index=pd.date_range("2024-01-02", periods=4, freq="B"),
+ )
+ result = compute_native_then_align(
+ _mean_of_last(2), prices, {"AAA": "XNYS"}, pd.DatetimeIndex(prices.index)
+ )
+ expected = prices.rolling(2, min_periods=2).mean()
+ pd.testing.assert_frame_equal(result, expected)
+
+
+def test_uniform_calendar_fast_path_covers_multiple_columns_sharing_it() -> None:
+ """The uniform-calendar short-circuit (`len(calendars) ==
+ len(prices.columns) and uniform_calendar(...) is not None`) exists
+ precisely for a genuine multi-column, single-calendar universe (e.g.
+ several XNYS equities together, per this module's own docstring) --
+ a single column trivially satisfies "every column shares one
+ calendar" without exercising more than one, so this test uses three.
+ Confirms both the byte-for-byte result AND that `compute_fn` is
+ called exactly ONCE on the full multi-column frame, never once per
+ column."""
+ prices = pd.DataFrame(
+ {
+ "AAA": [1.0, 2.0, 3.0, 4.0],
+ "BBB": [10.0, 20.0, 30.0, 40.0],
+ "CCC": [100.0, 200.0, 300.0, 400.0],
+ },
+ index=pd.date_range("2024-01-02", periods=4, freq="B"),
+ )
+ calls: list[pd.DataFrame] = []
+
+ def compute_fn(p: pd.DataFrame) -> pd.DataFrame:
+ calls.append(p.copy())
+ return p.rolling(2, min_periods=2).mean()
+
+ result = compute_native_then_align(
+ compute_fn,
+ prices,
+ {"AAA": "XNYS", "BBB": "XNYS", "CCC": "XNYS"},
+ pd.DatetimeIndex(prices.index),
+ )
+
+ expected = prices.rolling(2, min_periods=2).mean()
+ pd.testing.assert_frame_equal(result, expected)
+ assert len(calls) == 1
+ assert list(calls[0].columns) == ["AAA", "BBB", "CCC"]
+
+
+def test_native_computation_removes_closure_dilution() -> None:
+ """The core fix: AAA (XNYS) is closed over a weekend shared with BTC
+ (24/7) on the same combined timeline. A 3-period rolling mean computed
+ on AAA's own native (session-only) dates must differ from -- and be
+ more accurate than -- the same rolling mean computed directly on the
+ closure-padded combined timeline."""
+ dates = pd.DatetimeIndex(
+ [
+ "2024-01-04", # Thu (AAA open)
+ "2024-01-05", # Fri (AAA open)
+ "2024-01-06", # Sat (AAA closed, verified XNYS weekend)
+ "2024-01-07", # Sun (AAA closed, verified XNYS weekend)
+ "2024-01-08", # Mon (AAA open)
+ "2024-01-09", # Tue (AAA open)
+ ]
+ )
+ # AAA's padded series: flat-filled (last real close) on the weekend,
+ # exactly as `insert_verified_closure_bars` produces in production.
+ aaa = pd.Series([10.0, 12.0, 12.0, 12.0, 16.0, 20.0], index=dates)
+ btc = pd.Series([100.0, 101.0, 102.0, 103.0, 104.0, 105.0], index=dates)
+ prices = pd.DataFrame({"AAA": aaa, "BTC": btc})
+
+ result = compute_native_then_align(
+ _mean_of_last(3),
+ prices,
+ {"AAA": "XNYS", "BTC": "24/7"},
+ pd.DatetimeIndex(prices.index),
+ )
+
+ # Native AAA rolling(3): only real sessions Thu/Fri/Mon/Tue contribute.
+ # Warm-up needs 3 native observations, so only Mon (Thu,Fri,Mon) and Tue
+ # (Fri,Mon,Tue) are defined; the weekend rows forward-fill from Friday's
+ # own (still-NaN, insufficient-warmup) native result, so they stay NaN.
+ assert pd.isna(result.loc["2024-01-04", "AAA"])
+ assert pd.isna(result.loc["2024-01-05", "AAA"])
+ assert pd.isna(result.loc["2024-01-06", "AAA"])
+ assert pd.isna(result.loc["2024-01-07", "AAA"])
+ assert result.loc["2024-01-08", "AAA"] == pytest.approx((10.0 + 12.0 + 16.0) / 3.0)
+ assert result.loc["2024-01-09", "AAA"] == pytest.approx((12.0 + 16.0 + 20.0) / 3.0)
+
+ # The naive diluted computation (directly on the padded frame) would
+ # have given a materially different, wrong answer for Monday.
+ diluted = prices.rolling(3, min_periods=3).mean()
+ assert diluted.loc["2024-01-08", "AAA"] != pytest.approx(
+ result.loc["2024-01-08", "AAA"]
+ )
+
+ # BTC has no closures at all -- untouched, byte-identical to a plain
+ # vectorized computation on its own full series.
+ expected_btc = btc.rolling(3, min_periods=3).mean()
+ pd.testing.assert_series_equal(result["BTC"], expected_btc, check_names=False)
+
+
+def test_symbol_with_no_calendar_entry_computed_directly() -> None:
+ """A column absent from `symbol_calendars` is treated as always open --
+ computed directly, never sliced.
+
+ Regression test: the original version of this test used a calendar
+ with zero actual closures in range (or no calendar at all), which hits
+ `compute_native_then_align`'s own EARLIER short-circuits (a calendar-
+ less universe, or one where nothing genuinely closed, both return
+ before ever reaching the per-column loop this test means to exercise)
+ -- it happened to pass, but for the wrong reason, never actually
+ running the `calendar is None` branch it claimed to cover. AAA (XNYS)
+ below has a genuine weekend closure, forcing the function past both
+ short-circuits into the per-column loop; ZZZ, absent from
+ `symbol_calendars` entirely, must then be reached by THAT loop and
+ take its own `calendar is None` branch."""
+ dates = pd.DatetimeIndex(
+ [
+ "2024-01-04", # Thu (AAA open)
+ "2024-01-05", # Fri (AAA open)
+ "2024-01-06", # Sat (AAA closed, verified XNYS weekend)
+ "2024-01-07", # Sun (AAA closed, verified XNYS weekend)
+ "2024-01-08", # Mon (AAA open)
+ ]
+ )
+ aaa = pd.Series([10.0, 12.0, 12.0, 12.0, 16.0], index=dates)
+ zzz = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0], index=dates)
+ prices = pd.DataFrame({"AAA": aaa, "ZZZ": zzz})
+
+ result = compute_native_then_align(
+ _mean_of_last(2), prices, {"AAA": "XNYS"}, pd.DatetimeIndex(prices.index)
+ )
+
+ # ZZZ has no calendar entry at all -- computed directly on its own
+ # full (unsliced) column, byte-identical to a plain vectorized call.
+ expected_zzz = prices[["ZZZ"]].rolling(2, min_periods=2).mean()["ZZZ"]
+ pd.testing.assert_series_equal(result["ZZZ"], expected_zzz, check_names=False)
diff --git a/tests/unit/test_native_calendar_strategies.py b/tests/unit/test_native_calendar_strategies.py
new file mode 100644
index 0000000..3585b8e
--- /dev/null
+++ b/tests/unit/test_native_calendar_strategies.py
@@ -0,0 +1,406 @@
+"""Wiring tests: every rolling-window feature call site in a strategy (and
+`runner.py`'s ADV) must route through `compute_native_then_align` via
+`BaseStrategy.symbol_calendars` / `self._native_feature`, and must be a
+provable no-op (byte-identical) when `symbol_calendars` is unset.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from quantlab.constants import (
+ ADJUSTED_CLOSE,
+ CLOSE,
+ HIGH,
+ LOW,
+ OPEN,
+ SYMBOL,
+ TIMESTAMP,
+ VOLUME,
+)
+
+
+def _long_from_wide(prices: pd.DataFrame) -> pd.DataFrame:
+ frames = []
+ for symbol in prices.columns:
+ series = prices[symbol]
+ frames.append(
+ pd.DataFrame(
+ {
+ TIMESTAMP: series.index,
+ SYMBOL: symbol,
+ OPEN: series.to_numpy(),
+ HIGH: series.to_numpy(),
+ LOW: series.to_numpy(),
+ CLOSE: series.to_numpy(),
+ ADJUSTED_CLOSE: series.to_numpy(),
+ VOLUME: 1_000.0,
+ }
+ )
+ )
+ return pd.concat(frames, ignore_index=True)
+
+
+def _mixed_calendar_panel(periods: int = 42) -> pd.DataFrame:
+ """AAA (session-bound, closed weekends) + BTC (24/7), 42 calendar days
+ (30 native AAA trading days -- enough margin above pairs_trading's
+ ``formation_window`` minimum of 20).
+
+ AAA's weekend rows are flat-filled from the prior real close, exactly
+ matching `insert_verified_closure_bars`'s own production convention.
+ """
+ dates = pd.date_range("2024-01-01", periods=periods, freq="D") # Jan 1 = Monday
+ is_weekend = dates.weekday >= 5
+ aaa = np.empty(len(dates))
+ trading_values = np.linspace(100.0, 150.0, num=int((~is_weekend).sum()))
+ aaa[~is_weekend] = trading_values
+ last = np.nan
+ for i in range(len(dates)):
+ if is_weekend[i]:
+ aaa[i] = last
+ else:
+ last = aaa[i]
+ btc = np.linspace(40_000.0, 41_260.0, num=len(dates))
+ return pd.DataFrame({"AAA": aaa, "BTC": btc}, index=dates)
+
+
+_CALENDARS = {"AAA": "XNYS", "BTC": "24/7"}
+
+
+def test_time_series_momentum_native_calendar_changes_diluted_output() -> None:
+ from quantlab.strategies.momentum import TimeSeriesMomentumStrategy
+
+ prices = _mixed_calendar_panel()
+ data = _long_from_wide(prices)
+
+ diluted = TimeSeriesMomentumStrategy(
+ lookback_period=10, skip_period=0, signal_scaling="binary"
+ )
+ diluted_signals = diluted.generate_signals(data)
+
+ native = TimeSeriesMomentumStrategy(
+ lookback_period=10, skip_period=0, signal_scaling="binary"
+ )
+ native.symbol_calendars = _CALENDARS
+ native_signals = native.generate_signals(data)
+
+ # BTC has no closures at all -- untouched either way.
+ pd.testing.assert_series_equal(
+ diluted_signals["BTC"], native_signals["BTC"], check_names=False
+ )
+ # AAA's dilution genuinely changes at least one date's signal.
+ assert not diluted_signals["AAA"].equals(native_signals["AAA"])
+
+
+def test_cross_sectional_momentum_native_calendar_changes_diluted_output() -> None:
+ from quantlab.strategies.momentum import CrossSectionalMomentumStrategy
+
+ prices = _mixed_calendar_panel()
+ data = _long_from_wide(prices)
+
+ diluted = CrossSectionalMomentumStrategy(
+ lookback_period=10, skip_period=0, top_fraction=0.5, signal_scaling="binary"
+ )
+ diluted_signals = diluted.generate_signals(data)
+
+ native = CrossSectionalMomentumStrategy(
+ lookback_period=10, skip_period=0, top_fraction=0.5, signal_scaling="binary"
+ )
+ native.symbol_calendars = _CALENDARS
+ native_signals = native.generate_signals(data)
+
+ assert not diluted_signals.equals(native_signals)
+
+
+def test_trend_following_native_calendar_changes_diluted_output() -> None:
+ from quantlab.strategies.trend_following import TrendFollowingStrategy
+
+ prices = _mixed_calendar_panel()
+ data = _long_from_wide(prices)
+
+ diluted = TrendFollowingStrategy(fast_window=3, slow_window=10)
+ diluted_signals = diluted.generate_signals(data)
+
+ native = TrendFollowingStrategy(fast_window=3, slow_window=10)
+ native.symbol_calendars = _CALENDARS
+ native_signals = native.generate_signals(data)
+
+ pd.testing.assert_series_equal(
+ diluted_signals["BTC"], native_signals["BTC"], check_names=False
+ )
+ assert not diluted_signals["AAA"].equals(native_signals["AAA"])
+
+
+def test_mean_reversion_native_calendar_changes_diluted_output() -> None:
+ """Exercises `_centered_indicator` directly (rather than the full
+ entry/exit state machine) -- this simple synthetic price panel never
+ crosses the default z-score entry threshold, so the post-threshold
+ SIGNAL would be an uninformative constant zero either way; the
+ underlying INDICATOR is where the native-calendar wrapping actually
+ shows up."""
+ from quantlab.strategies.mean_reversion import _centered_indicator
+
+ prices = _mixed_calendar_panel()
+
+ diluted = _centered_indicator(prices, "zscore", 10, 2.0)
+ native = _centered_indicator(prices, "zscore", 10, 2.0, _CALENDARS)
+
+ pd.testing.assert_series_equal(diluted["BTC"], native["BTC"], check_names=False)
+ assert not diluted["AAA"].equals(native["AAA"])
+
+
+def test_engine_collapses_position_group_tradability_end_to_end() -> None:
+ """`BacktestEngine.run()` must gate a declared position group's
+ tradability as ONE unit (both legs eligible only on a date BOTH are
+ open), not per-leg independently -- otherwise a rebalance could move
+ one leg of a pair while the other stays frozen, introducing unmodeled
+ legging risk. Checked at the `result.weights` level: across the whole
+ backtest, AAA's executed weight must change on a date if and only if
+ BTC's does too."""
+ from tests.conftest import geometric_series
+
+ from quantlab.backtesting.runner import run_backtest_from_config
+ from quantlab.config import ExperimentConfig
+
+ dates = pd.date_range("2024-01-01", periods=90, freq="D")
+ is_weekend = dates.weekday >= 5
+ aaa_native = geometric_series(
+ int((~is_weekend).sum()), mu=0.0, sigma=0.02, s0=100.0, seed=11
+ )
+ aaa = np.empty(len(dates))
+ aaa[~is_weekend] = aaa_native
+ last = np.nan
+ for i in range(len(dates)):
+ if is_weekend[i]:
+ aaa[i] = last
+ else:
+ last = aaa[i]
+ btc = geometric_series(len(dates), mu=0.0, sigma=0.02, s0=100.0, seed=22)
+ prices = pd.DataFrame({"AAA": aaa, "BTC": btc}, index=dates)
+ data = _long_from_wide(prices)
+
+ cfg = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "pairs_group_tradability",
+ "data": {
+ "instruments": [
+ {"symbol": "AAA", "source": "csv", "calendar": "XNYS"},
+ {"symbol": "BTC", "source": "csv", "calendar": "24/7"},
+ ],
+ "start_date": "2024-01-01",
+ "end_date": "2024-03-30",
+ },
+ "strategy": {
+ "name": "pairs_trading",
+ "parameters": {
+ "symbol_a": "AAA",
+ "symbol_b": "BTC",
+ "formation_window": 20,
+ "indicator_window": 5,
+ "indicator": "percentile",
+ "entry_threshold": 0.30,
+ "exit_threshold": 0.05,
+ "adf_pvalue_threshold": None,
+ "dynamic_hedge_ratio": True,
+ },
+ },
+ "portfolio": {
+ "allocator": "signal_proportional",
+ "rebalance_frequency": "daily",
+ },
+ "execution": {},
+ "backtest": {"periods_per_year": 252},
+ }
+ )
+
+ result = run_backtest_from_config(data, cfg)
+
+ aaa_changed = result.weights["AAA"].diff().abs() > 1e-9
+ btc_changed = result.weights["BTC"].diff().abs() > 1e-9
+ pd.testing.assert_series_equal(aaa_changed, btc_changed, check_names=False)
+ assert bool(aaa_changed.any()) # the invariant must be exercised, not vacuous
+
+
+def test_pairs_trading_native_pair_context_uses_intersection_of_native_calendars() -> (
+ None
+):
+ from quantlab.strategies.pairs_trading import PairsTradingStrategy
+
+ prices = _mixed_calendar_panel()
+
+ diluted = PairsTradingStrategy(
+ symbol_a="AAA",
+ symbol_b="BTC",
+ formation_window=20,
+ indicator_window=5,
+ adf_pvalue_threshold=None,
+ )
+ _, _, diluted_indicator, _, diluted_tradable = diluted._native_pair_context(prices)
+
+ native = PairsTradingStrategy(
+ symbol_a="AAA",
+ symbol_b="BTC",
+ formation_window=20,
+ indicator_window=5,
+ adf_pvalue_threshold=None,
+ )
+ native.symbol_calendars = _CALENDARS
+ _, _, native_indicator, _, native_tradable = native._native_pair_context(prices)
+
+ assert not diluted_indicator.equals(native_indicator)
+ # No injected calendar -> both legs always considered open.
+ assert bool(diluted_tradable.all())
+ # Injected mixed calendars -> weekends/holidays correctly block entry.
+ assert not bool(native_tradable.all())
+
+
+def test_pairs_trading_entry_gate_matches_symbol_a_native_calendar() -> None:
+ """`symbol_b` (BTC) is 24/7, so the combined entry gate must reduce
+ exactly to `symbol_a`'s (AAA/XNYS) own native session mask when the ADF
+ stationarity gate is disabled."""
+ from quantlab.data.calendar import is_session_day
+ from quantlab.strategies.pairs_trading import PairsTradingStrategy
+
+ prices = _mixed_calendar_panel()
+ strategy = PairsTradingStrategy(
+ symbol_a="AAA",
+ symbol_b="BTC",
+ formation_window=20,
+ indicator_window=5,
+ adf_pvalue_threshold=None,
+ )
+ strategy.symbol_calendars = _CALENDARS
+ _, _, _, _, tradable = strategy._native_pair_context(prices)
+
+ expected = is_session_day("XNYS", pd.DatetimeIndex(prices.index))
+ np.testing.assert_array_equal(tradable, expected)
+
+
+def test_build_execution_from_config_adv_uses_native_calendar() -> None:
+ """A weekend closure's synthetic zero-volume bar must never drag down a
+ session-bound symbol's own trailing dollar-ADV -- the window's content
+ is computed on its own native calendar, not the closure-padded
+ combined timeline."""
+ from quantlab.backtesting.runner import build_execution_from_config
+ from quantlab.config import ExperimentConfig
+ from quantlab.execution.slippage import VolumeBasedSlippageModel
+
+ dates = pd.date_range("2024-01-01", periods=28, freq="D")
+ is_weekend = dates.weekday >= 5
+ aaa_close = np.full(len(dates), 100.0)
+ aaa_volume = np.where(is_weekend, 0.0, 1_000_000.0)
+ btc_close = np.full(len(dates), 40_000.0)
+ btc_volume = np.full(len(dates), 500_000.0)
+
+ data = pd.concat(
+ [
+ pd.DataFrame(
+ {
+ TIMESTAMP: dates,
+ SYMBOL: "AAA",
+ OPEN: aaa_close,
+ HIGH: aaa_close,
+ LOW: aaa_close,
+ CLOSE: aaa_close,
+ ADJUSTED_CLOSE: aaa_close,
+ VOLUME: aaa_volume,
+ }
+ ),
+ pd.DataFrame(
+ {
+ TIMESTAMP: dates,
+ SYMBOL: "BTC",
+ OPEN: btc_close,
+ HIGH: btc_close,
+ LOW: btc_close,
+ CLOSE: btc_close,
+ ADJUSTED_CLOSE: btc_close,
+ VOLUME: btc_volume,
+ }
+ ),
+ ],
+ ignore_index=True,
+ )
+ cfg = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "adv_native",
+ "data": {
+ "instruments": [
+ {"symbol": "AAA", "source": "csv", "calendar": "XNYS"},
+ {"symbol": "BTC", "source": "csv", "calendar": "24/7"},
+ ],
+ "start_date": "2024-01-01",
+ "end_date": "2024-01-28",
+ },
+ "strategy": {"name": "buy_and_hold"},
+ "execution": {"slippage_model": "volume", "slippage_bps": 5.0},
+ "backtest": {"periods_per_year": 252},
+ }
+ )
+
+ execution = build_execution_from_config(cfg, data)
+ slippage = execution.slippage
+ assert isinstance(slippage, VolumeBasedSlippageModel)
+ adv = slippage.average_daily_volume
+ assert isinstance(adv, pd.DataFrame)
+
+ naive = (
+ pd.DataFrame(
+ {"AAA": aaa_volume * aaa_close, "BTC": btc_volume * btc_close}, index=dates
+ )
+ .rolling(21, min_periods=1)
+ .mean()
+ .shift(1)
+ )
+ assert not adv["AAA"].equals(naive["AAA"])
+ # Every real AAA trading day has an identical dollar volume; its native
+ # trailing ADV must therefore be exactly constant, never diluted below
+ # this by a weekend's zero-volume synthetic bar.
+ assert np.allclose(adv["AAA"].dropna().to_numpy(), 100_000_000.0)
+
+
+def test_symbol_calendars_none_is_byte_identical_to_no_wrapper() -> None:
+ """Regression safety gate: an unset `symbol_calendars` (the default,
+ e.g. a strategy built directly in a unit test outside the engine) must
+ reproduce today's plain vectorized computation exactly."""
+ from quantlab.features.momentum import momentum
+ from quantlab.strategies.momentum import TimeSeriesMomentumStrategy
+
+ prices = _mixed_calendar_panel()
+ data = _long_from_wide(prices)
+ strategy = TimeSeriesMomentumStrategy(
+ lookback_period=10, skip_period=0, signal_scaling="binary"
+ )
+ signals = strategy.generate_signals(data)
+
+ expected_score = momentum(prices, 10, 0)
+ expected = pd.DataFrame(
+ np.sign(expected_score.to_numpy()),
+ index=expected_score.index,
+ columns=expected_score.columns,
+ ).fillna(0.0)
+ pd.testing.assert_frame_equal(
+ signals, expected, check_dtype=False, check_names=False, check_freq=False
+ )
+
+
+def test_parameters_excludes_symbol_calendars() -> None:
+ """`symbol_calendars` is engine-injected context, never a user-supplied
+ hyperparameter -- `BaseStrategy.parameters()` must never surface it in
+ a config-YAML round-trip, execution-model hash, or sweep-parameter
+ enumeration (see `_NON_PARAMETER_ATTRIBUTES`). Relied on throughout
+ this module (every other test here sets `.symbol_calendars` directly,
+ bypassing the constructor-parameter freeze) but never directly
+ asserted until now."""
+ from quantlab.strategies.momentum import TimeSeriesMomentumStrategy
+
+ strategy = TimeSeriesMomentumStrategy(
+ lookback_period=10, skip_period=0, signal_scaling="binary"
+ )
+ strategy.symbol_calendars = _CALENDARS
+
+ params = strategy.parameters()
+
+ assert "symbol_calendars" not in params
+ assert strategy.symbol_calendars == _CALENDARS
diff --git a/tests/unit/test_pair_diagnostics.py b/tests/unit/test_pair_diagnostics.py
new file mode 100644
index 0000000..f1defdf
--- /dev/null
+++ b/tests/unit/test_pair_diagnostics.py
@@ -0,0 +1,249 @@
+"""Tests for the shared pairs-trading diagnostics module."""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from quantlab.data.base import price_matrix
+from quantlab.features.pairs_diagnostics import compute_pair_diagnostics, spread
+
+
+def test_spread_matches_manual_computation() -> None:
+ index = pd.date_range("2020-01-01", periods=5, freq="D")
+ a = pd.Series([10.0, 11.0, 12.0, 13.0, 14.0], index=index)
+ b = pd.Series([5.0, 5.5, 6.0, 6.5, 7.0], index=index)
+ intercept = pd.Series(1.0, index=index)
+ beta = pd.Series(2.0, index=index)
+ result = spread(a, b, intercept, beta)
+ expected = a - 1.0 - 2.0 * b
+ pd.testing.assert_series_equal(result, expected, check_names=False)
+
+
+def test_spread_rejects_mismatched_axes() -> None:
+ a = pd.Series([1.0, 2.0], index=[0, 1])
+ b = pd.Series([1.0, 2.0], index=[0, 2])
+ intercept = pd.Series([0.0, 0.0], index=[0, 1])
+ beta = pd.Series([1.0, 1.0], index=[0, 1])
+ with pytest.raises(ValueError, match="index"):
+ spread(a, b, intercept, beta)
+
+
+def test_compute_pair_diagnostics_on_a_cointegrated_pair(
+ two_symbol_panel: pd.DataFrame,
+) -> None:
+ """``two_symbol_panel`` builds EWB = 0.8 * EWA + 20 + small noise -- a
+ strongly cointegrated, mean-reverting-spread pair by construction."""
+ prices = price_matrix(two_symbol_panel)
+ diagnostics = compute_pair_diagnostics(
+ prices,
+ "EWA",
+ "EWB",
+ formation_window=100,
+ indicator_window=20,
+ dynamic_hedge_ratio=True,
+ )
+ assert diagnostics.symbol_a == "EWA"
+ assert diagnostics.symbol_b == "EWB"
+ assert -1.0 <= diagnostics.correlation <= 1.0
+ assert diagnostics.correlation > 0.5
+ assert diagnostics.hedge_ratio.notna().sum() > 0
+ assert diagnostics.spread.notna().sum() > 0
+ assert diagnostics.indicator == "zscore"
+ assert diagnostics.spread_indicator.notna().sum() > 0
+ assert diagnostics.adf_result is not None
+ assert diagnostics.adf_result.reject_null is True
+ assert diagnostics.cointegration_result is not None
+ assert diagnostics.cointegration_result.reject_null is True
+ assert np.isfinite(diagnostics.half_life)
+ assert diagnostics.half_life > 0
+ assert np.isfinite(diagnostics.hedge_ratio_stability)
+ assert diagnostics.rolling_adf_pvalue.notna().sum() > 0
+
+
+def test_compute_pair_diagnostics_rejects_missing_symbol(
+ two_symbol_panel: pd.DataFrame,
+) -> None:
+ prices = price_matrix(two_symbol_panel)
+ with pytest.raises(ValueError, match="EWZ"):
+ compute_pair_diagnostics(
+ prices,
+ "EWA",
+ "EWZ",
+ formation_window=100,
+ indicator_window=20,
+ dynamic_hedge_ratio=True,
+ )
+
+
+def test_compute_pair_diagnostics_rejects_non_dataframe() -> None:
+ with pytest.raises(TypeError, match="DataFrame"):
+ compute_pair_diagnostics(
+ pd.Series([1.0, 2.0]), # type: ignore[arg-type]
+ "EWA",
+ "EWB",
+ formation_window=100,
+ indicator_window=20,
+ dynamic_hedge_ratio=True,
+ )
+
+
+def test_compute_pair_diagnostics_static_hedge_ratio_is_stable_by_construction(
+ two_symbol_panel: pd.DataFrame,
+) -> None:
+ """A static hedge ratio never varies after formation, so its own
+ stability diagnostic must read as exactly zero dispersion."""
+ prices = price_matrix(two_symbol_panel)
+ diagnostics = compute_pair_diagnostics(
+ prices,
+ "EWA",
+ "EWB",
+ formation_window=100,
+ indicator_window=20,
+ dynamic_hedge_ratio=False,
+ )
+ assert diagnostics.hedge_ratio_stability == pytest.approx(0.0)
+
+
+@pytest.mark.parametrize("dynamic_hedge_ratio", [True, False])
+def test_rolling_adf_pvalue_reproduces_the_live_strategys_own_gate(
+ two_symbol_panel: pd.DataFrame, dynamic_hedge_ratio: bool
+) -> None:
+ """``PairDiagnostics.rolling_adf_pvalue``, thresholded, must equal
+ ``PairsTradingStrategy._stationarity_gate`` bar for bar -- the whole
+ point of both calling the same shared
+ ``periodic_stationarity_pvalues`` function (see its docstring). A
+ diagnostic that silently used a DIFFERENT computation (e.g. slicing an
+ already-dynamically-refit spread series instead of fitting one fresh
+ regression per checkpoint window) would show a pair as stationarity-
+ gated on dates where a real backtest of it was not, or vice versa.
+
+ ``two_symbol_panel`` is single-calendar, and ``_stationarity_gate`` is
+ called directly here with the SAME ``prices["EWA"]``/``prices["EWB"]``
+ series ``compute_pair_diagnostics`` itself uses -- this only proves
+ "same function, same input -> same output". The live strategy's own
+ entry gate instead reaches ``_stationarity_gate`` via
+ ``_native_pair_context``, which feeds it each leg sliced to the
+ intersection of both legs' native session dates: for a MIXED-calendar
+ pair that input differs from what ``compute_pair_diagnostics`` uses,
+ and the two genuinely diverge -- see
+ ``test_rolling_adf_pvalue_diverges_from_the_live_gate_under_mixed_
+ calendars`` below.
+ """
+ from quantlab.strategies.pairs_trading import PairsTradingStrategy
+
+ prices = price_matrix(two_symbol_panel)
+ formation_window = 100
+ indicator_window = 20
+ adf_pvalue_threshold = 0.10
+
+ diagnostics = compute_pair_diagnostics(
+ prices,
+ "EWA",
+ "EWB",
+ formation_window=formation_window,
+ indicator_window=indicator_window,
+ dynamic_hedge_ratio=dynamic_hedge_ratio,
+ )
+ diagnostics_gate = (
+ diagnostics.rolling_adf_pvalue.notna()
+ & (diagnostics.rolling_adf_pvalue <= adf_pvalue_threshold)
+ ).to_numpy()
+
+ strategy = PairsTradingStrategy(
+ symbol_a="EWA",
+ symbol_b="EWB",
+ formation_window=formation_window,
+ indicator_window=indicator_window,
+ dynamic_hedge_ratio=dynamic_hedge_ratio,
+ adf_pvalue_threshold=adf_pvalue_threshold,
+ )
+ strategy_gate = strategy._stationarity_gate(prices["EWA"], prices["EWB"])
+
+ np.testing.assert_array_equal(diagnostics_gate, strategy_gate)
+ # Not a vacuous comparison of two all-False arrays.
+ assert strategy_gate.any()
+
+
+def test_rolling_adf_pvalue_diverges_from_the_live_gate_under_mixed_calendars() -> None:
+ """Regression test for a documentation bug: earlier docstrings claimed
+ ``rolling_adf_pvalue`` was "the one exception" among this module's
+ diagnostics that always reproduces the live entry gate exactly,
+ because it calls the exact same ``periodic_stationarity_pvalues``
+ FUNCTION. Calling the same function is not the same as reproducing
+ the same RESULT: under a mixed-calendar universe, the live gate (via
+ ``PairsTradingStrategy._native_pair_context``) feeds that function
+ each leg sliced to the intersection of both legs' own native session
+ dates, while ``compute_pair_diagnostics`` feeds it the full combined,
+ closure-padded timeline. Proven directly here by calling
+ ``periodic_stationarity_pvalues`` both ways on the same mixed-calendar
+ pair and showing the results genuinely differ on native session dates
+ (not just on the padding itself, where they could trivially differ)."""
+ from quantlab.data.calendar import is_session_day
+ from quantlab.strategies.pairs_trading import periodic_stationarity_pvalues
+
+ dates = pd.date_range("2019-01-01", periods=200, freq="D") # includes weekends
+ is_weekend = dates.weekday >= 5
+ rng = np.random.default_rng(5)
+ common = np.empty(len(dates))
+ common[~is_weekend] = 100.0 + np.cumsum(
+ rng.normal(0.05, 1.0, size=int((~is_weekend).sum()))
+ )
+ last = np.nan
+ for i in range(len(dates)):
+ if is_weekend[i]:
+ common[i] = last
+ else:
+ last = common[i]
+ noise = rng.normal(0.0, 0.5, size=len(dates))
+ a = pd.Series(common, index=dates) # AAA: XNYS, flat-filled on weekends
+ b = pd.Series(0.8 * common + 20.0 + noise, index=dates) # BTC: 24/7, tracks A
+
+ both_open = is_session_day("XNYS", dates)
+ native_index = dates[both_open]
+
+ combined_pvalues = periodic_stationarity_pvalues(
+ a, b, formation_window=60, stride=10, dynamic_hedge_ratio=True
+ )
+ native_pvalues = periodic_stationarity_pvalues(
+ a.loc[native_index],
+ b.loc[native_index],
+ formation_window=60,
+ stride=10,
+ dynamic_hedge_ratio=True,
+ )
+
+ # Compare on native session dates only -- a mismatch there proves the
+ # divergence isn't just an artefact of the padding/reindex itself.
+ combined_on_native = combined_pvalues.loc[native_index]
+ assert not combined_on_native.reset_index(drop=True).equals(
+ native_pvalues.reset_index(drop=True)
+ )
+
+
+@pytest.mark.parametrize("indicator", ["zscore", "rsi", "percentile"])
+def test_compute_pair_diagnostics_indicator_matches_the_strategys_own_series(
+ two_symbol_panel: pd.DataFrame, indicator: str
+) -> None:
+ """``spread_indicator`` must match the SAME series
+ ``PairsTradingStrategy._centered_spread_indicator`` computes for a
+ given ``indicator`` -- not always the zscore, regardless of which
+ indicator was requested."""
+ from quantlab.strategies.pairs_trading import _centered_spread_indicator
+
+ prices = price_matrix(two_symbol_panel)
+ diagnostics = compute_pair_diagnostics(
+ prices,
+ "EWA",
+ "EWB",
+ formation_window=100,
+ indicator_window=20,
+ dynamic_hedge_ratio=True,
+ indicator=indicator,
+ )
+ assert diagnostics.indicator == indicator
+ expected = _centered_spread_indicator(diagnostics.spread, indicator, 20)
+ pd.testing.assert_series_equal(
+ diagnostics.spread_indicator, expected, check_names=False
+ )
diff --git a/tests/unit/test_portfolio.py b/tests/unit/test_portfolio.py
index 5f50806..35f5f30 100644
--- a/tests/unit/test_portfolio.py
+++ b/tests/unit/test_portfolio.py
@@ -2,22 +2,27 @@
from __future__ import annotations
+from typing import cast
+
import numpy as np
import pandas as pd
import pytest
-from quantlab.config import RebalanceFrequency
+from quantlab.config import PortfolioConfig, RebalanceFrequency
from quantlab.portfolio.allocator import (
EqualWeightAllocator,
InverseVolatilityAllocator,
SignalProportionalAllocator,
build_allocator,
)
-from quantlab.portfolio.constraints import ConstraintSet
+from quantlab.portfolio.constraints import ConstraintSet, ConstraintTouch, _mark_touched
from quantlab.portfolio.position_sizing import gross_exposure
from quantlab.portfolio.rebalancing import (
+ _rebalance_tradability_aware,
apply_rebalancing,
+ cap_turnover,
compute_turnover,
+ rebalance_and_cap_turnover,
rebalance_dates,
)
@@ -101,6 +106,232 @@ def test_constraint_gross_cap() -> None:
assert gross_exposure(out).iloc[0] == pytest.approx(1.0)
+# --------------------------------------------------------------------------- #
+# Constraint provenance (apply_with_provenance)
+# --------------------------------------------------------------------------- #
+def test_apply_with_provenance_matches_apply_exactly() -> None:
+ """apply() and apply_with_provenance()[0] must be byte-identical --
+ provenance tracking is pure instrumentation, never a second, possibly
+ diverging computation."""
+ idx = pd.date_range("2020-01-01", periods=5)
+ rng = np.random.default_rng(0)
+ weights = pd.DataFrame(
+ rng.normal(scale=0.5, size=(5, 4)), index=idx, columns=["A", "B", "C", "D"]
+ )
+ constraints = ConstraintSet(
+ maximum_weight=0.3,
+ minimum_weight=0.02,
+ maximum_gross_exposure=1.0,
+ maximum_leverage=1.0,
+ maximum_net_exposure=0.5,
+ maximum_positions=3,
+ long_only=False,
+ )
+
+ direct = constraints.apply(weights)
+ via_provenance, _ = constraints.apply_with_provenance(weights)
+
+ pd.testing.assert_frame_equal(direct, via_provenance)
+
+
+def test_apply_with_provenance_marks_only_the_constraint_that_fired() -> None:
+ idx = pd.date_range("2020-01-01", periods=1)
+ weights = pd.DataFrame({"A": [0.6], "B": [0.4]}, index=idx)
+
+ _, touches = ConstraintSet(maximum_weight=0.3).apply_with_provenance(weights)
+
+ assert set(touches) == {"maximum_weight"}
+ assert bool(touches["maximum_weight"].touched.loc[idx[0], "A"])
+ assert touches["maximum_weight"].before.loc[idx[0], "A"] == pytest.approx(0.6)
+ assert touches["maximum_weight"].after.loc[idx[0], "A"] == pytest.approx(0.3)
+
+
+def test_apply_with_provenance_untriggered_constraint_has_an_all_false_mask() -> None:
+ """A configured constraint that never actually binds must still appear
+ in the provenance dict (so callers can tell "configured but inert"
+ from "not configured"), with an all-False touched mask."""
+ idx = pd.date_range("2020-01-01", periods=1)
+ weights = pd.DataFrame({"A": [0.2], "B": [0.2]}, index=idx) # already <= 0.3
+
+ _, touches = ConstraintSet(maximum_weight=0.3).apply_with_provenance(weights)
+
+ assert "maximum_weight" in touches
+ assert not touches["maximum_weight"].touched.to_numpy().any()
+
+
+def test_apply_with_provenance_marks_every_constraint_that_binds_on_the_same_cell() -> (
+ None
+):
+ """maximum_weight trims first, then maximum_gross_exposure rescales the
+ whole row further -- both must be recorded as touching the cell, not
+ just the last one to run (the user's explicit "multiple causes"
+ requirement)."""
+ idx = pd.date_range("2020-01-01", periods=1)
+ weights = pd.DataFrame({"A": [0.9], "B": [0.9]}, index=idx) # gross 1.8
+
+ _, touches = ConstraintSet(
+ maximum_weight=0.5, maximum_gross_exposure=0.6
+ ).apply_with_provenance(weights)
+
+ assert set(touches) == {"maximum_weight", "maximum_gross_exposure"}
+ assert bool(touches["maximum_weight"].touched.loc[idx[0], "A"])
+ assert bool(touches["maximum_gross_exposure"].touched.loc[idx[0], "A"])
+
+
+def test_apply_with_provenance_maximum_weight_direct_vs_redistribution() -> None:
+ """A cell directly clipped by maximum_weight (A) vs a cell only
+ redimensioned by the water-filling redistribution that follows (B,
+ which never itself exceeded the cap) must be distinguishable via
+ `direct` -- confirmed against `renormalize_within_cap`'s own two-step
+ clip-then-water-fill behaviour (position_sizing.py)."""
+ idx = pd.date_range("2020-01-01", periods=1)
+ weights = pd.DataFrame({"A": [0.6], "B": [0.1]}, index=idx)
+
+ _, touches = ConstraintSet(maximum_weight=0.3).apply_with_provenance(weights)
+ touch = touches["maximum_weight"]
+
+ assert bool(touch.direct.loc[idx[0], "A"])
+ assert bool(touch.touched.loc[idx[0], "B"])
+ assert not bool(touch.direct.loc[idx[0], "B"])
+ # B was genuinely redistributed upward (never itself over the cap).
+ assert cast(float, touch.after.at[idx[0], "B"]) > cast(
+ float, touch.before.at[idx[0], "B"]
+ )
+
+
+def test_apply_with_provenance_minimum_weight_direct_vs_redistribution() -> None:
+ """A is dropped as dust (direct); B, a genuine survivor well above the
+ minimum, is only redimensioned by the redistribution back to the
+ pre-drop gross target."""
+ idx = pd.date_range("2020-01-01", periods=1)
+ weights = pd.DataFrame({"A": [0.05], "B": [0.3]}, index=idx)
+
+ _, touches = ConstraintSet(minimum_weight=0.1).apply_with_provenance(weights)
+ touch = touches["minimum_weight"]
+
+ assert bool(touch.direct.loc[idx[0], "A"])
+ assert touch.after.loc[idx[0], "A"] == pytest.approx(0.0)
+ assert bool(touch.touched.loc[idx[0], "B"])
+ assert not bool(touch.direct.loc[idx[0], "B"])
+ assert cast(float, touch.after.at[idx[0], "B"]) > cast(
+ float, touch.before.at[idx[0], "B"]
+ )
+
+
+def test_apply_with_provenance_maximum_positions_direct_vs_redistribution() -> None:
+ """C (the smallest) is directly dropped by the cardinality cut; A/B
+ (the survivors) are only redimensioned by the redistribution back to
+ the pre-drop gross target."""
+ idx = pd.date_range("2020-01-01", periods=1)
+ weights = pd.DataFrame({"A": [0.5], "B": [0.3], "C": [0.1]}, index=idx)
+
+ _, touches = ConstraintSet(maximum_positions=2).apply_with_provenance(weights)
+ touch = touches["maximum_positions"]
+
+ assert bool(touch.direct.loc[idx[0], "C"])
+ assert touch.after.loc[idx[0], "C"] == pytest.approx(0.0)
+ for survivor in ("A", "B"):
+ assert bool(touch.touched.loc[idx[0], survivor])
+ assert not bool(touch.direct.loc[idx[0], survivor])
+ after_value = cast(float, touch.after.at[idx[0], survivor])
+ before_value = cast(float, touch.before.at[idx[0], survivor])
+ assert after_value > before_value
+
+
+def test_apply_with_provenance_direct_equals_touched_for_uniform_rescales() -> None:
+ """maximum_gross_exposure/maximum_leverage/maximum_net_exposure/
+ long_only are uniform whole-row rescales with no cell-level direct-vs-
+ indirect distinction -- `direct` must always equal `touched`."""
+ idx = pd.date_range("2020-01-01", periods=1)
+ weights = pd.DataFrame({"A": [0.9], "B": [0.9]}, index=idx)
+
+ _, touches = ConstraintSet(maximum_gross_exposure=1.0).apply_with_provenance(
+ weights
+ )
+ touch = touches["maximum_gross_exposure"]
+
+ pd.testing.assert_frame_equal(touch.direct, touch.touched)
+
+
+def test_apply_with_provenance_direct_is_always_a_subset_of_touched() -> None:
+ """Invariant that must hold for every constraint, every run: a cell
+ can never be `direct` without also being `touched`."""
+ idx = pd.date_range("2020-01-01", periods=5)
+ rng = np.random.default_rng(1)
+ weights = pd.DataFrame(
+ rng.normal(scale=0.5, size=(5, 4)), index=idx, columns=["A", "B", "C", "D"]
+ )
+ constraints = ConstraintSet(
+ maximum_weight=0.3,
+ minimum_weight=0.02,
+ maximum_gross_exposure=1.0,
+ maximum_leverage=1.0,
+ maximum_net_exposure=0.5,
+ maximum_positions=3,
+ long_only=False,
+ )
+
+ _, touches = constraints.apply_with_provenance(weights)
+
+ for touch in touches.values():
+ violation = touch.direct & ~touch.touched
+ assert not bool(violation.to_numpy().any())
+
+
+def test_mark_touched_after_only_updates_on_a_pass_that_actually_retouches() -> None:
+ """Point 2/9: a cell whose value changes between two passes of THIS
+ constraint, but NOT because of this constraint's own operation on the
+ intervening pass, must not have that unrelated change attributed to
+ it -- `after` only moves on a pass where `before != after` for THIS
+ call. `before` stays the very first value across every pass."""
+ idx = pd.date_range("2020-01-01", periods=1)
+ touched: dict[str, ConstraintTouch] = {}
+
+ # Pass 1: X changes A from 0.9 to 0.7.
+ _mark_touched(
+ touched,
+ "X",
+ pd.DataFrame({"A": [0.9]}, index=idx),
+ pd.DataFrame({"A": [0.7]}, index=idx),
+ )
+ assert touched["X"].before.loc[idx[0], "A"] == pytest.approx(0.9)
+ assert touched["X"].after.loc[idx[0], "A"] == pytest.approx(0.7)
+
+ # Between passes, an UNRELATED operation moved A to 0.75. Pass 2: X's
+ # own before/after this call are equal (0.75 -> 0.75) -- it did not
+ # retouch A.
+ _mark_touched(
+ touched,
+ "X",
+ pd.DataFrame({"A": [0.75]}, index=idx),
+ pd.DataFrame({"A": [0.75]}, index=idx),
+ )
+ assert touched["X"].after.loc[idx[0], "A"] == pytest.approx(0.7)
+ assert touched["X"].before.loc[idx[0], "A"] == pytest.approx(0.9)
+
+ # Pass 3: X retouches A for real (0.75 -> 0.6).
+ _mark_touched(
+ touched,
+ "X",
+ pd.DataFrame({"A": [0.75]}, index=idx),
+ pd.DataFrame({"A": [0.6]}, index=idx),
+ )
+ assert touched["X"].before.loc[idx[0], "A"] == pytest.approx(0.9)
+ assert touched["X"].after.loc[idx[0], "A"] == pytest.approx(0.6)
+
+
+def test_maximum_weight_direct_predicate_matches_the_real_clip_exactly() -> None:
+ """No epsilon reconstructed for `direct`: at exactly the cap, clip()
+ is a no-op (not direct); a hair above it, clip() DOES change the
+ value (direct) -- boundary values chosen to straddle EPSILON."""
+ cap = 0.3
+ idx = pd.date_range("2020-01-01", periods=1)
+ for offset, expect_direct in ((0.0, False), (1e-13, False), (1e-8, True)):
+ weights = pd.DataFrame({"A": [cap + offset], "B": [0.05]}, index=idx)
+ _, touches = ConstraintSet(maximum_weight=cap).apply_with_provenance(weights)
+ assert bool(touches["maximum_weight"].direct.loc[idx[0], "A"]) is expect_direct
+
+
def test_weights_have_no_nan_or_inf(synthetic_panel: pd.DataFrame) -> None:
idx = synthetic_panel["timestamp"].drop_duplicates().sort_values()
signals = pd.DataFrame(1.0, index=idx, columns=["AAA", "BBB", "CCC"])
@@ -108,6 +339,172 @@ def test_weights_have_no_nan_or_inf(synthetic_panel: pd.DataFrame) -> None:
assert np.isfinite(weights.to_numpy()).all()
+# --------------------------------------------------------------------------- #
+# Turnover-cap provenance (cell-level, episode-scoped)
+# --------------------------------------------------------------------------- #
+def test_cap_turnover_provenance_does_not_change_the_computed_weights() -> None:
+ """Provenance tracking is pure instrumentation -- requesting it must
+ never change the numeric result."""
+ idx = pd.date_range("2020-01-01", periods=4)
+ held = pd.DataFrame({"A": [1.0, 1.0, 0.3, 0.3]}, index=idx)
+ episode_id = pd.DataFrame({"A": [1, 1, 2, 2]}, index=idx)
+
+ without = cap_turnover(held, maximum_turnover=0.4)
+ with_provenance, _ = cap_turnover(
+ held, maximum_turnover=0.4, episode_id=episode_id, return_provenance=True
+ )
+
+ pd.testing.assert_frame_equal(without, with_provenance)
+
+
+def test_cap_turnover_touched_is_cell_level_not_a_row_broadcast() -> None:
+ """Two columns, only one of which actually has a requested delta this
+ row -- the untouched one must never be marked, even though the row as
+ a whole was turnover-limited."""
+ idx = pd.date_range("2020-01-01", periods=1)
+ held = pd.DataFrame({"A": [1.0], "B": [0.0]}, index=idx)
+ episode_id = pd.DataFrame({"A": [1], "B": [0]}, index=idx)
+
+ _, provenance = cap_turnover(
+ held, maximum_turnover=0.4, episode_id=episode_id, return_provenance=True
+ )
+
+ assert bool(provenance.turnover_touched.loc[idx[0], "A"])
+ assert not bool(provenance.turnover_touched.loc[idx[0], "B"])
+
+
+def test_cap_turnover_touched_persists_across_the_same_episode() -> None:
+ """The exact scenario from the redesign's central example: a target
+ held constant across 3 turnover-limited rebalances -- the LAST fill
+ (no longer actively binding) must still carry the real turnover_cap
+ provenance, as a catch-up of the same still-unresolved episode."""
+ idx = pd.date_range("2020-01-01", periods=3)
+ held = pd.DataFrame({"A": [1.0, 1.0, 1.0]}, index=idx)
+ episode_id = pd.DataFrame({"A": [1, 1, 1]}, index=idx)
+
+ output, provenance = cap_turnover(
+ held, maximum_turnover=0.4, episode_id=episode_id, return_provenance=True
+ )
+
+ assert output["A"].tolist() == pytest.approx([0.4, 0.8, 1.0])
+ assert provenance.turnover_actively_limited["A"].tolist() == [True, True, False]
+ assert provenance.turnover_touched["A"].tolist() == [True, True, True]
+
+
+def test_cap_turnover_new_episode_does_not_inherit_debt_even_same_sign() -> None:
+ """Core fix: a NEW upstream decision (different episode_id) must NOT
+ inherit an old episode's turnover debt, even when the new target
+ happens to continue moving in the same direction."""
+ idx = pd.date_range("2020-01-01", periods=2)
+ held = pd.DataFrame({"A": [1.0, 0.6]}, index=idx)
+ episode_id = pd.DataFrame({"A": [1, 2]}, index=idx)
+
+ output, provenance = cap_turnover(
+ held, maximum_turnover=0.4, episode_id=episode_id, return_provenance=True
+ )
+
+ assert output["A"].tolist() == pytest.approx([0.4, 0.6])
+ assert provenance.turnover_actively_limited["A"].tolist() == [True, False]
+ assert provenance.turnover_touched["A"].tolist() == [True, False]
+
+
+def test_cap_turnover_new_episode_abandoning_the_debt() -> None:
+ idx = pd.date_range("2020-01-01", periods=2)
+ held = pd.DataFrame({"A": [1.0, 0.2]}, index=idx)
+ episode_id = pd.DataFrame({"A": [1, 2]}, index=idx)
+
+ output, provenance = cap_turnover(
+ held, maximum_turnover=0.4, episode_id=episode_id, return_provenance=True
+ )
+
+ assert output["A"].tolist() == pytest.approx([0.4, 0.2])
+ assert provenance.turnover_touched["A"].tolist() == [True, False]
+
+
+def test_cap_turnover_debt_identity_comes_from_episode_id_not_target_value() -> None:
+ """The adversarial case demanded explicitly: an IDENTICAL weight path
+ (so the numeric result is unaffected), but the second row is tagged
+ as a DIFFERENT episode than the first even though the target value
+ coincidentally repeats -- the catch-up must not be attributed to the
+ first episode's debt. A same-episode control confirms the debt IS
+ correctly inherited when it's genuinely the same decision."""
+ idx = pd.date_range("2020-01-01", periods=2)
+ held = pd.DataFrame({"A": [0.5, 0.5]}, index=idx)
+
+ same_episode = pd.DataFrame({"A": [1, 1]}, index=idx)
+ same_output, same_provenance = cap_turnover(
+ held, maximum_turnover=0.3, episode_id=same_episode, return_provenance=True
+ )
+ assert same_provenance.turnover_touched["A"].tolist() == [True, True]
+
+ different_episode = pd.DataFrame({"A": [1, 2]}, index=idx)
+ different_output, different_provenance = cap_turnover(
+ held,
+ maximum_turnover=0.3,
+ episode_id=different_episode,
+ return_provenance=True,
+ )
+ assert different_provenance.turnover_touched["A"].tolist() == [True, False]
+
+ # Same weight path in both cases -- provenance never affects the
+ # computed numbers.
+ pd.testing.assert_series_equal(same_output["A"], different_output["A"])
+
+
+def test_rebalance_tradability_aware_tradability_touched_on_reopen_catchup() -> None:
+ """A target changes while the symbol is closed; no trade happens while
+ it stays closed; on reopening, the catch-up trade must carry
+ tradability_touched=True even though `tradable` is True again that
+ exact day (the current-row boolean alone cannot explain a real
+ executed trade -- see _rebalance_tradability_aware's own change==0
+ guarantee while ineligible)."""
+ idx = pd.date_range("2020-01-01", periods=3)
+ target = pd.DataFrame({"A": [0.0, 1.0, 1.0]}, index=idx)
+ tradable = pd.DataFrame({"A": [True, False, True]}, index=idx)
+ episode_id = pd.DataFrame({"A": [0, 1, 1]}, index=idx)
+ portfolio_config = PortfolioConfig(rebalance_frequency=RebalanceFrequency.DAILY)
+
+ output, provenance = _rebalance_tradability_aware(
+ target,
+ portfolio_config,
+ tradable,
+ episode_id=episode_id,
+ return_provenance=True,
+ )
+
+ # No trade at all while closed (row 1): held at the prior value.
+ assert output["A"].tolist() == pytest.approx([0.0, 0.0, 1.0])
+ assert not bool(provenance.tradability_touched.loc[idx[1], "A"])
+ assert bool(provenance.tradability_touched.loc[idx[2], "A"])
+
+
+def test_rebalance_and_cap_turnover_returns_provenance_when_requested() -> None:
+ """The public dispatcher (used by engine.py) must forward episode_id/
+ return_provenance correctly on both the tradable and non-tradable
+ paths, and on the no-turnover-cap-configured path."""
+ idx = pd.date_range("2020-01-01", periods=2)
+ target = pd.DataFrame({"A": [1.0, 1.0]}, index=idx)
+ episode_id = pd.DataFrame({"A": [1, 1]}, index=idx)
+ portfolio_config = PortfolioConfig(
+ rebalance_frequency=RebalanceFrequency.DAILY, maximum_turnover=0.4
+ )
+
+ output, provenance = rebalance_and_cap_turnover(
+ target, portfolio_config, episode_id=episode_id, return_provenance=True
+ )
+ assert output["A"].tolist() == pytest.approx([0.4, 0.8])
+ assert provenance.turnover_touched["A"].tolist() == [True, True]
+
+ # No turnover cap configured at all -- provenance must still come
+ # back, all-False (nothing can ever be turnover-limited).
+ no_cap_config = PortfolioConfig(rebalance_frequency=RebalanceFrequency.DAILY)
+ no_cap_output, no_cap_provenance = rebalance_and_cap_turnover(
+ target, no_cap_config, episode_id=episode_id, return_provenance=True
+ )
+ assert no_cap_output["A"].tolist() == pytest.approx([1.0, 1.0])
+ assert not bool(no_cap_provenance.turnover_touched.to_numpy().any())
+
+
# --------------------------------------------------------------------------- #
# Rebalancing
# --------------------------------------------------------------------------- #
diff --git a/tests/unit/test_portfolio_hardening.py b/tests/unit/test_portfolio_hardening.py
index e494cc7..d1dfa8b 100644
--- a/tests/unit/test_portfolio_hardening.py
+++ b/tests/unit/test_portfolio_hardening.py
@@ -178,7 +178,7 @@ def test_turnover_functions_reject_non_finite_weights() -> None:
def test_cap_turnover_rejects_invalid_direct_budget(bad: object) -> None:
targets = pd.DataFrame({"A": [0.5]})
with pytest.raises(InvalidConfigurationError):
- cap_turnover(targets, maximum_turnover=bad) # type: ignore[arg-type]
+ cap_turnover(targets, maximum_turnover=bad) # type: ignore[call-overload]
def test_volatility_estimator_excludes_missing_observations() -> None:
diff --git a/tests/unit/test_regression_data.py b/tests/unit/test_regression_data.py
index c018cd7..7ea9b2d 100644
--- a/tests/unit/test_regression_data.py
+++ b/tests/unit/test_regression_data.py
@@ -1409,14 +1409,19 @@ def test_generator_hash_is_sensitive_to_cli_edits_but_not_dashboard_edits(
assert engine._generator_hash() != original
-def test_robustness_placeholder_does_not_overclaim_cli_coverage() -> None:
+def test_robustness_placeholder_points_to_the_actual_cli_commands() -> None:
+ """Every technique named here now has a real `quantlab ` CLI
+ command (added alongside YAML-configurable robustness settings) --
+ the placeholder must point users at those, not only the Python API."""
from quantlab.reporting.html_report import _render_robustness
html = _render_robustness(None)
assert "quantlab walk-forward" in html
- assert "run_parameter_sensitivity" in html
- assert "bootstrap_returns" in html
- assert "monte_carlo_permutation" in html
+ assert "quantlab stress-test" in html
+ assert "quantlab sensitivity" in html
+ assert "quantlab bootstrap" in html
+ assert "quantlab permutation-test" in html
+ assert "quantlab robustness" in html
def test_cache_covers_tolerates_a_weekend_end_date(tmp_path: Path) -> None:
@@ -4126,26 +4131,51 @@ def test_bar_bucket_end_distinguishes_xnys_friday_close_from_24_7_monday() -> No
def test_drop_still_open_bars_uses_the_right_calendar(
- tmp_path: Path,
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
+ """Deterministic in wall-clock time: real ``_utc_now()`` briefly disagreed
+ with itself near a UTC/exchange-local day boundary (the equity bar was
+ already safe 12h past its close, but the 24/7 bar -- whose bucket runs to
+ the following UTC midnight -- was not yet 12h past its own close), making
+ this flaky once per day. Pinned via monkeypatch instead."""
+ import quantlab.data.storage as storage_module
from quantlab.data.calendar import (
daily_equity_bucket_settlement,
last_trading_day_on_or_before,
)
- from quantlab.data.storage import ParquetStorage, _drop_still_open_bars
+ from quantlab.data.storage import (
+ ParquetStorage,
+ _drop_still_open_bars,
+ _posting_lag_for,
+ )
- now = pd.Timestamp.now(tz="UTC").tz_localize(None)
- today = pd.Timestamp(year=now.year, month=now.month, day=now.day)
+ # A fixed, known-closed XNYS session -- no dependence on wall-clock time.
closed_session = last_trading_day_on_or_before(
- today - pd.Timedelta(days=1), calendar="XNYS"
+ pd.Timestamp("2024-01-10"), calendar="XNYS"
)
data = pd.DataFrame({"timestamp": [closed_session], "symbol": ["AAPL"]})
- assert len(_drop_still_open_bars(data, "1d", calendar="XNYS")) == 1
- assert len(_drop_still_open_bars(data, "1d", calendar="24/7")) == 1
equity_close = daily_equity_bucket_settlement(closed_session)
flat_close = closed_session + pd.Timedelta(days=1)
assert equity_close < flat_close
+ posting_lag = _posting_lag_for("1d")
+ equity_safe_at = equity_close + posting_lag
+ flat_safe_at = flat_close + posting_lag
+ assert equity_safe_at < flat_safe_at
+
+ # Between the two safe-at instants: the equity bar is already safe, the
+ # 24/7 bar (whose bucket, and thus posting-lag clock, starts later) is not.
+ between = equity_safe_at + (flat_safe_at - equity_safe_at) / 2
+ monkeypatch.setattr(storage_module, "_utc_now", lambda: between)
+ assert len(_drop_still_open_bars(data, "1d", calendar="XNYS")) == 1
+ assert len(_drop_still_open_bars(data, "1d", calendar="24/7")) == 0
+
+ # After both instants: both calendars now consider the bar safe.
+ monkeypatch.setattr(
+ storage_module, "_utc_now", lambda: flat_safe_at + pd.Timedelta(minutes=1)
+ )
+ assert len(_drop_still_open_bars(data, "1d", calendar="XNYS")) == 1
+ assert len(_drop_still_open_bars(data, "1d", calendar="24/7")) == 1
storage = ParquetStorage(cache_dir=tmp_path / "cache", metadata_dir=tmp_path / "md")
equity_data = pd.DataFrame(
diff --git a/tests/unit/test_regression_execution.py b/tests/unit/test_regression_execution.py
index 806d3b4..8d9cdc3 100644
--- a/tests/unit/test_regression_execution.py
+++ b/tests/unit/test_regression_execution.py
@@ -451,7 +451,7 @@ def test_cap_turnover_rejects_invalid_public_api_parameters() -> None:
for name in ("maximum_weight", "maximum_gross_exposure", "maximum_net_exposure"):
for bad in (-0.1, float("nan")):
with pytest.raises(InvalidConfigurationError):
- cap_turnover(targets, maximum_turnover=0.5, **{name: bad}) # type: ignore[arg-type]
+ cap_turnover(targets, maximum_turnover=0.5, **{name: bad}) # type: ignore[call-overload]
# Sanity: ordinary usage remains unaffected.
out = cap_turnover(targets, maximum_turnover=0.3)
assert out.to_numpy().tolist() == [[0.3], [0.5]]
@@ -943,6 +943,16 @@ def test_turnover_cap_completes_a_full_rotation_between_disjoint_sets() -> None:
def test_engine_turnover_cap_never_exceeds_the_configured_budget() -> None:
+ """`maximum_turnover` bounds the REAL trade size (`result.turnover`,
+ built from `apply_weight_drift`'s own `trade_changes` -- exactly zero
+ on a pure-drift row, the real size on an anchor or a landed
+ correction), not a raw row-to-row diff of `result.positions`. With
+ `model_weight_drift` at its default (`True`), `result.positions`
+ itself keeps moving every day from organic price drift independently
+ of the turnover cap -- that is the whole point of the feature, not a
+ violation of this cap, which only throttles genuine trades (see
+ docs/backtesting.md's Rebalancing & turnover / Weight drift
+ sections)."""
from quantlab.backtesting.runner import run_backtest_from_config
frames = [
@@ -991,10 +1001,8 @@ def test_engine_turnover_cap_never_exceeds_the_configured_budget() -> None:
assert result.target_weights is not None
target_nonzero = (result.target_weights.abs() > 1e-9).sum(axis=1)
assert target_nonzero.max() <= 2
- realised_turnover = (
- (result.positions - result.positions.shift(1).fillna(0.0)).abs().sum(axis=1)
- )
- assert realised_turnover.max() <= 0.1 + 1e-9
+ assert result.turnover is not None
+ assert result.turnover.max() <= 0.1 + 1e-9
def test_infeasible_position_weight_combo_warns(caplog: Any) -> None:
@@ -1287,7 +1295,15 @@ def test_cap_turnover_preserves_float_precision_for_integer_input() -> None:
def test_engine_only_trades_cap_turnover_on_rebalance_dates() -> None:
"""End-to-end: a monthly-rebalanced, turnover-capped backtest must not
- trade on non-rebalance dates."""
+ trade on non-rebalance dates.
+
+ `model_weight_drift` is explicitly disabled: this test is specifically
+ about the turnover-cap/rebalance-schedule mechanism producing a
+ constant-weight step function between rebalances -- with drift enabled
+ (the default), `result.positions` genuinely changes every day from
+ organic price movement, which is the whole point of that feature, not
+ a violation of this one's own scope.
+ """
from quantlab.backtesting.runner import run_backtest_from_config
data, cfg = _rf_test_setup()
@@ -1297,6 +1313,7 @@ def test_engine_only_trades_cap_turnover_on_rebalance_dates() -> None:
update={
"maximum_turnover": 0.1,
"rebalance_frequency": "monthly",
+ "model_weight_drift": False,
}
)
}
diff --git a/tests/unit/test_regression_reporting.py b/tests/unit/test_regression_reporting.py
index 7cbabc0..ffeb3df 100644
--- a/tests/unit/test_regression_reporting.py
+++ b/tests/unit/test_regression_reporting.py
@@ -592,19 +592,100 @@ def test_report_fallback_discovers_a_bundled_yml_config(
lambda loaded_data, loaded_cfg, *, data_quality_report: fake_result,
)
- def fake_save(result: object, exp_dir: Path) -> Path:
+ def fake_save(
+ result: object, exp_dir: Path, *, robustness_extra: object = None
+ ) -> Path:
saved["result"] = result
saved["exp_dir"] = exp_dir
return exp_dir
monkeypatch.setattr(result_module, "save_with_walk_forward_reuse", fake_save)
- cli_module.report(experiment=experiment)
+ cli_module.report(experiment=experiment, config=None, shipped_config=None)
assert saved["result"] is fake_result
assert saved["exp_dir"] == (reports / experiment).resolve()
+def test_report_dash_config_finds_a_custom_output_directory(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """`--experiment` can only ever discover a config saved under
+ reports/generated/ (or a bundled config of the same name) -- a config
+ saved with its own `output.directory` elsewhere (never bundled) is
+ unreachable that way. `--config PATH`, pointing directly at the saved
+ config file, must still find and regenerate it."""
+ from types import SimpleNamespace
+
+ import quantlab.backtesting.result as result_module
+ import quantlab.backtesting.runner as runner_module
+ import quantlab.cli as cli_module
+ import quantlab.data.loader as loader_module
+
+ data, cfg = _holdout_config()
+ custom_output_dir = tmp_path / "somewhere" / "custom"
+ cfg = cfg.revalidated_copy(
+ update={
+ "output": cfg.output.revalidated_copy(
+ update={"directory": str(custom_output_dir)}
+ )
+ }
+ )
+ config_path = tmp_path / "not_bundled_config.yaml"
+ cfg.to_yaml(config_path)
+ saved: dict[str, object] = {}
+ fake_result = SimpleNamespace(save_warnings=[])
+
+ monkeypatch.setattr(
+ loader_module.DataLoader,
+ "load",
+ lambda _self, loaded_cfg: (data, SimpleNamespace(warnings=[])),
+ )
+ monkeypatch.setattr(
+ runner_module,
+ "run_backtest_from_config",
+ lambda loaded_data, loaded_cfg, *, data_quality_report: fake_result,
+ )
+
+ def fake_save(
+ result: object, exp_dir: Path, *, robustness_extra: object = None
+ ) -> Path:
+ saved["result"] = result
+ saved["exp_dir"] = exp_dir
+ return exp_dir
+
+ monkeypatch.setattr(result_module, "save_with_walk_forward_reuse", fake_save)
+
+ cli_module.report(experiment=None, config=config_path, shipped_config=None)
+
+ assert saved["result"] is fake_result
+ assert saved["exp_dir"] == custom_output_dir
+
+
+def test_report_experiment_and_config_are_mutually_exclusive() -> None:
+ import typer
+
+ import quantlab.cli as cli_module
+
+ with pytest.raises(typer.Exit) as raised:
+ cli_module.report(
+ experiment="some_experiment",
+ config=Path("some_config.yaml"),
+ shipped_config=None,
+ )
+ assert raised.value.exit_code == 1
+
+
+def test_report_requires_one_of_experiment_config_or_shipped_config() -> None:
+ import typer
+
+ import quantlab.cli as cli_module
+
+ with pytest.raises(typer.Exit) as raised:
+ cli_module.report(experiment=None, config=None, shipped_config=None)
+ assert raised.value.exit_code == 1
+
+
def test_code_hash_changes_when_quantlab_source_changes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -751,6 +832,119 @@ def test_save_records_figure_failures_instead_of_masking_them(
assert (out / "equity_curve.csv").is_file()
+def test_save_records_trade_log_schema_version_in_metadata(tmp_path: Path) -> None:
+ """The trade log's schema version is not a CSV column (a raw export
+ carries no such metadata) -- it must be recoverable from the bundle's
+ `metadata.json` instead, alongside the other run-level facts."""
+ from quantlab.backtesting.runner import run_backtest_from_config
+ from quantlab.backtesting.trade_log import TRADE_LOG_SCHEMA_VERSION
+
+ data, cfg = _holdout_config()
+ result = run_backtest_from_config(data, cfg)
+ out = result.save(tmp_path / "out")
+
+ on_disk = json.loads((out / "metadata.json").read_text(encoding="utf-8"))
+ assert on_disk["trade_log_schema_version"] == TRADE_LOG_SCHEMA_VERSION == 2
+
+
+def test_save_records_signal_price_type_in_resolved_config(tmp_path: Path) -> None:
+ """resolved_config.signal_price_type must always be present and match
+ whatever the strategy was actually built with -- true whether or not
+ the YAML explicitly set strategy.signal_price_type."""
+ from quantlab.backtesting.runner import run_backtest_from_config
+
+ data, cfg = _holdout_config()
+ result = run_backtest_from_config(data, cfg)
+ out = result.save(tmp_path / "default")
+ on_disk = json.loads((out / "metadata.json").read_text(encoding="utf-8"))
+ assert on_disk["resolved_config"]["signal_price_type"] == "adjusted_close"
+
+ custom_cfg = cfg.revalidated_copy(
+ update={
+ "strategy": cfg.strategy.revalidated_copy(
+ update={"signal_price_type": "close"}
+ )
+ }
+ )
+ custom_result = run_backtest_from_config(data, custom_cfg)
+ custom_out = custom_result.save(tmp_path / "custom")
+ custom_on_disk = json.loads(
+ (custom_out / "metadata.json").read_text(encoding="utf-8")
+ )
+ assert custom_on_disk["resolved_config"]["signal_price_type"] == "close"
+
+
+def test_custom_research_question_and_hypothesis_appear_in_the_html_report() -> None:
+ """config.research_question/.hypothesis, when set, must reach the
+ rendered HTML report verbatim -- not just the standalone research_
+ summary functions in isolation."""
+ from quantlab.backtesting.runner import run_backtest_from_config
+
+ data, cfg = _holdout_config()
+ custom_cfg = cfg.revalidated_copy(
+ update={
+ "research_question": "Does this exact sentinel phrase survive?",
+ "hypothesis": "H1: sentinel survives. H0: it does not.",
+ }
+ )
+ result = run_backtest_from_config(data, custom_cfg)
+ html = result.to_html()
+ assert "Does this exact sentinel phrase survive?" in html
+ assert "H1: sentinel survives. H0: it does not." in html
+
+
+def test_output_directory_config_is_respected_by_save(tmp_path: Path) -> None:
+ """config.output.directory must be used when result.save() is called
+ with no explicit override -- an explicit call-time argument still wins."""
+ from quantlab.backtesting.runner import run_backtest_from_config
+
+ custom_dir = tmp_path / "configured_output"
+ data, cfg = _holdout_config()
+ cfg = cfg.revalidated_copy(update={"output": {"directory": str(custom_dir)}})
+ result = run_backtest_from_config(data, cfg)
+
+ out = result.save()
+ assert out == custom_dir
+ assert (out / "metrics.json").is_file()
+
+ explicit_dir = tmp_path / "explicit_override"
+ out2 = result.save(explicit_dir)
+ assert out2 == explicit_dir
+
+
+def test_output_save_toggles_skip_only_the_presentation_layer(tmp_path: Path) -> None:
+ """save_html_report=False/save_figures=False must skip only the HTML/
+ PNG rendering -- every numeric artefact stays unconditional, so
+ `quantlab report`-style regeneration can still work afterwards."""
+ from quantlab.backtesting.runner import run_backtest_from_config
+
+ data, cfg = _holdout_config()
+ cfg = cfg.revalidated_copy(
+ update={
+ "output": {
+ "directory": str(tmp_path / "out"),
+ "save_html_report": False,
+ "save_figures": False,
+ }
+ }
+ )
+ result = run_backtest_from_config(data, cfg)
+ out = result.save()
+
+ assert not (out / "report.html").exists()
+ assert (out / "figures").is_dir()
+ assert not any((out / "figures").iterdir())
+ assert (out / "trades.csv").is_file()
+ assert (out / "equity_curve.csv").is_file()
+ assert (out / "metrics.json").is_file()
+ assert (out / "metadata.json").is_file()
+
+ # A later, HTML-enabled save (e.g. `quantlab report`'s own regeneration
+ # path) must still be able to render the report from the same bundle.
+ html = result.to_html()
+ assert " None:
@@ -1044,7 +1238,7 @@ def test_report_command_rejects_experiment_path_traversal(
)
with pytest.raises(typer.Exit) as raised:
- cli_module.report(experiment="../../../etc")
+ cli_module.report(experiment="../../../etc", config=None, shipped_config=None)
assert raised.value.exit_code == 1
assert any(
diff --git a/tests/unit/test_regression_risk.py b/tests/unit/test_regression_risk.py
index ba6ee44..ed651be 100644
--- a/tests/unit/test_regression_risk.py
+++ b/tests/unit/test_regression_risk.py
@@ -147,7 +147,7 @@ def test_benchmark_outside_universe_is_loaded_but_not_tradable(
"symbol_a": "EWA",
"symbol_b": "EWC",
"formation_window": 40,
- "zscore_window": 15,
+ "indicator_window": 15,
},
},
"portfolio": {"allocator": "signal_proportional"},
diff --git a/tests/unit/test_regression_strategies.py b/tests/unit/test_regression_strategies.py
index b1617b8..cfc46ec 100644
--- a/tests/unit/test_regression_strategies.py
+++ b/tests/unit/test_regression_strategies.py
@@ -67,11 +67,11 @@ def test_known_strategy_parameters_still_accepted() -> None:
},
"strategy": {
"name": "mean_reversion",
- "parameters": {"lookback_period": 20, "entry_zscore": 2.0},
+ "parameters": {"lookback_period": 20, "entry_threshold": 2.0},
},
}
)
- assert cfg.strategy.parameters == {"lookback_period": 20, "entry_zscore": 2.0}
+ assert cfg.strategy.parameters == {"lookback_period": 20, "entry_threshold": 2.0}
def test_var_keyword_catch_all_does_not_admit_bogus_parameters() -> None:
@@ -194,8 +194,8 @@ def test_time_series_momentum_unknown_signal_scaling_rejected() -> None:
def test_int_accepted_for_float_strategy_parameter() -> None:
- """`entry_zscore: 2` (an int) is semantically identical to `2.0` and
- must not be rejected just because the annotation says `float`."""
+ """`entry_threshold: 2` (an int) is semantically identical to `2.0`
+ and must not be rejected just because the annotation says `float`."""
cfg = ExperimentConfig.from_dict(
{
"experiment_name": "x",
@@ -208,21 +208,29 @@ def test_int_accepted_for_float_strategy_parameter() -> None:
},
"strategy": {
"name": "mean_reversion",
- "parameters": {"entry_zscore": 2, "exit_zscore": 0.5},
+ "parameters": {"entry_threshold": 2, "exit_threshold": 0.5},
},
}
)
- assert cfg.strategy.parameters["entry_zscore"] == 2
+ assert cfg.strategy.parameters["entry_threshold"] == 2
-def test_any_annotated_custom_strategy_parameter_does_not_crash() -> None:
-
+def test_any_annotated_custom_strategy_parameter_does_not_crash(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ import quantlab.strategies.base as strategy_base
from quantlab.strategies.base import (
BaseStrategy,
register_strategy,
validate_strategy_parameters,
)
+ # Registering a strategy mutates the module-level global registry --
+ # isolate it to this test (matching test_strategies_hardening.py's own
+ # pattern) so `any_param_test` never leaks into another test's own
+ # `available_strategies()`/`available_profiles()` check.
+ monkeypatch.setattr(strategy_base, "_REGISTRY", dict(strategy_base._REGISTRY))
+
@register_strategy("any_param_test")
class _AnyParamStrategy(BaseStrategy):
def __init__(self, threshold: Any = None) -> None:
@@ -321,7 +329,7 @@ def test_mean_reversion_stop_below_entry_rejected() -> None:
with pytest.raises(InvalidConfigurationError):
_try_strategy(
"mean_reversion",
- {"entry_zscore": 2.0, "exit_zscore": 0.5, "stop_zscore": 1.0},
+ {"entry_threshold": 2.0, "exit_threshold": 0.5, "stop_threshold": 1.0},
)
@@ -334,8 +342,8 @@ def test_pairs_trading_entry_below_exit_rejected() -> None:
{
"symbol_a": "AAA",
"symbol_b": "BBB",
- "entry_zscore": 0.5,
- "exit_zscore": 2.0,
+ "entry_threshold": 0.5,
+ "exit_threshold": 2.0,
},
)
@@ -347,9 +355,9 @@ def test_pairs_trading_stop_below_entry_rejected() -> None:
{
"symbol_a": "AAA",
"symbol_b": "BBB",
- "entry_zscore": 2.0,
- "exit_zscore": 0.5,
- "stop_zscore": 1.0,
+ "entry_threshold": 2.0,
+ "exit_threshold": 0.5,
+ "stop_threshold": 1.0,
},
)
@@ -362,28 +370,30 @@ def test_pairs_trading_zero_formation_window_rejected() -> None:
)
-def test_pairs_trading_zero_zscore_window_rejected() -> None:
+def test_pairs_trading_zero_indicator_window_rejected() -> None:
with pytest.raises(InvalidConfigurationError):
_try_strategy(
"pairs_trading",
- {"symbol_a": "AAA", "symbol_b": "BBB", "zscore_window": 0},
+ {"symbol_a": "AAA", "symbol_b": "BBB", "indicator_window": 0},
)
-def test_mean_reversion_negative_zscore_rejected() -> None:
+def test_mean_reversion_negative_threshold_rejected() -> None:
with pytest.raises(InvalidConfigurationError):
- _try_strategy("mean_reversion", {"entry_zscore": -1.0, "exit_zscore": -2.0})
+ _try_strategy(
+ "mean_reversion", {"entry_threshold": -1.0, "exit_threshold": -2.0}
+ )
-def test_pairs_trading_negative_zscore_rejected() -> None:
+def test_pairs_trading_negative_threshold_rejected() -> None:
with pytest.raises(InvalidConfigurationError):
_try_strategy(
"pairs_trading",
{
"symbol_a": "AAA",
"symbol_b": "BBB",
- "entry_zscore": -1.0,
- "exit_zscore": -2.0,
+ "entry_threshold": -1.0,
+ "exit_threshold": -2.0,
},
)
@@ -420,11 +430,34 @@ def test_time_series_momentum_continuous_scaling_small_lookback_works() -> None:
assert signals.shape == (100, 1)
-def test_cross_sectional_momentum_non_binary_signal_scaling_rejected() -> None:
+def test_cross_sectional_momentum_continuous_signal_scaling_accepted() -> None:
+ config = _try_strategy(
+ "cross_sectional_momentum",
+ {"signal_scaling": "continuous"},
+ portfolio={"allocator": "signal_proportional"},
+ )
+ assert config.strategy.parameters["signal_scaling"] == "continuous"
+
+
+def test_cross_sectional_momentum_continuous_scaling_rejects_equal_weight() -> None:
+ """Mirrors time_series_momentum's identical guard: equal_weight discards
+ signal magnitude (np.sign only), silently degenerating continuous
+ scaling to binary -- must be rejected at config load, not silently
+ accepted."""
with pytest.raises(InvalidConfigurationError):
_try_strategy("cross_sectional_momentum", {"signal_scaling": "continuous"})
+def test_cross_sectional_momentum_volatility_adjusted_signal_scaling_rejected() -> None:
+ """`volatility_adjusted` is a real signal_scaling value, but only for
+ `time_series_momentum` -- cross_sectional_momentum only accepts
+ 'binary'/'continuous'."""
+ with pytest.raises(InvalidConfigurationError):
+ _try_strategy(
+ "cross_sectional_momentum", {"signal_scaling": "volatility_adjusted"}
+ )
+
+
def test_time_series_momentum_continuous_scaling_needs_lookback_ge_2() -> None:
with pytest.raises(InvalidConfigurationError):
_try_strategy(
@@ -450,19 +483,24 @@ def test_cross_sectional_momentum_zero_top_fraction_accepted_for_short_only() ->
assert config.strategy.parameters["top_fraction"] == 0.0
-def test_mean_reversion_zero_entry_zscore_rejected_with_clear_message() -> None:
- with pytest.raises(InvalidConfigurationError, match=r"entry_zscore must be > 0\.0"):
- _try_strategy("mean_reversion", {"entry_zscore": 0.0, "exit_zscore": -0.5})
+def test_mean_reversion_zero_entry_threshold_rejected_with_clear_message() -> None:
+ with pytest.raises(
+ InvalidConfigurationError, match=r"entry_threshold must be > 0\.0"
+ ):
+ _try_strategy(
+ "mean_reversion", {"entry_threshold": 0.0, "exit_threshold": -0.5}
+ )
-def test_pairs_trading_zero_entry_zscore_rejected_with_clear_message() -> None:
- """See `test_mean_reversion_zero_entry_zscore_rejected_with_clear_message`
- — `pairs_trading` walks the same z-score state machine and must give
- the same direct error naming `entry_zscore`."""
- with pytest.raises(InvalidConfigurationError, match=r"entry_zscore must be > 0\.0"):
+def test_pairs_trading_zero_entry_threshold_rejected_with_clear_message() -> None:
+ """`pairs_trading` walks the same state machine as mean_reversion and
+ must give the same direct error naming `entry_threshold`."""
+ with pytest.raises(
+ InvalidConfigurationError, match=r"entry_threshold must be > 0\.0"
+ ):
_try_strategy(
"pairs_trading",
- {"symbol_a": "AAA", "symbol_b": "BBB", "entry_zscore": 0.0},
+ {"symbol_a": "AAA", "symbol_b": "BBB", "entry_threshold": 0.0},
)
diff --git a/tests/unit/test_regression_validation.py b/tests/unit/test_regression_validation.py
index 0027a3b..42d9a88 100644
--- a/tests/unit/test_regression_validation.py
+++ b/tests/unit/test_regression_validation.py
@@ -321,6 +321,44 @@ def test_walk_forward_oos_result_reports_config_yaml_reflects_everything() -> No
assert "reproducible from config.yaml given the same code" in html
+def test_walk_forward_trade_log_has_no_reason_attribution() -> None:
+ """The stitched out-of-sample trade log has no trigger/adjustment/
+ position_strategy_origin provenance -- not a crash, not a fabricated
+ value, and not the `unknown` safety-net code either (that is reserved
+ for the *active* attribution path failing to identify a real cause,
+ never for an attribution path that was never run). Each fold reruns
+ the pipeline independently with its own warmup/fit; the diagnostic
+ frames a single engine run keeps for attribution do not survive the
+ cut/restitch across folds, so `build_trade_log` is called here without
+ any of the provenance kwargs (see the comment at that call site in
+ `walk_forward.py`), leaving the reason columns `None`/`NaT`
+ everywhere -- a real architectural fact about walk-forward, not a
+ negligence bug."""
+ from quantlab.validation.walk_forward import WalkForwardValidator
+
+ data, cfg = _rf_test_setup()
+ wf = WalkForwardValidator(cfg).run(
+ data, parameter_grid={}, train_window=200, validation_window=50, test_window=50
+ )
+ assert wf.oos_result is not None
+ trades = wf.oos_result.trades
+ assert len(trades) > 0 # sanity: the fixture must actually produce trades to check
+
+ reason_columns = [
+ "trigger_reason_code",
+ "trigger_reason_detail_code",
+ "trigger_reason_details",
+ "adjustment_reason_codes",
+ "adjustment_reason_details",
+ "position_strategy_origin_code",
+ "position_strategy_origin_details",
+ ]
+ for column in reason_columns:
+ assert trades[column].isna().all(), column
+ assert trades["position_strategy_origin_timestamp"].isna().all()
+ assert (trades["trigger_reason_code"] == "unknown").sum() == 0
+
+
def test_holdout_test_ratio_without_validation_ratio_does_not_crash() -> None:
from quantlab.backtesting.runner import run_backtest_from_config
@@ -761,7 +799,177 @@ def test_walk_forward_oos_metrics_use_configured_risk_free_rate() -> None:
assert with_rf["sharpe_ratio"] != pytest.approx(without_rf["sharpe_ratio"])
+def test_walk_forward_step_defaults_to_test_window() -> None:
+ """Omitting ``step`` must reproduce the original non-overlapping-folds
+ behaviour exactly: the same fold count/dates as an explicit
+ ``step=test_window``, and the metadata must record that resolved value."""
+ from quantlab.validation.walk_forward import WalkForwardValidator
+
+ data, cfg = _rf_test_setup()
+ default_run = WalkForwardValidator(cfg).run(
+ data, parameter_grid={}, train_window=150, validation_window=30, test_window=40
+ )
+ explicit_run = WalkForwardValidator(cfg).run(
+ data,
+ parameter_grid={},
+ train_window=150,
+ validation_window=30,
+ test_window=40,
+ step=40,
+ )
+ assert len(default_run.folds) == len(explicit_run.folds)
+ assert default_run.oos_returns.equals(explicit_run.oos_returns)
+ assert default_run.oos_result is not None
+ assert default_run.oos_result.metadata["walk_forward_windows"]["step"] == 40
+
+
+def test_walk_forward_step_smaller_than_test_window_overlaps_folds() -> None:
+ """A step smaller than test_window must produce MORE folds than the
+ default (overlapping test blocks) and must not raise the "test blocks
+ overlap" error a genuine bug would trigger -- the stitched OOS series
+ must still come out with a unique, sorted date index."""
+ from quantlab.config import RebalanceFrequency
+ from quantlab.validation.walk_forward import WalkForwardValidator
+
+ data, cfg = _rf_test_setup()
+ # Daily rebalancing so each fold's first execution date is distinct even
+ # with heavily overlapping test blocks -- with the default monthly
+ # cadence, a step this much smaller than test_window can make two folds'
+ # test blocks share the same first rebalance date, which is a distinct,
+ # separately covered failure mode (see
+ # test_walk_forward_folds_are_rejected_when_execution_dates_collide).
+ cfg = cfg.revalidated_copy(
+ update={
+ "portfolio": cfg.portfolio.revalidated_copy(
+ update={"rebalance_frequency": RebalanceFrequency.DAILY}
+ )
+ }
+ )
+ default_run = WalkForwardValidator(cfg).run(
+ data, parameter_grid={}, train_window=150, validation_window=30, test_window=40
+ )
+ overlapping_run = WalkForwardValidator(cfg).run(
+ data,
+ parameter_grid={},
+ train_window=150,
+ validation_window=30,
+ test_window=40,
+ step=20,
+ )
+ assert len(overlapping_run.folds) > len(default_run.folds)
+ assert overlapping_run.oos_returns.index.is_unique
+ assert overlapping_run.oos_returns.index.is_monotonic_increasing
+
+
+def test_walk_forward_folds_are_rejected_when_execution_dates_collide() -> None:
+ """A step small enough to overlap test blocks, combined with rebalancing
+ too infrequent to distinguish them, can make two folds resolve to the
+ SAME first execution date -- silently attributing zero (or the wrong)
+ observations to one of them via FoldResult.test_returns slicing. This
+ must be rejected outright rather than reported silently."""
+ from quantlab.exceptions import InvalidConfigurationError
+ from quantlab.validation.walk_forward import WalkForwardValidator
+
+ data, cfg = _rf_test_setup()
+ with pytest.raises(InvalidConfigurationError, match="execution date"):
+ WalkForwardValidator(cfg).run(
+ data,
+ parameter_grid={},
+ train_window=150,
+ validation_window=30,
+ test_window=40,
+ step=20,
+ )
+
+
+def test_walk_forward_step_larger_than_test_window_is_rejected() -> None:
+ """A step larger than test_window would skip observations between folds,
+ leaving the stitched OOS curve with gaps that CAGR/annualisation (which
+ assume regularly spaced observations) cannot account for -- rejected
+ outright rather than silently misreporting elapsed time."""
+ from quantlab.exceptions import InvalidConfigurationError
+ from quantlab.validation.walk_forward import WalkForwardValidator
+
+ data, cfg = _rf_test_setup()
+ with pytest.raises(InvalidConfigurationError, match="step"):
+ WalkForwardValidator(cfg).run(
+ data,
+ parameter_grid={},
+ train_window=150,
+ validation_window=30,
+ test_window=40,
+ step=80,
+ )
+
+
+def test_walk_forward_overlapping_step_keeps_the_latest_folds_target() -> None:
+ """On a date shared by two overlapping folds' test blocks, the stitched
+ series must keep the LATER fold's own target -- verified directly on
+ ``_finalize()``'s own dedup step with two fabricated, deliberately
+ conflicting target pieces, sidestepping any dependency on a real
+ strategy/allocator pipeline actually producing distinguishable values
+ for two overlapping folds (not guaranteed for every strategy)."""
+ from quantlab.config import RebalanceFrequency
+ from quantlab.validation.splits import WalkForwardWindow
+ from quantlab.validation.walk_forward import WalkForwardValidator
+
+ data, cfg = _rf_test_setup()
+ # Daily rebalancing so every short, synthetic fold test-window below
+ # has at least one rebalance date -- irrelevant to what this test
+ # actually verifies (the dedup step), just a precondition _finalize()
+ # enforces.
+ cfg = cfg.revalidated_copy(
+ update={
+ "portfolio": cfg.portfolio.revalidated_copy(
+ update={"rebalance_frequency": RebalanceFrequency.DAILY}
+ )
+ }
+ )
+ validator = WalkForwardValidator(cfg)
+ idx = pd.bdate_range("2020-06-01", periods=10)
+ shared_dates = idx[3:7]
+ piece_a = pd.DataFrame(0.1, index=idx[0:7], columns=["AAA", "BBB"])
+ piece_b = pd.DataFrame(0.9, index=idx[3:10], columns=["AAA", "BBB"])
+ fold_a = WalkForwardWindow(fold=0, train=idx[:1], validation=idx[:1], test=idx[0:7])
+ fold_b = WalkForwardWindow(
+ fold=1, train=idx[:1], validation=idx[:1], test=idx[3:10]
+ )
+
+ stitched = validator._finalize(
+ [fold_a, fold_b],
+ [{}, {}],
+ [1.0, 1.0],
+ [piece_a, piece_b],
+ data[data["symbol"].isin({"AAA", "BBB"})],
+ data,
+ cfg,
+ 252,
+ 0.0,
+ 0,
+ {},
+ 7,
+ 1,
+ 7,
+ 4, # step=4 < test_window=7 -> overlap, dedup path
+ True,
+ 0.0,
+ )
+ assert stitched.oos_result is not None
+ assert stitched.oos_result.target_weights is not None
+ for date in shared_dates:
+ target = stitched.oos_result.target_weights.at[date, "AAA"]
+ assert target == pytest.approx(0.9)
+
+
def test_walk_forward_charges_entry_cost_at_the_first_fold_start() -> None:
+ """This test is about the OOS-stitching mechanism, not weight drift: it
+ checks that per-fold reporting doesn't spuriously double-charge an
+ entry cost at a fold boundary the position was actually carried
+ through. `model_weight_drift` is pinned `False` here so a genuine,
+ correct periodic rebalance-driven cost (buy_and_hold's constant
+ target snapping back from organic price drift on a scheduled
+ rebalance -- see test_weight_drift.py) can't be mistaken for a
+ stitching bug."""
from quantlab.validation.walk_forward import WalkForwardValidator
def run_with_commission(commission_bps: float) -> list[pd.Series]:
@@ -774,7 +982,10 @@ def run_with_commission(commission_bps: float) -> list[pd.Series]:
"spread_bps": 0.0,
"slippage_bps": 0.0,
}
- )
+ ),
+ "portfolio": cfg.portfolio.revalidated_copy(
+ update={"model_weight_drift": False}
+ ),
}
)
wf = WalkForwardValidator(cfg).run(
@@ -953,23 +1164,19 @@ def fake_weights_on_test(
monkeypatch.setattr(WalkForwardValidator, "_weights_on_test", fake_weights_on_test)
- import quantlab.validation.walk_forward as wf_mod
-
- captured: dict[str, pd.DataFrame] = {}
- orig_run_accounting = wf_mod.run_accounting
-
- def spy_run_accounting(all_weights: pd.DataFrame, *args: Any, **kwargs: Any) -> Any:
- captured["weights"] = all_weights.copy()
- return orig_run_accounting(all_weights, *args, **kwargs)
-
- monkeypatch.setattr(wf_mod, "run_accounting", spy_run_accounting)
-
- validator.run(
+ result = validator.run(
data, parameter_grid={}, train_window=100, validation_window=20, test_window=5
)
- weights = captured["weights"]
- turnover = (weights - weights.shift(1).fillna(0.0)).abs().sum(axis=1)
+ # With `model_weight_drift` at its default (True), `apply_weight_drift`
+ # (inside `run_accounting`) is the SOLE place `maximum_turnover` is
+ # enforced -- the decision-level `all_weights` handed to accounting is
+ # deliberately left uncapped (see `decision_portfolio_config` in
+ # walk_forward.py's own OOS-stitching call site), so the cap must be
+ # checked on the final realised turnover, not intercepted upstream.
+ assert result.oos_result is not None
+ turnover = result.oos_result.turnover
+ assert turnover is not None
assert turnover.max() == pytest.approx(0.1, abs=1e-6), (
f"maximum_turnover=0.1 must bound every rebalance, including across "
f"a fold boundary; observed max realised turnover {turnover.max()}"
@@ -1456,3 +1663,105 @@ def test_notebook_walk_forward_cell_passes_risk_free_rate() -> None:
assert oos_metrics_cells, "expected a wf.oos_metrics(...) cell in the notebook"
for code in oos_metrics_cells:
assert "wf.oos_metrics(config.periods_per_year, config.risk_free_rate)" in code
+
+
+def test_evaluate_window_never_double_applies_execution_delay_to_rebalance_date() -> (
+ None
+):
+ """`window_weights` (from `_weights_for_window`, via
+ `run_backtest_from_config(..., execution_delay=execution_delay)`) is
+ itself already `execution_delay`-shifted -- it IS
+ `BacktestResult.weights`. Passing `execution_delay` a second time into
+ `_rebalance_date_for_run_accounting` would shift the rebalance-date
+ flag an EXTRA `execution_delay` rows past where `window_weights`
+ itself already sits, misaligning candidate-scoring's schedule-anchor
+ detection from the actual execution model it's supposed to score.
+ `rebalance_date` must be built with `delay=0`, matching the sibling
+ call site in this module's own candidate-scoring loop."""
+ from tests.regression_helpers import _rf_test_setup
+
+ import quantlab.validation.walk_forward as wf_mod
+
+ data, cfg = _rf_test_setup()
+ cfg = cfg.revalidated_copy(
+ update={
+ "portfolio": cfg.portfolio.revalidated_copy(
+ update={"rebalance_frequency": "daily"}
+ )
+ }
+ )
+ lookback_start = pd.Timestamp("2020-01-01")
+ window_start = pd.Timestamp("2020-03-01")
+ window_end = pd.Timestamp("2020-06-01")
+
+ captured: dict[str, pd.DataFrame] = {}
+ orig_run_accounting = wf_mod.run_accounting
+
+ def spy_run_accounting(*args: Any, **kwargs: Any) -> Any:
+ captured["rebalance_date"] = kwargs["rebalance_date"].copy()
+ captured["tradable"] = kwargs["tradable"]
+ return orig_run_accounting(*args, **kwargs)
+
+ wf_mod.run_accounting = spy_run_accounting
+ try:
+ window_weights, _ = wf_mod._weights_and_returns_for_validation(
+ data, cfg, lookback_start, window_start, window_end, execution_delay=2
+ )
+ finally:
+ wf_mod.run_accounting = orig_run_accounting
+
+ expected = wf_mod._rebalance_date_for_run_accounting(
+ window_weights,
+ cfg.portfolio.rebalance_frequency,
+ None,
+ captured["tradable"],
+ 0,
+ )
+ double_delayed = wf_mod._rebalance_date_for_run_accounting(
+ window_weights,
+ cfg.portfolio.rebalance_frequency,
+ None,
+ captured["tradable"],
+ 2,
+ )
+ # Sanity: the two would genuinely differ, so this test is not vacuous.
+ assert not expected.equals(double_delayed)
+ pd.testing.assert_frame_equal(captured["rebalance_date"], expected)
+
+
+def test_rebalance_date_for_run_accounting_never_true_on_a_closed_row() -> None:
+ """Regression test: `compute_executed_weights` is built for *weights*,
+ where a closed row correctly repeats the last tradable row's frozen
+ value. `_rebalance_date_for_run_accounting` reused it to align a
+ boolean flag -- applied to a flag, that same repetition kept it True
+ for every row a column stayed closed right after a landing, which
+ `apply_weight_drift`'s own documented precondition explicitly forbids
+ (it re-anchors ordinary debt to a stale target and fires an
+ unscheduled trade the moment the column reopens). A `daily` schedule
+ flags every row True, so a landing on the last tradable row before a
+ closure is guaranteed, not scenario-dependent."""
+ from quantlab.validation.walk_forward import _rebalance_date_for_run_accounting
+
+ dates = pd.date_range("2024-01-01", periods=8, freq="D")
+ decision_weights = pd.DataFrame({"A": [0.5] * 8, "B": [0.5] * 8}, index=dates)
+ tradable = pd.DataFrame(
+ {
+ "A": [True, True, True, False, False, True, True, True],
+ "B": [True] * 8,
+ },
+ index=dates,
+ )
+
+ result = _rebalance_date_for_run_accounting(
+ decision_weights, "daily", None, tradable, 0
+ )
+
+ violation = result & ~tradable
+ assert not violation.to_numpy().any(), (
+ f"rebalance_date is True on a closed row: {violation[violation.any(axis=1)]}"
+ )
+ # Sanity: the closure itself is genuinely exercised, not vacuously
+ # passing because A never lands True around it.
+ assert result.loc[dates[2], "A"] # lands True right before the closure
+ assert not result.loc[dates[3], "A"]
+ assert not result.loc[dates[4], "A"]
diff --git a/tests/unit/test_reporting_hardening.py b/tests/unit/test_reporting_hardening.py
index 03f6be0..de60c8e 100644
--- a/tests/unit/test_reporting_hardening.py
+++ b/tests/unit/test_reporting_hardening.py
@@ -16,6 +16,8 @@
from quantlab.config import ExperimentConfig
from quantlab.reporting import charts
from quantlab.reporting.html_report import (
+ _format_cell,
+ _format_report_table,
_render_data_quality,
_render_robustness,
)
@@ -24,7 +26,7 @@
data_description,
methodology,
)
-from quantlab.reporting.tables import _fmt, regime_table
+from quantlab.reporting.tables import _fmt, format_bootstrap_summary, regime_table
def _config(
@@ -74,6 +76,37 @@ def test_metric_formatter_handles_numpy_non_finite_values() -> None:
assert _fmt(np.float32(np.nan), "int") == "n/a"
+def test_format_bootstrap_summary_avoids_scientific_notation() -> None:
+ """Each row is formatted by its own statistic's kind (not its column's
+ shared repr) so wildly different magnitudes in the same column --
+ final_value near 1e5 next to cagr near 5e-2 -- never push pandas'
+ default float repr into scientific notation for either one."""
+ summary = pd.DataFrame(
+ {
+ "statistic": ["cagr", "sharpe", "max_drawdown", "final_value"],
+ "median": [0.026741, 0.244565, -0.191660, 105375.654092],
+ "p_lower": [-0.113499, -0.700071, -0.343333, 78738.751320],
+ "p_upper": [0.239792, 1.404149, -0.110733, 153184.828396],
+ "mean": [0.047455, 0.335996, -0.204574, 110866.493909],
+ "std": [0.112626, 0.657864, 0.070470, 24125.201431],
+ }
+ )
+ formatted = format_bootstrap_summary(summary)
+ assert list(formatted["statistic"]) == [
+ "CAGR",
+ "Sharpe",
+ "Max Drawdown",
+ "Final Value",
+ ]
+ rendered = formatted.to_string(index=False).lower()
+ assert "e+" not in rendered
+ assert "e-" not in rendered
+ assert formatted.loc[0, "median"] == "2.67%"
+ assert formatted.loc[1, "median"] == "0.24"
+ assert formatted.loc[2, "median"] == "-19.17%"
+ assert formatted.loc[3, "median"] == "105,375.65"
+
+
def test_regime_table_excludes_undefined_warmup_and_empty_regimes() -> None:
short_index = pd.date_range("2024-01-01", periods=4, freq="D")
config = SimpleNamespace(
@@ -146,6 +179,40 @@ def test_robustness_tables_format_percentage_columns() -> None:
assert "0.42" in rendered
+def test_format_cell_shows_a_plain_bool_instead_of_n_a() -> None:
+ """Regression test: `bool` is a subclass of `int` (a `numbers.Real`),
+ so a plain `True`/`False` value in a GENERIC (non percent/number/
+ integer) column used to be misclassified as "a Real number that isn't
+ finite" and rendered as "n/a" -- e.g. a strategy diagnostics table's
+ `("Long/short", False)` row would show "n/a" instead of `False`,
+ misrepresenting a real, meaningful configuration value as missing
+ data. A bool inside an ACTUAL designated numeric column (where it
+ would be nonsensical, e.g. formatted as "0.00") must still fall back
+ to "n/a" -- that specific exclusion is deliberate, not a bug."""
+ assert _format_cell(False, "Value") is False
+ assert _format_cell(True, "Value") is True
+ # Genuine non-finite reals must still show "n/a", in any column.
+ assert _format_cell(float("nan"), "Value") == "n/a"
+ assert _format_cell(float("inf"), "Value") == "n/a"
+ assert _format_cell(None, "Value") == "n/a"
+ # A bool landing in an ACTUAL numeric-formatted column is still "n/a".
+ assert _format_cell(True, "return") == "n/a"
+ assert _format_cell(False, "trades") == "n/a"
+
+
+def test_diagnostics_table_shows_a_bool_metric_correctly() -> None:
+ """End-to-end: a strategy diagnostics table (e.g.
+ cross_sectional_momentum's `("Long/short", False)` row) must render
+ the real boolean value, not "n/a"."""
+ table = pd.DataFrame(
+ [("Long/short", False), ("Mean rank correlation", 0.123)],
+ columns=["Metric", "Value"],
+ )
+ formatted = _format_report_table(table)
+ assert formatted.loc[0, "Value"] is False
+ assert "n/a" not in formatted["Value"].astype(str).tolist()
+
+
def _sensitivity_frame() -> pd.DataFrame:
return pd.DataFrame(
{
@@ -201,6 +268,85 @@ def test_render_robustness_sensitivity_heatmap_failure_does_not_crash_report() -
assert "60" in rendered # the raw table must still render
+def test_render_strategy_diagnostics_dispatches_by_type() -> None:
+ """`render_html_report` filters any `DiagnosticsSection` value out of the
+ merged robustness dict, dispatched by isinstance -- never by a
+ hard-coded key name -- and renders it via `_render_strategy_diagnostics`
+ into a section separate from "Robustness" (see
+ `test_diagnostics_section_appears_outside_robustness_section` below): a
+ correlation/spread/ADF diagnostic describes whether the STRATEGY's
+ assumptions hold, not whether the RESULT is robust to perturbation."""
+ from quantlab.reporting.html_report import _render_strategy_diagnostics
+ from quantlab.reporting.sections import DiagnosticsSection
+
+ section = DiagnosticsSection(
+ table=pd.DataFrame({"Metric": ["Correlation"], "Value": ["0.83"]}),
+ chart_data_uri="data:image/png;base64,AAAA",
+ note="Pair relationship diagnostics.",
+ )
+ rendered = _render_strategy_diagnostics({"pair_diagnostics": section})
+ assert "Pair Diagnostics" in rendered
+ assert "Pair relationship diagnostics." in rendered
+ assert '' in rendered
+ assert "Correlation" in rendered
+
+
+def test_render_strategy_diagnostics_omits_missing_chart_and_note() -> None:
+ """`note`/`chart_data_uri` are optional -- absent, they render nothing,
+ never an empty `
`/`` tag."""
+ from quantlab.reporting.html_report import _render_strategy_diagnostics
+ from quantlab.reporting.sections import DiagnosticsSection
+
+ section = DiagnosticsSection(table=pd.DataFrame({"Metric": ["x"], "Value": ["1"]}))
+ rendered = _render_strategy_diagnostics({"pair_diagnostics": section})
+ assert "" not in rendered
+
+
+def test_render_robustness_never_receives_a_diagnostics_section() -> None:
+ """A `DiagnosticsSection` value passed to `_render_robustness` directly
+ (bypassing `render_html_report`'s own filtering) renders as a generic
+ unrecognised object rather than specially -- `_render_robustness` no
+ longer knows about this type at all; filtering it out is
+ `render_html_report`'s job, exercised by
+ `test_diagnostics_section_appears_outside_robustness_section` below."""
+ from quantlab.reporting.sections import DiagnosticsSection
+
+ section = DiagnosticsSection(table=pd.DataFrame({"Metric": ["x"], "Value": ["1"]}))
+ rendered = _render_robustness({"pair_diagnostics": section})
+ assert "Correlation" not in rendered
+ assert "
None:
+ """`render_html_report` places a `DiagnosticsSection`-typed robustness
+ entry under its own "Strategy diagnostics" heading, before
+ "Robustness", and never inside the Robustness section's own rendered
+ HTML -- a strategy diagnostic must never be misclassified as
+ robustness evidence."""
+ from quantlab.reporting.html_report import render_html_report
+ from quantlab.reporting.sections import DiagnosticsSection
+
+ result = _result()
+ section = DiagnosticsSection(
+ table=pd.DataFrame({"Metric": ["Correlation"], "Value": ["0.83"]}),
+ note="Pair relationship diagnostics: correlation, hedge ratio.",
+ )
+ document = render_html_report(
+ result,
+ robustness={"pair_diagnostics": section},
+ figures={},
+ )
+ diagnostics_index = document.index("Strategy diagnostics")
+ robustness_index = document.index("
Robustness
")
+ assert diagnostics_index < robustness_index
+ robustness_section = document[
+ robustness_index : document.index("Limitations", robustness_index)
+ ]
+ assert "Correlation" not in robustness_section
+ assert "pair relationship diagnostics" not in robustness_section.lower()
+
+
def test_methodology_describes_volume_slippage_and_constraints() -> None:
result: Any = SimpleNamespace(
config=_config(volume_slippage=True),
@@ -211,7 +357,7 @@ def test_methodology_describes_volume_slippage_and_constraints() -> None:
assert "impact coefficient 0.2000" in text
assert "maximum absolute weight 80.00%" in text
assert "annual volatility target 10.00%" in text
- assert "maximum L1 turnover per rebalance 0.30" in text
+ assert "maximum L1 turnover per period 0.30" in text
def test_data_description_separates_requested_and_observed_periods() -> None:
diff --git a/tests/unit/test_shared_components.py b/tests/unit/test_shared_components.py
new file mode 100644
index 0000000..4b69e95
--- /dev/null
+++ b/tests/unit/test_shared_components.py
@@ -0,0 +1,129 @@
+"""Direct tests for `quantlab.dashboard.explorer.shared_components`."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from quantlab.dashboard.explorer.shared_components import (
+ render_stop_loss_take_profit_illustration,
+)
+
+
+class _FakeColumn:
+ def __init__(self, value: float) -> None:
+ self._value = value
+
+ def slider(self, *args: object, **kwargs: object) -> float:
+ return self._value
+
+
+class _FakeStreamlit:
+ def __init__(
+ self, stop_loss_pct: float = 0.1, take_profit_pct: float = 0.0
+ ) -> None:
+ self._stop_loss_pct = stop_loss_pct
+ self._take_profit_pct = take_profit_pct
+ self.warnings: list[str] = []
+
+ def markdown(self, *args: object, **kwargs: object) -> None:
+ pass
+
+ def caption(self, *args: object, **kwargs: object) -> None:
+ pass
+
+ def columns(self, n: int) -> list[_FakeColumn]:
+ return [_FakeColumn(self._stop_loss_pct), _FakeColumn(self._take_profit_pct)]
+
+ def warning(self, message: str, **kwargs: object) -> None:
+ self.warnings.append(message)
+
+ def dataframe(self, *args: object, **kwargs: object) -> None:
+ pass
+
+
+def _patch_render_price_chart(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Stub out chart rendering -- these tests only care about the
+ gap-detection/warning logic, not Plotly figure construction."""
+ import quantlab.dashboard.explorer.shared_components as shared_components
+
+ monkeypatch.setattr(
+ shared_components,
+ "render_price_chart",
+ lambda *args, **kwargs: None,
+ )
+
+
+def test_internal_price_gap_while_held_triggers_a_warning(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Regression test: `pct_change().fillna(0.0)` used to silently turn a
+ genuine internal missing price into a flat 0% return, which could hide
+ a real stop-loss/take-profit trigger. A NaN price mid-series while the
+ position is nonzero must be reported via `st.warning`, naming the
+ affected date(s)."""
+ _patch_render_price_chart(monkeypatch)
+ idx = pd.date_range("2024-01-01", periods=6, freq="D")
+ prices = pd.Series([100.0, 101.0, np.nan, 103.0, 104.0, 105.0], index=idx)
+ positions = pd.Series([0.0, 0.5, 0.5, 0.5, 0.5, 0.5], index=idx)
+
+ st: Any = _FakeStreamlit(stop_loss_pct=0.1)
+ render_stop_loss_take_profit_illustration(st, positions, prices, key_prefix="x")
+
+ assert len(st.warnings) == 1
+ assert "asset" in st.warnings[0]
+ assert "2024-01-03" in st.warnings[0] # the NaN price itself
+ assert "2024-01-04" in st.warnings[0] # pct_change's own next-day NaN
+
+
+def test_no_warning_when_the_gap_coincides_with_a_flat_position(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A missing price while the position is flat (0) carries no risk of
+ hiding a stop-loss/take-profit trigger -- must not warn."""
+ _patch_render_price_chart(monkeypatch)
+ idx = pd.date_range("2024-01-01", periods=6, freq="D")
+ prices = pd.Series([100.0, 101.0, np.nan, 103.0, 104.0, 105.0], index=idx)
+ positions = pd.Series([0.0, 0.0, 0.0, 0.0, 0.5, 0.5], index=idx)
+
+ st: Any = _FakeStreamlit(stop_loss_pct=0.1)
+ render_stop_loss_take_profit_illustration(st, positions, prices, key_prefix="x")
+
+ assert st.warnings == []
+
+
+def test_first_observation_is_zeroed_not_reported_as_a_gap(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The very first observation has no prior price to compare against --
+ a structural absence, not a genuine missing return -- and must be
+ silently zeroed even when the position is already nonzero there,
+ never reported as a warned gap."""
+ _patch_render_price_chart(monkeypatch)
+ idx = pd.date_range("2024-01-01", periods=4, freq="D")
+ prices = pd.Series([100.0, 101.0, 102.0, 103.0], index=idx)
+ positions = pd.Series([0.5, 0.5, 0.5, 0.5], index=idx)
+
+ st: Any = _FakeStreamlit(stop_loss_pct=0.1)
+ render_stop_loss_take_profit_illustration(st, positions, prices, key_prefix="x")
+
+ assert st.warnings == []
+
+
+def test_both_thresholds_disabled_returns_before_computing_returns_at_all(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Sanity check: with both thresholds at 0 (disabled), the function
+ returns early and never touches the gap-detection path."""
+ _patch_render_price_chart(monkeypatch)
+ idx = pd.date_range("2024-01-01", periods=3, freq="D")
+ prices = pd.Series([100.0, np.nan, 102.0], index=idx)
+ positions = pd.Series([0.5, 0.5, 0.5], index=idx)
+
+ st: Any = _FakeStreamlit(stop_loss_pct=0.0, take_profit_pct=0.0)
+ render_stop_loss_take_profit_illustration(st, positions, prices, key_prefix="x")
+
+ assert st.warnings == []
diff --git a/tests/unit/test_strategies.py b/tests/unit/test_strategies.py
index 5841778..15fabe7 100644
--- a/tests/unit/test_strategies.py
+++ b/tests/unit/test_strategies.py
@@ -12,16 +12,30 @@
import pytest
from tests.conftest import make_ohlcv
+from quantlab.exceptions import StrategyError
+from quantlab.features.mean_reversion import rolling_zscore
from quantlab.strategies import (
available_strategies,
build_strategy,
)
-from quantlab.strategies.mean_reversion import MeanReversionStrategy
+from quantlab.strategies.base import BaseStrategy
+from quantlab.strategies.buy_and_hold import BuyAndHoldStrategy
+from quantlab.strategies.mean_reversion import (
+ INDICATORS,
+ MeanReversionStrategy,
+ _walk_positions_with_reasons,
+)
from quantlab.strategies.momentum import (
CrossSectionalMomentumStrategy,
TimeSeriesMomentumStrategy,
)
-from quantlab.strategies.pairs_trading import PairsTradingStrategy, adf_pvalue
+from quantlab.strategies.pairs_trading import (
+ PairsTradingStrategy,
+ _walk_pairs_positions_with_reasons,
+ adf_pvalue,
+ rolling_hedge_parameters,
+)
+from quantlab.strategies.trend_following import TrendFollowingStrategy
def _assert_contract(signals: pd.DataFrame) -> None:
@@ -88,12 +102,99 @@ def test_cross_sectional_momentum_picks_winner(synthetic_panel: pd.DataFrame) ->
assert last["BBB"] == 0.0 # weakest → not selected (long-only)
+def test_cross_sectional_magnitude_is_monotone_within_each_selected_leg() -> None:
+ """Regression test: an earlier, cross-sectional-mean-centered version
+ of this function was NOT monotone in score within a selected leg --
+ scores [0, 1, 2] all selected as one long leg standardized to
+ magnitudes [1, 0, 1] (mean 1, std 1), zeroing out the MIDDLE score
+ while the best and worst tied at full weight. Rank-within-leg must
+ fix this: strictly increasing with score in the long leg, strictly
+ decreasing (more negative = higher magnitude) in the short leg, and
+ never exactly zero for a selected asset."""
+ from quantlab.strategies.momentum import _cross_sectional_magnitude
+
+ idx = pd.date_range("2024-01-01", periods=1)
+ score = pd.DataFrame({"A": [0.0], "B": [1.0], "C": [2.0]}, index=idx)
+ selection = pd.DataFrame({"A": [1.0], "B": [1.0], "C": [1.0]}, index=idx)
+ magnitude = _cross_sectional_magnitude(score, selection)
+ row = magnitude.iloc[0]
+ assert row["A"] < row["B"] < row["C"]
+ assert (row > 0.0).all()
+ assert row["C"] == pytest.approx(1.0)
+
+ # Mirrored on the short side: more negative score -> higher magnitude.
+ short_score = pd.DataFrame({"A": [-2.0], "B": [-1.0], "C": [-0.5]}, index=idx)
+ short_selection = pd.DataFrame({"A": [-1.0], "B": [-1.0], "C": [-1.0]}, index=idx)
+ short_magnitude = _cross_sectional_magnitude(short_score, short_selection)
+ short_row = short_magnitude.iloc[0]
+ assert short_row["A"] > short_row["B"] > short_row["C"]
+ assert (short_row > 0.0).all()
+ assert short_row["A"] == pytest.approx(1.0)
+
+ # Unselected assets stay at exactly zero regardless of their score.
+ mixed_score = pd.DataFrame(
+ {"A": [5.0], "B": [1.0], "C": [-1.0], "D": [-5.0]}, index=idx
+ )
+ mixed_selection = pd.DataFrame(
+ {"A": [1.0], "B": [0.0], "C": [0.0], "D": [-1.0]}, index=idx
+ )
+ mixed_magnitude = _cross_sectional_magnitude(mixed_score, mixed_selection)
+ mixed_row = mixed_magnitude.iloc[0]
+ assert mixed_row["B"] == 0.0
+ assert mixed_row["C"] == 0.0
+ assert mixed_row["A"] == pytest.approx(1.0)
+ assert mixed_row["D"] == pytest.approx(1.0)
+
+
+def test_cross_sectional_magnitude_is_invariant_to_column_permutation() -> None:
+ """Regression test: identical scores must get identical magnitudes
+ regardless of which column order they happen to be pivoted into --
+ ``rank(method="first")`` broke ties by column position, an arbitrary,
+ non-economic artifact (e.g. two backtests over the same data loaded
+ with a differently-ordered universe declaration would silently size
+ tied positions differently). ``method="max"`` fixes this: every tied
+ score shares the same rank."""
+ from quantlab.strategies.momentum import _cross_sectional_magnitude
+
+ idx = pd.date_range("2024-01-01", periods=1)
+ score_abc = pd.DataFrame({"A": [1.0], "B": [1.0], "C": [1.0]}, index=idx)
+ selection_abc = pd.DataFrame({"A": [1.0], "B": [1.0], "C": [1.0]}, index=idx)
+ magnitude_abc = _cross_sectional_magnitude(score_abc, selection_abc)
+ # A fully tied leg must resolve to magnitude 1.0 for EVERY member, not
+ # a range spread across the tie group by column order.
+ assert magnitude_abc.iloc[0].to_dict() == {"A": 1.0, "B": 1.0, "C": 1.0}
+
+ score_cab = score_abc[["C", "A", "B"]]
+ selection_cab = selection_abc[["C", "A", "B"]]
+ magnitude_cab = _cross_sectional_magnitude(score_cab, selection_cab)
+ assert magnitude_cab.iloc[0].to_dict() == {"C": 1.0, "A": 1.0, "B": 1.0}
+
+ # A partial tie (two names share the best score) must also resolve
+ # identically for both, regardless of order, while staying monotone
+ # against the untied, lower-scored name.
+ partial_score = pd.DataFrame({"A": [0.0], "B": [2.0], "C": [2.0]}, index=idx)
+ partial_selection = pd.DataFrame({"A": [1.0], "B": [1.0], "C": [1.0]}, index=idx)
+ partial_magnitude = _cross_sectional_magnitude(partial_score, partial_selection)
+ partial_row = partial_magnitude.iloc[0]
+ assert partial_row["B"] == partial_row["C"] == pytest.approx(1.0)
+ assert partial_row["A"] < partial_row["B"]
+
+ reordered_score = partial_score[["C", "A", "B"]]
+ reordered_selection = partial_selection[["C", "A", "B"]]
+ reordered_magnitude = _cross_sectional_magnitude(
+ reordered_score, reordered_selection
+ )
+ assert reordered_magnitude.iloc[0]["B"] == pytest.approx(partial_row["B"])
+ assert reordered_magnitude.iloc[0]["C"] == pytest.approx(partial_row["C"])
+ assert reordered_magnitude.iloc[0]["A"] == pytest.approx(partial_row["A"])
+
+
def test_mean_reversion_goes_long_after_crash() -> None:
# Flat then a sharp drop → z-score deeply negative → long entry.
prices = np.concatenate([np.full(40, 100.0), np.linspace(100, 70, 10)])
data = make_ohlcv("AAA", prices)
strat = MeanReversionStrategy(
- lookback_period=20, entry_zscore=1.5, exit_zscore=0.5, long_only=True
+ lookback_period=20, entry_threshold=1.5, exit_threshold=0.5, long_only=True
)
signals = strat.generate_signals(data)
_assert_contract(signals)
@@ -101,8 +202,319 @@ def test_mean_reversion_goes_long_after_crash() -> None:
def test_mean_reversion_rejects_bad_thresholds() -> None:
- with pytest.raises(ValueError, match="entry_zscore"):
- MeanReversionStrategy(entry_zscore=0.5, exit_zscore=2.0)
+ """entry_threshold must exceed exit_threshold."""
+ with pytest.raises(ValueError, match="entry_threshold"):
+ MeanReversionStrategy(entry_threshold=0.5, exit_threshold=2.0)
+
+
+def test_mean_reversion_explicit_none_disables_stop_threshold() -> None:
+ """Passing stop_threshold=None explicitly must disable the stop
+ entirely -- NOT silently resolve to the indicator's own default, the
+ bug this sentinel-based design fixes."""
+ strat = MeanReversionStrategy(lookback_period=20, stop_threshold=None)
+ assert strat.stop_threshold is None
+
+
+def test_mean_reversion_omitted_stop_threshold_uses_indicator_default() -> None:
+ """Leaving stop_threshold out entirely must resolve to the chosen
+ indicator's own default -- distinct from an explicit None (see the
+ test above)."""
+ strat = MeanReversionStrategy(lookback_period=20, indicator="zscore")
+ assert strat.stop_threshold == 4.0
+
+
+@pytest.mark.parametrize("indicator", sorted(INDICATORS))
+def test_mean_reversion_every_indicator_produces_a_valid_signal(indicator: str) -> None:
+ """Every one of the five indicators must drive the SAME state machine
+ to a valid, actually-nonzero signal on a series constructed to deviate
+ sharply from its own recent history -- not just avoid raising.
+
+ A 30-period monotonic decline (longer than the 25-period lookback)
+ ensures even `percentile`'s rank(pct=True) -- whose minimum is
+ exactly 1/N, never 0 -- comfortably clears its own default entry
+ threshold (percentile < 0.05, i.e. 1/25 = 0.04)."""
+ rng = np.random.default_rng(7)
+ prices = np.concatenate(
+ [100.0 + np.cumsum(rng.normal(0.0, 0.2, 60)), np.linspace(100.0, 40.0, 30)]
+ )
+ data = make_ohlcv("AAA", prices)
+ strat = MeanReversionStrategy(
+ lookback_period=25, indicator=indicator, long_only=False
+ )
+ signals = strat.generate_signals(data)
+ _assert_contract(signals)
+ assert (signals["AAA"] != 0.0).any()
+
+
+def test_walk_positions_with_reasons_covers_every_branch() -> None:
+ """Direct test of the state machine's reason attribution -- one
+ z-score path deliberately visits every branch: oversold entry,
+ mean-reversion exit, overbought entry, stop-loss exit, a no-op NaN
+ (already flat, no reason recorded) and a NaN-driven forced exit."""
+ z = np.array(
+ [
+ 0.0, # flat, below threshold -> no transition
+ -2.5, # crosses -entry (-2.0) -> oversold_entry
+ -0.3, # crosses -exit_ (-0.5) -> mean_reversion_exit
+ 2.5, # crosses entry (2.0) -> overbought_entry
+ 5.0, # |z| > stop (4.0) -> stop_loss_exit
+ np.nan, # already flat -> no-op, no reason recorded
+ -2.5, # oversold_entry again
+ np.nan, # was long -> data_unavailable_exit
+ ]
+ )
+
+ positions, detail_code, details = _walk_positions_with_reasons(
+ z, entry=2.0, exit_=0.5, stop=4.0, long_only=False
+ )
+
+ assert positions.tolist() == [0.0, 1.0, 0.0, -1.0, 0.0, 0.0, 1.0, 0.0]
+ assert detail_code.tolist() == [
+ None,
+ "oversold_entry",
+ "mean_reversion_exit",
+ "overbought_entry",
+ "stop_loss_exit",
+ None,
+ "oversold_entry",
+ "data_unavailable_exit",
+ ]
+ assert details[1] is not None
+ assert "entry threshold -2.0000" in details[1]
+ assert details[2] is not None
+ assert "exit threshold -0.5000" in details[2]
+ assert details[3] is not None
+ assert "entry threshold 2.0000" in details[3]
+ assert details[4] is not None
+ assert "stop threshold 4.0000" in details[4]
+ assert details[7] is not None
+ assert "unavailable" in details[7]
+
+
+def test_walk_positions_with_reasons_long_only_suppresses_short_entry() -> None:
+ """long_only=True must never record overbought_entry -- the branch is
+ unreachable, matching generate_signals' own long_only gate."""
+ z = np.array([0.0, 2.5])
+
+ positions, detail_code, _ = _walk_positions_with_reasons(
+ z, entry=2.0, exit_=0.5, stop=None, long_only=True
+ )
+
+ assert positions.tolist() == [0.0, 0.0]
+ assert detail_code.tolist() == [None, None]
+
+
+def test_mean_reversion_explain_signals_matches_generate_signals_transitions() -> None:
+ """Every date generate_signals() actually changes AAA's position must
+ have a non-None reason, and vice versa -- explain_signals() must
+ never invent a reason for a date nothing happened, nor omit one where
+ something did."""
+ prices = np.concatenate([np.full(40, 100.0), np.linspace(100, 70, 10)])
+ data = make_ohlcv("AAA", prices)
+ strat = MeanReversionStrategy(
+ lookback_period=20, entry_threshold=1.5, exit_threshold=0.5, long_only=True
+ )
+
+ signals = strat.generate_signals(data)
+ reasons = strat.explain_signals(data)
+
+ assert reasons.detail_code.index.equals(signals.index)
+ assert reasons.detail_code.columns.equals(signals.columns)
+ assert reasons.details.index.equals(signals.index)
+ assert reasons.details.columns.equals(signals.columns)
+
+ values = signals["AAA"].to_numpy()
+ previous = np.concatenate([[0.0], values[:-1]])
+ changed = np.abs(values - previous) > 1e-12
+ has_reason = reasons.detail_code["AAA"].notna().to_numpy()
+ assert (changed == has_reason).all()
+ # Exactly one transition (flat -> long) drives this whole crash
+ # scenario -- the FIRST row generate_signals() goes to 1.0 must read
+ # as oversold_entry, not some other branch.
+ entry_row = int(np.flatnonzero(changed)[0])
+ assert reasons.detail_code["AAA"].iloc[entry_row] == "oversold_entry"
+
+
+def test_mean_reversion_explain_signals_does_not_affect_generate_signals() -> None:
+ """explain_signals() is a pure, independent recomputation -- calling
+ it must not change what generate_signals() itself returns."""
+ prices = np.concatenate([np.full(40, 100.0), np.linspace(100, 70, 10)])
+ data = make_ohlcv("AAA", prices)
+ strat = MeanReversionStrategy(
+ lookback_period=20, entry_threshold=1.5, exit_threshold=0.5, long_only=True
+ )
+
+ before = strat.generate_signals(data)
+ strat.explain_signals(data)
+ after = strat.generate_signals(data)
+
+ pd.testing.assert_frame_equal(before, after)
+
+
+def test_buy_and_hold_explain_signals_matches_generate_signals_transitions() -> None:
+ """A symbol whose price starts partway through the window (a
+ staggered listing date) must read as price_became_available exactly
+ on its first valid row -- the only thing this strategy's signal can
+ ever depend on -- and every transition generate_signals() actually
+ makes must have a matching non-None reason, and vice versa."""
+ data_a = make_ohlcv("AAA", np.full(20, 100.0), start="2020-01-01")
+ data_b = make_ohlcv("BBB", np.full(20, 50.0), start="2020-01-01").iloc[5:]
+ data = pd.concat([data_a, data_b], ignore_index=True)
+
+ strat = BuyAndHoldStrategy()
+ signals = strat.generate_signals(data)
+ reasons = strat.explain_signals(data)
+
+ for symbol in ("AAA", "BBB"):
+ values = signals[symbol].to_numpy()
+ previous = np.concatenate([[0.0], values[:-1]])
+ changed = np.abs(values - previous) > 1e-12
+ has_reason = reasons.detail_code[symbol].notna().to_numpy()
+ assert (changed == has_reason).all()
+
+ first_valid_bbb_date = signals.index[5]
+ assert (
+ reasons.detail_code.at[first_valid_bbb_date, "BBB"] == "price_became_available"
+ )
+ assert reasons.detail_code.at[signals.index[0], "AAA"] == "price_became_available"
+
+
+def test_trend_following_explain_signals_reports_crossover_codes() -> None:
+ """A clean down-then-up price path forces exactly one bearish and one
+ bullish crossover; codes and the fast/slow MA values in the details
+ must match generate_signals()' own transitions."""
+ prices = np.concatenate([np.linspace(100, 80, 60), np.linspace(80, 120, 60)])
+ data = make_ohlcv("AAA", prices, start="2020-01-01")
+ strat = TrendFollowingStrategy(fast_window=5, slow_window=20, long_only=False)
+
+ signals = strat.generate_signals(data)
+ reasons = strat.explain_signals(data)
+
+ values = signals["AAA"].to_numpy()
+ previous = np.concatenate([[0.0], values[:-1]])
+ changed = np.abs(values - previous) > 1e-12
+ has_reason = reasons.detail_code["AAA"].notna().to_numpy()
+ assert (changed == has_reason).all()
+ assert set(reasons.detail_code["AAA"].dropna().unique()) <= {
+ "bullish_crossover",
+ "bearish_crossover",
+ }
+ # The uptrend leg must eventually produce a bullish crossover, and
+ # its details must cite real MA values.
+ bullish = reasons.detail_code["AAA"] == "bullish_crossover"
+ assert bullish.any()
+ bullish_details = reasons.details["AAA"][bullish].iloc[0]
+ assert "fast MA" in bullish_details
+ assert "crossed above slow MA" in bullish_details
+
+
+def test_time_series_momentum_binary_explain_signals_reports_entry_codes() -> None:
+ prices = np.concatenate([np.full(30, 100.0), np.linspace(100, 160, 40)])
+ data = make_ohlcv("AAA", prices, start="2020-01-01")
+ strat = TimeSeriesMomentumStrategy(
+ lookback_period=20, skip_period=1, signal_scaling="binary", long_only=True
+ )
+
+ signals = strat.generate_signals(data)
+ reasons = strat.explain_signals(data)
+ assert reasons is not None
+
+ values = signals["AAA"].to_numpy()
+ previous = np.concatenate([[0.0], values[:-1]])
+ changed = np.abs(values - previous) > 1e-12
+ has_reason = reasons.detail_code["AAA"].notna().to_numpy()
+ assert (changed == has_reason).all()
+ codes = set(reasons.detail_code["AAA"].dropna().unique())
+ assert codes <= {
+ "positive_momentum_entry",
+ "negative_momentum_entry",
+ "momentum_exit",
+ }
+ assert "positive_momentum_entry" in codes
+
+
+@pytest.mark.parametrize("signal_scaling", ["continuous", "volatility_adjusted"])
+def test_time_series_momentum_non_binary_explain_signals_returns_none(
+ signal_scaling: str,
+) -> None:
+ """A continuously-scaled signal changes almost every rebalance date --
+ the generic pipeline text already explains it fully, so this
+ deliberately opts out of a strategy-specific attribution rather than
+ inventing a label repeated on nearly every row."""
+ prices = np.concatenate([np.full(30, 100.0), np.linspace(100, 160, 40)])
+ data = make_ohlcv("AAA", prices, start="2020-01-01")
+ strat = TimeSeriesMomentumStrategy(
+ lookback_period=20, skip_period=1, signal_scaling=signal_scaling
+ )
+ assert strat.explain_signals(data) is None
+
+
+def test_time_series_momentum_volatility_adjusted_masks_zero_volatility() -> None:
+ """Regression test: a price jump followed by a long dead-flat stretch
+ gives a zero trailing realized volatility while the (longer-lookback)
+ momentum score is still positive. `generate_signals()` previously
+ computed ``score / volatility`` inline and clipped the result, so
+ ``positive / 0 == inf`` became a false full-conviction ``+1.0`` there --
+ diverging from the public `volatility_adjusted_momentum()` helper (and
+ the Strategy Explorer lab), which both mask a zero-volatility window to
+ ``NaN`` (an inconclusive read, not a confident signal). The strategy
+ must now agree with the helper: NaN there, filled to `0.0` by
+ `_validate_signals()`, never `1.0`.
+ """
+ from quantlab.features.momentum import volatility_adjusted_momentum
+
+ prices = np.concatenate([np.full(10, 100.0), np.full(90, 110.0)])
+ data = make_ohlcv("AAA", prices, start="2020-01-01")
+ lookback, skip, vol_window = 60, 0, 20
+ strat = TimeSeriesMomentumStrategy(
+ lookback_period=lookback,
+ skip_period=skip,
+ signal_scaling="volatility_adjusted",
+ volatility_window=vol_window,
+ long_only=False,
+ )
+ signals = strat.generate_signals(data)["AAA"]
+
+ helper = volatility_adjusted_momentum(
+ pd.Series(prices, index=data["timestamp"].unique()),
+ lookback,
+ skip,
+ vol_window,
+ 252,
+ ).clip(-1.0, 1.0)
+ expected = helper.fillna(0.0)
+ expected.index = signals.index
+ pd.testing.assert_series_equal(signals, expected, check_names=False)
+ # Not a vacuous comparison -- confirm the zero-volatility window this
+ # test targets actually occurs and would previously have been 1.0.
+ assert (expected == 0.0).any()
+
+
+def test_cross_sectional_momentum_explain_signals_reports_selection_codes(
+ synthetic_panel: pd.DataFrame,
+) -> None:
+ strat = CrossSectionalMomentumStrategy(
+ lookback_period=60, skip_period=5, top_fraction=0.34, long_short=True
+ )
+ signals = strat.generate_signals(synthetic_panel)
+ reasons = strat.explain_signals(synthetic_panel)
+ assert reasons is not None
+
+ for symbol in signals.columns:
+ values = signals[symbol].to_numpy()
+ previous = np.concatenate([[0.0], values[:-1]])
+ changed = np.abs(values - previous) > 1e-12
+ has_reason = reasons.detail_code[symbol].notna().to_numpy()
+ assert (changed == has_reason).all()
+
+ codes = set(np.unique(reasons.detail_code.to_numpy()[reasons.detail_code.notna()]))
+ assert codes <= {
+ "entered_top_selection",
+ "left_top_selection",
+ "entered_bottom_selection",
+ "left_bottom_selection",
+ }
+ assert "entered_top_selection" in codes
def test_pairs_trading_contract(two_symbol_panel: pd.DataFrame) -> None:
@@ -110,9 +522,9 @@ def test_pairs_trading_contract(two_symbol_panel: pd.DataFrame) -> None:
symbol_a="EWA",
symbol_b="EWB",
formation_window=120,
- zscore_window=30,
- entry_zscore=1.5,
- exit_zscore=0.5,
+ indicator_window=30,
+ entry_threshold=1.5,
+ exit_threshold=0.5,
)
signals = strat.generate_signals(two_symbol_panel)
_assert_contract(signals)
@@ -124,6 +536,278 @@ def test_pairs_trading_contract(two_symbol_panel: pd.DataFrame) -> None:
assert np.sign(row["EWA"]) == -np.sign(row["EWB"])
+def test_pairs_trading_explain_signals_matches_generate_signals_transitions(
+ two_symbol_panel: pd.DataFrame,
+) -> None:
+ """Both legs must carry the SAME reason at the SAME date (one shared
+ pair position), matching a direct re-walk of the state machine; every
+ other symbol stays None."""
+ strat = PairsTradingStrategy(
+ symbol_a="EWA",
+ symbol_b="EWB",
+ formation_window=120,
+ indicator_window=30,
+ entry_threshold=1.5,
+ exit_threshold=0.5,
+ )
+ prices = strat._prices(two_symbol_panel)
+ a, b = prices["EWA"], prices["EWB"]
+ intercept, beta = rolling_hedge_parameters(
+ a, b, strat.formation_window, strat.dynamic_hedge_ratio
+ )
+ zscore = rolling_zscore(a - intercept - beta * b, strat.indicator_window)
+ state, expected_detail_code, expected_details = _walk_pairs_positions_with_reasons(
+ zscore.to_numpy(dtype=float),
+ strat._stationarity_gate(a, b),
+ entry=strat.entry_threshold,
+ exit_=strat.exit_threshold,
+ stop=strat.stop_threshold,
+ )
+
+ reasons = strat.explain_signals(two_symbol_panel)
+
+ pd.testing.assert_series_equal(
+ reasons.detail_code["EWA"], reasons.detail_code["EWB"], check_names=False
+ )
+ assert reasons.detail_code["EWA"].tolist() == list(expected_detail_code)
+ assert reasons.details["EWA"].tolist() == list(expected_details)
+ codes = set(reasons.detail_code["EWA"].dropna().unique())
+ assert codes <= {
+ "spread_oversold_entry",
+ "spread_overbought_entry",
+ "mean_reversion_exit",
+ "stop_loss_exit",
+ "data_unavailable_exit",
+ }
+ assert codes # this panel is designed to actually trade
+ assert (state != 0).any()
+
+
+def test_pairs_trading_explain_signals_says_gate_disabled_when_adf_is_none(
+ two_symbol_panel: pd.DataFrame,
+) -> None:
+ """`adf_pvalue_threshold=None` disables the stationarity gate entirely
+ -- an entry's reason text must say so, never claim "stationarity gate
+ open" for a gate that was never even evaluated."""
+ strat = PairsTradingStrategy(
+ symbol_a="EWA",
+ symbol_b="EWB",
+ formation_window=120,
+ indicator_window=30,
+ entry_threshold=1.5,
+ exit_threshold=0.5,
+ adf_pvalue_threshold=None,
+ )
+ reasons = strat.explain_signals(two_symbol_panel)
+ entry_details = reasons.details["EWA"][
+ reasons.detail_code["EWA"].isin(
+ ["spread_oversold_entry", "spread_overbought_entry"]
+ )
+ ]
+ assert not entry_details.empty # this panel is designed to actually trade
+ assert entry_details.str.contains("gate disabled").all()
+ assert not entry_details.str.contains("stationarity gate open").any()
+
+
+def test_pairs_trading_decision_signal_matches_the_real_state_array(
+ two_symbol_panel: pd.DataFrame,
+) -> None:
+ """decision_signal() must return EXACTLY the same discrete `state`
+ array (+-1/0) that generate_signals() computes internally -- a pure
+ recalculation via the same shared helper, never a reconstruction that
+ could diverge."""
+ strat = PairsTradingStrategy(
+ symbol_a="EWA",
+ symbol_b="EWB",
+ formation_window=120,
+ indicator_window=30,
+ entry_threshold=1.5,
+ exit_threshold=0.5,
+ )
+ prices = strat._prices(two_symbol_panel)
+ a, b = prices["EWA"], prices["EWB"]
+ intercept, beta = rolling_hedge_parameters(
+ a, b, strat.formation_window, strat.dynamic_hedge_ratio
+ )
+ zscore = rolling_zscore(a - intercept - beta * b, strat.indicator_window)
+ expected_state, _, _ = _walk_pairs_positions_with_reasons(
+ zscore.to_numpy(dtype=float),
+ strat._stationarity_gate(a, b),
+ entry=strat.entry_threshold,
+ exit_=strat.exit_threshold,
+ stop=strat.stop_threshold,
+ )
+
+ decision = strat.decision_signal(two_symbol_panel)
+
+ assert decision is not None
+ assert decision["EWA"].tolist() == list(expected_state)
+ assert decision["EWB"].tolist() == list(expected_state)
+ # Every other symbol in the universe stays 0 -- this strategy never
+ # touches them.
+ other_columns = [c for c in decision.columns if c not in ("EWA", "EWB")]
+ for column in other_columns:
+ assert (decision[column] == 0.0).all()
+
+
+def test_pairs_trading_decision_signal_shares_index_and_columns_with_prices(
+ two_symbol_panel: pd.DataFrame,
+) -> None:
+ strat = PairsTradingStrategy(
+ symbol_a="EWA",
+ symbol_b="EWB",
+ formation_window=120,
+ indicator_window=30,
+ entry_threshold=1.5,
+ exit_threshold=0.5,
+ )
+ prices = strat._prices(two_symbol_panel)
+
+ decision = strat.decision_signal(two_symbol_panel)
+
+ assert decision is not None
+ assert decision.index.equals(prices.index)
+ assert decision.columns.equals(prices.columns)
+ assert np.isfinite(decision.to_numpy(dtype=float)).all()
+
+
+def test_base_strategy_decision_signal_defaults_to_none() -> None:
+ """Every built-in strategy except pairs_trading leaves decision_signal
+ at its default -- generate_signals()'s own output is already a
+ faithful decision proxy for them."""
+ strat = MeanReversionStrategy(
+ lookback_period=20, entry_threshold=1.5, exit_threshold=0.5
+ )
+ assert strat.decision_signal(pd.DataFrame()) is None
+
+
+def test_validate_decision_signal_rejects_mismatched_shape() -> None:
+ reference = pd.DataFrame(
+ {"A": [1.0, 2.0]}, index=pd.date_range("2020-01-01", periods=2)
+ )
+ mismatched = pd.DataFrame(
+ {"A": [1.0]}, index=pd.date_range("2020-01-01", periods=1)
+ )
+ with pytest.raises(StrategyError, match="index and columns"):
+ BaseStrategy._validate_decision_signal(mismatched, reference)
+
+
+def test_validate_decision_signal_rejects_mismatched_columns() -> None:
+ idx = pd.date_range("2020-01-01", periods=2)
+ reference = pd.DataFrame({"A": [1.0, 2.0]}, index=idx)
+ mismatched = pd.DataFrame({"B": [1.0, 2.0]}, index=idx)
+ with pytest.raises(StrategyError, match="index and columns"):
+ BaseStrategy._validate_decision_signal(mismatched, reference)
+
+
+def test_validate_decision_signal_rejects_non_numeric_values() -> None:
+ idx = pd.date_range("2020-01-01", periods=2)
+ reference = pd.DataFrame({"A": [1.0, 2.0]}, index=idx)
+ non_numeric = pd.DataFrame({"A": ["x", "y"]}, index=idx)
+ with pytest.raises(StrategyError, match="numeric"):
+ BaseStrategy._validate_decision_signal(non_numeric, reference)
+
+
+def test_validate_decision_signal_rejects_nan() -> None:
+ idx = pd.date_range("2020-01-01", periods=2)
+ reference = pd.DataFrame({"A": [1.0, 2.0]}, index=idx)
+ with_nan = pd.DataFrame({"A": [1.0, np.nan]}, index=idx)
+ with pytest.raises(StrategyError, match="NaN or Infinity"):
+ BaseStrategy._validate_decision_signal(with_nan, reference)
+
+
+def test_validate_decision_signal_rejects_infinity() -> None:
+ idx = pd.date_range("2020-01-01", periods=2)
+ reference = pd.DataFrame({"A": [1.0, 2.0]}, index=idx)
+ with_inf = pd.DataFrame({"A": [1.0, np.inf]}, index=idx)
+ with pytest.raises(StrategyError, match="NaN or Infinity"):
+ BaseStrategy._validate_decision_signal(with_inf, reference)
+
+
+def test_decision_signal_never_affects_backtest_numerics(
+ two_symbol_panel: pd.DataFrame,
+) -> None:
+ """decision_signal() is a strictly diagnostic proxy (point 6): a
+ monkeypatched version that always returns None (forcing the engine's
+ fallback to the raw `signals`) must produce a BIT-IDENTICAL backtest
+ -- weights, PnL, costs -- to the real override; only the trade log's
+ reason columns may differ."""
+ from quantlab.backtesting.engine import BacktestEngine
+ from quantlab.config import ExperimentConfig
+ from quantlab.execution.execution_model import ExecutionModel
+ from quantlab.portfolio.allocator import build_allocator
+
+ config = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "decision_signal_invariance",
+ "data": {
+ "instruments": [
+ {"symbol": "EWA", "source": "csv", "calendar": "XNYS"},
+ {"symbol": "EWB", "source": "csv", "calendar": "XNYS"},
+ ],
+ "start_date": "2020-01-01",
+ "end_date": "2020-12-31",
+ },
+ "strategy": {
+ "name": "pairs_trading",
+ "parameters": {"symbol_a": "EWA", "symbol_b": "EWB"},
+ }, # unused: an instance is passed directly to .run() below
+ "portfolio": {
+ "allocator": "signal_proportional",
+ "rebalance_frequency": "daily",
+ },
+ "execution": {
+ "commission_bps": 0.0,
+ "spread_bps": 0.0,
+ "slippage_bps": 0.0,
+ },
+ "backtest": {"initial_capital": 100_000},
+ }
+ )
+ strategy = PairsTradingStrategy(
+ symbol_a="EWA",
+ symbol_b="EWB",
+ formation_window=60,
+ indicator_window=15,
+ entry_threshold=1.0,
+ exit_threshold=0.3,
+ )
+ execution_model = ExecutionModel.from_config(config.execution)
+ allocator = build_allocator("signal_proportional")
+ with_decision = BacktestEngine().run(
+ two_symbol_panel, strategy, allocator, execution_model, config
+ )
+
+ class _NoDecisionProxy(PairsTradingStrategy):
+ def decision_signal( # type: ignore[override]
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> None:
+ return None
+
+ fallback_strategy = _NoDecisionProxy(
+ symbol_a="EWA",
+ symbol_b="EWB",
+ formation_window=60,
+ indicator_window=15,
+ entry_threshold=1.0,
+ exit_threshold=0.3,
+ )
+ without_decision = BacktestEngine().run(
+ two_symbol_panel, fallback_strategy, allocator, execution_model, config
+ )
+
+ pd.testing.assert_series_equal(
+ with_decision.equity_curve, without_decision.equity_curve
+ )
+ pd.testing.assert_frame_equal(with_decision.weights, without_decision.weights)
+ pd.testing.assert_frame_equal(with_decision.positions, without_decision.positions)
+ pd.testing.assert_series_equal(with_decision.returns, without_decision.returns)
+ for column in ("commission", "spread_cost", "slippage_cost", "total_cost"):
+ pd.testing.assert_series_equal(
+ with_decision.trades[column], without_decision.trades[column]
+ )
+
+
def test_adf_pvalue_on_stationary_series() -> None:
rng = np.random.default_rng(0)
n = 400
@@ -140,3 +824,119 @@ def test_build_strategy_unknown_raises() -> None:
with pytest.raises(StrategyError):
build_strategy("does_not_exist")
+
+
+def _split_like_data(symbol: str = "AAA") -> pd.DataFrame:
+ """40 rows whose adjusted_close diverges from close via a simulated split.
+
+ The first half of adjusted_close is halved relative to close, creating
+ a real, structural divergence between the two price series -- not just
+ numeric noise -- so ``_prices()`` must produce genuinely different
+ matrices for ``price_type="close"`` vs ``"adjusted_close"``.
+ """
+ n = 40
+ dates = pd.bdate_range("2020-01-01", periods=n)
+ close = np.linspace(100, 140, n)
+ adjusted_close = close.copy()
+ adjusted_close[:20] /= 2.0
+ return pd.DataFrame(
+ {
+ "timestamp": dates,
+ "symbol": symbol,
+ "open": close,
+ "high": close,
+ "low": close,
+ "close": close,
+ "adjusted_close": adjusted_close,
+ "volume": 1_000_000.0,
+ }
+ )
+
+
+@pytest.mark.parametrize(
+ ("strategy_cls", "kwargs"),
+ [
+ (BuyAndHoldStrategy, {}),
+ (TimeSeriesMomentumStrategy, {"lookback_period": 10, "skip_period": 0}),
+ (CrossSectionalMomentumStrategy, {"lookback_period": 10, "skip_period": 0}),
+ (MeanReversionStrategy, {"lookback_period": 10}),
+ (TrendFollowingStrategy, {"fast_window": 3, "slow_window": 8}),
+ ],
+)
+def test_prices_respects_signal_price_type_per_strategy(
+ strategy_cls: type[BaseStrategy], kwargs: dict[str, object]
+) -> None:
+ """Each strategy's ``_prices()`` must read whichever ``price_type`` it
+ was constructed with -- ``"close"`` and ``"adjusted_close"`` must
+ produce genuinely different price matrices on data with a real
+ divergence between the two fields."""
+ data = _split_like_data()
+ strategy_close = strategy_cls(price_type="close", **kwargs) # type: ignore[call-arg]
+ strategy_adjusted = strategy_cls(price_type="adjusted_close", **kwargs) # type: ignore[call-arg]
+ assert strategy_close.price_type == "close"
+ assert strategy_adjusted.price_type == "adjusted_close"
+
+ prices_close = strategy_close._prices(data)
+ prices_adjusted = strategy_adjusted._prices(data)
+ assert not prices_close.equals(prices_adjusted)
+ pd.testing.assert_series_equal(
+ prices_close["AAA"], data.set_index("timestamp")["close"], check_names=False
+ )
+ pd.testing.assert_series_equal(
+ prices_adjusted["AAA"],
+ data.set_index("timestamp")["adjusted_close"],
+ check_names=False,
+ )
+
+
+def test_prices_respects_signal_price_type_for_pairs_trading() -> None:
+ data = pd.concat(
+ [_split_like_data("AAA"), _split_like_data("BBB")], ignore_index=True
+ )
+ strategy_close = PairsTradingStrategy(
+ symbol_a="AAA", symbol_b="BBB", formation_window=20, price_type="close"
+ )
+ strategy_adjusted = PairsTradingStrategy(
+ symbol_a="AAA", symbol_b="BBB", formation_window=20, price_type="adjusted_close"
+ )
+ prices_close = strategy_close._prices(data)
+ prices_adjusted = strategy_adjusted._prices(data)
+ assert not prices_close.equals(prices_adjusted)
+
+
+def test_price_type_is_rejected_when_invalid() -> None:
+ with pytest.raises(ValueError, match="price_type"):
+ BuyAndHoldStrategy(price_type="vwap")
+ with pytest.raises(StrategyError, match="price_type"):
+ build_strategy("buy_and_hold", {"price_type": "vwap"})
+
+
+def test_build_strategy_from_config_injects_signal_price_type() -> None:
+ """build_strategy_from_config() must inject strategy.signal_price_type
+ the same way it already injects periods_per_year -- only when the
+ strategy accepts it and the YAML didn't already set it explicitly."""
+ from quantlab.backtesting.runner import build_strategy_from_config
+ from quantlab.config import ExperimentConfig
+
+ cfg = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "price_type_injection",
+ "data": {
+ "instruments": [{"symbol": "AAA", "source": "csv", "calendar": "XNYS"}],
+ "start_date": "2020-01-01",
+ "end_date": "2020-06-01",
+ },
+ "strategy": {"name": "buy_and_hold", "signal_price_type": "close"},
+ }
+ )
+ strategy = build_strategy_from_config(cfg)
+ assert strategy.price_type == "close"
+
+ default_cfg = cfg.revalidated_copy(
+ update={
+ "strategy": cfg.strategy.revalidated_copy(
+ update={"signal_price_type": "adjusted_close"}
+ )
+ }
+ )
+ assert build_strategy_from_config(default_cfg).price_type == "adjusted_close"
diff --git a/tests/unit/test_strategies_hardening.py b/tests/unit/test_strategies_hardening.py
index d303fe2..98961a3 100644
--- a/tests/unit/test_strategies_hardening.py
+++ b/tests/unit/test_strategies_hardening.py
@@ -140,7 +140,7 @@ def test_direct_strategy_data_requires_finite_positive_prices(
@pytest.mark.parametrize(
("factory", "message"),
[
- (lambda: MeanReversionStrategy(entry_zscore=np.nan), "finite"),
+ (lambda: MeanReversionStrategy(entry_threshold=np.nan), "finite"),
(lambda: MeanReversionStrategy(long_only=cast(Any, "false")), "boolean"),
(
lambda: TimeSeriesMomentumStrategy(lookback_period=cast(Any, True)),
@@ -185,7 +185,9 @@ def test_adf_inconclusive_is_explicit_and_never_passes_gate(
PairsTradingStrategy("AAA", "BBB", adf_pvalue_threshold=1.0)
monkeypatch.setattr(pairs_module, "adf_pvalue", lambda series: None)
- strategy = PairsTradingStrategy("AAA", "BBB", formation_window=20, zscore_window=2)
+ strategy = PairsTradingStrategy(
+ "AAA", "BBB", formation_window=20, indicator_window=2
+ )
index = pd.date_range("2020-01-01", periods=25)
a = pd.Series(np.linspace(100.0, 120.0, 25), index=index)
b = pd.Series(np.linspace(50.0, 60.0, 25), index=index)
@@ -204,7 +206,9 @@ def _capture(series: pd.Series) -> float:
return 0.01
monkeypatch.setattr(pairs_module, "adf_pvalue", _capture)
- strategy = PairsTradingStrategy("AAA", "BBB", formation_window=20, zscore_window=2)
+ strategy = PairsTradingStrategy(
+ "AAA", "BBB", formation_window=20, indicator_window=2
+ )
index = pd.date_range("2020-01-01", periods=25)
b = pd.Series(np.linspace(50.0, 60.0, 25), index=index)
a = 5.0 + 1.5 * b + pd.Series(np.sin(np.arange(25)), index=index)
@@ -232,7 +236,7 @@ def _counted_ols(x: np.ndarray, y: np.ndarray) -> tuple[float, float]:
"AAA",
"BBB",
formation_window=20,
- zscore_window=2,
+ indicator_window=2,
dynamic_hedge_ratio=False,
)
index = pd.date_range("2020-01-01", periods=25)
@@ -270,7 +274,9 @@ def _parameters(
[make_ohlcv("AAA", np.full(25, 100.0)), make_ohlcv("BBB", np.full(25, 50.0))],
ignore_index=True,
)
- strategy = PairsTradingStrategy("AAA", "BBB", formation_window=20, zscore_window=2)
+ strategy = PairsTradingStrategy(
+ "AAA", "BBB", formation_window=20, indicator_window=2
+ )
last = strategy.generate_signals(data).iloc[-1]
assert last["AAA"] == pytest.approx(1.0)
assert last["BBB"] == pytest.approx(-1.0)
@@ -363,6 +369,9 @@ def test_trend_strategy_contains_direction_parameters_only() -> None:
"fast_window": 10,
"slow_window": 30,
"long_only": True,
+ "price_type": "adjusted_close",
+ "stop_loss_pct": None,
+ "take_profit_pct": None,
}
@@ -420,7 +429,7 @@ def test_unwrap_simple_type_returns_none_for_ambiguous_annotations() -> None:
def test_prices_rejects_data_with_no_rows() -> None:
empty = make_ohlcv("AAA", [100.0, 101.0]).iloc[0:0]
with pytest.raises(StrategyError, match="at least one date and symbol"):
- BaseStrategy._prices(empty)
+ BuyAndHoldStrategy()._prices(empty)
def test_validate_signals_rejects_non_dataframe_input() -> None:
@@ -497,8 +506,8 @@ def test_walk_pairs_positions_requires_matching_lengths() -> None:
def test_pairs_strategy_allows_no_stop_and_rejects_missing_symbol() -> None:
- strategy = PairsTradingStrategy("AAA", "BBB", stop_zscore=None)
- assert strategy.stop_zscore is None
+ strategy = PairsTradingStrategy("AAA", "BBB", stop_threshold=None)
+ assert strategy.stop_threshold is None
data = make_ohlcv("AAA", [100.0] * 30)
with pytest.raises(StrategyError, match="needs symbol"):
strategy.generate_signals(data)
diff --git a/tests/unit/test_trade_log.py b/tests/unit/test_trade_log.py
index 22f8852..60ae133 100644
--- a/tests/unit/test_trade_log.py
+++ b/tests/unit/test_trade_log.py
@@ -2,15 +2,43 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, TypedDict, cast
import numpy as np
import pandas as pd
import pytest
-from quantlab.backtesting.trade_log import build_trade_log
+from quantlab.backtesting.trade_log import (
+ ADJUSTMENT_ORDER,
+ TRADE_LOG_COLUMNS,
+ TRADE_LOG_SCHEMA_VERSION,
+ TradeReason,
+ _classify_action,
+ _classify_reason,
+ build_trade_log,
+ parse_adjustment_codes,
+ serialize_adjustment_codes,
+)
from quantlab.exceptions import BacktestError
from quantlab.execution.slippage import ConstantSlippageModel, SlippageModel
+from quantlab.portfolio.constraints import ConstraintTouch
+
+
+def _touch(
+ touched: pd.DataFrame,
+ before: pd.DataFrame,
+ after: pd.DataFrame,
+ *,
+ direct: pd.DataFrame | None = None,
+) -> ConstraintTouch:
+ """Build a ConstraintTouch, defaulting `direct` to `touched` (no
+ redistribution concept -- matches _mark_touched's own default)."""
+ return ConstraintTouch(
+ touched=touched,
+ before=before,
+ after=after,
+ direct=direct if direct is not None else touched,
+ )
def _inputs() -> tuple[pd.DataFrame, pd.DataFrame, pd.Series, pd.DataFrame]:
@@ -49,6 +77,524 @@ def test_trade_log_uses_new_weight_schema() -> None:
assert trades["reference_price"].tolist() == [10.0]
+def test_trade_log_schema_has_21_columns_in_order() -> None:
+ """`action` is always computed; the trigger/adjustment/position_
+ strategy_origin columns stay `None`/`NaT` when the optional reason
+ frames are omitted -- the walk-forward call site (which rebuilds
+ trades from a stitched out-of-sample series with no per-fold
+ diagnostic frames surviving the stitch) must keep working unchanged."""
+ trades = _build(*_inputs())
+
+ assert list(trades.columns) == TRADE_LOG_COLUMNS
+ assert len(TRADE_LOG_COLUMNS) == 21
+ assert TRADE_LOG_SCHEMA_VERSION == 2
+ assert trades["action"].tolist() == ["entry_long"]
+ for column in (
+ "trigger_reason_code",
+ "trigger_reason_detail_code",
+ "trigger_reason_details",
+ "adjustment_reason_codes",
+ "adjustment_reason_details",
+ "position_strategy_origin_code",
+ "position_strategy_origin_details",
+ ):
+ assert trades[column].tolist() == [None]
+ assert pd.isna(trades["position_strategy_origin_timestamp"].iloc[0])
+
+
+def test_previous_weight_reflects_organic_drift_not_the_prior_rows_own_value() -> None:
+ """Regression test: `previous_weight` must be the value organic drift
+ actually left the position at going into this trade -- NOT the
+ previous ROW's own reported `executed_weight`, which under
+ `model_weight_drift=True` can differ from it whenever drift moved the
+ position between rows with no trade of its own. Reproduces the exact
+ scenario reported: a position drifts from 0.50 up to 0.60 with no
+ trade recorded (row 1, `weight_changes=0`), then a sell brings it to
+ 0.55 (`weight_change=-0.05`) -- `previous_weight` must read 0.60, and
+ the action must be `reduce_long`, not `increase_long` (what the old
+ `executed_weights.shift(1)` formula -- which would have read 0.50 --
+ would have produced)."""
+ index = pd.date_range("2024-01-01", periods=4, freq="D")
+ # A leading flat (no-trade) row, matching every real backtest's own
+ # warm-up convention -- gives the entry on row 1 a valid prior-period
+ # reference price (row 0's), avoiding an unrelated "no prior price"
+ # error for what would otherwise be the very first row.
+ executed = pd.DataFrame({"AAA": [0.0, 0.5, 0.6, 0.55]}, index=index)
+ changes = pd.DataFrame({"AAA": [0.0, 0.5, 0.0, -0.05]}, index=index)
+ equity = pd.Series([100.0, 100.0, 100.0, 100.0], index=index)
+ prices = pd.DataFrame({"AAA": [9.0, 10.0, 11.0, 12.0]}, index=index)
+
+ trades = _build(executed, changes, equity, prices)
+
+ assert trades["previous_weight"].tolist() == [0.0, pytest.approx(0.6)]
+ assert trades["new_weight"].tolist() == [0.5, pytest.approx(0.55)]
+ assert trades["weight_change"].tolist() == [0.5, pytest.approx(-0.05)]
+ assert trades["side"].tolist() == ["buy", "sell"]
+ assert trades["action"].tolist() == ["entry_long", "reduce_long"]
+ assert np.allclose(
+ (trades["new_weight"] - trades["previous_weight"]).to_numpy(),
+ trades["weight_change"].to_numpy(),
+ )
+
+
+def test_previous_weight_reflects_downward_drift_before_a_buy() -> None:
+ """Symmetric case: a long position drifts DOWN from 0.5 to 0.4 with no
+ trade of its own, then a buy tops it back up to 0.45
+ (`weight_change=+0.05`) -- `previous_weight` must read 0.40, and the
+ action must be `increase_long`."""
+ index = pd.date_range("2024-01-01", periods=4, freq="D")
+ executed = pd.DataFrame({"AAA": [0.0, 0.5, 0.4, 0.45]}, index=index)
+ changes = pd.DataFrame({"AAA": [0.0, 0.5, 0.0, 0.05]}, index=index)
+ equity = pd.Series([100.0, 100.0, 100.0, 100.0], index=index)
+ prices = pd.DataFrame({"AAA": [11.0, 10.0, 9.0, 9.5]}, index=index)
+
+ trades = _build(executed, changes, equity, prices)
+
+ assert trades["previous_weight"].tolist() == [0.0, pytest.approx(0.4)]
+ assert trades["new_weight"].tolist() == [0.5, pytest.approx(0.45)]
+ assert trades["side"].tolist() == ["buy", "buy"]
+ assert trades["action"].tolist() == ["entry_long", "increase_long"]
+ assert np.allclose(
+ (trades["new_weight"] - trades["previous_weight"]).to_numpy(),
+ trades["weight_change"].to_numpy(),
+ )
+
+
+def test_previous_weight_correct_across_a_long_short_reversal() -> None:
+ """A position drifts from a long anchor down to a small residual long
+ (0.5 -> 0.1, no trade), then a trade flips it to short (-0.2,
+ `weight_change=-0.3`) -- `previous_weight` must read the drifted 0.10,
+ not the anchor 0.5, and the action must be `reverse_long_to_short`."""
+ index = pd.date_range("2024-01-01", periods=4, freq="D")
+ executed = pd.DataFrame({"AAA": [0.0, 0.5, 0.1, -0.2]}, index=index)
+ changes = pd.DataFrame({"AAA": [0.0, 0.5, 0.0, -0.3]}, index=index)
+ equity = pd.Series([100.0, 100.0, 100.0, 100.0], index=index)
+ prices = pd.DataFrame({"AAA": [11.0, 10.0, 6.0, 6.0]}, index=index)
+
+ trades = _build(executed, changes, equity, prices)
+
+ assert trades["previous_weight"].tolist() == [0.0, pytest.approx(0.1)]
+ assert trades["new_weight"].tolist() == [0.5, pytest.approx(-0.2)]
+ assert trades["side"].tolist() == ["buy", "sell"]
+ assert trades["action"].tolist() == ["entry_long", "reverse_long_to_short"]
+ assert np.allclose(
+ (trades["new_weight"] - trades["previous_weight"]).to_numpy(),
+ trades["weight_change"].to_numpy(),
+ )
+
+
+def test_previous_weight_correct_for_a_drift_compliance_forced_trade() -> None:
+ """`previous_weight` for a row where the drift-compliance LP forces a
+ correction must be the value organic drift actually pushed the
+ position to (the breach itself), not the previous row's own value --
+ reuses the exact numeric scenario `test_maximum_weight_breach_
+ correction_lands_next_row_never_same_row` (test_weight_drift.py)
+ already proves the underlying drift/correction mechanics for, this
+ time feeding `apply_weight_drift`'s own output straight into
+ `build_trade_log`, exactly like the real pipeline (`engine.py` passes
+ `accounting.executed_weights`/`accounting.weight_changes`, which ARE
+ `apply_weight_drift`'s two frames when drift is active)."""
+ from quantlab.backtesting.accounting import apply_weight_drift
+
+ dates = pd.date_range("2024-01-01", periods=5, freq="D")
+ executed = pd.DataFrame({"A": [0.0, 0.5, 0.5, 0.5, 0.5]}, index=dates)
+ asset_returns = pd.DataFrame({"A": [np.nan, 0.0, 1.0, 0.0, 0.0]}, index=dates)
+
+ drifted, trade_changes, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=0.6,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ breach_row = dates[3]
+ landed_row = dates[4]
+ assert bool(provenance.drift_compliance_pending.loc[breach_row, "A"])
+ assert bool(provenance.drift_compliance_forced.loc[landed_row, "A"])
+
+ equity = pd.Series(100.0, index=dates)
+ prices = pd.DataFrame({"A": [10.0] * 5}, index=dates)
+ trades = _build(
+ drifted,
+ trade_changes,
+ equity,
+ prices,
+ # `drifted` stands in for every decision-level diagnostic frame:
+ # nothing else is being attributed here, so this isolates
+ # drift_compliance as the sole adjustment reason.
+ executed_desired=drifted,
+ executed_constrained=drifted,
+ executed_signal_diag=drifted,
+ executed_allocated_diag=drifted,
+ executed_desired_diag=drifted,
+ executed_drift_compliance_forced=provenance.drift_compliance_forced,
+ executed_drift_compliance_pending=provenance.drift_compliance_pending,
+ )
+
+ landed_trade = trades[trades["timestamp"] == landed_row].iloc[0]
+ # The breach itself (organic drift, no trade landed yet) is never a
+ # trade-log row -- the first row for this symbol after the initial
+ # entry is the correction landing.
+ assert landed_trade["previous_weight"] == pytest.approx(
+ drifted.loc[breach_row, "A"]
+ )
+ assert landed_trade["new_weight"] == pytest.approx(0.6)
+ assert landed_trade["adjustment_reason_codes"] == "drift_compliance"
+ assert np.allclose(
+ (trades["new_weight"] - trades["previous_weight"]).to_numpy(),
+ trades["weight_change"].to_numpy(),
+ )
+
+
+def test_previous_weight_correct_for_a_maximum_turnover_deferred_catchup() -> None:
+ """`previous_weight` across a `maximum_turnover`-throttled, multi-row
+ catch-up must reflect each row's own true entering value -- reuses
+ `test_maximum_turnover_caps_an_anchor_catch_up_and_carries_the_
+ remainder`'s exact scenario (test_weight_drift.py), this time
+ verifying the trade log's own invariant across both the partial-
+ landing row and the remainder-landing row."""
+ from quantlab.backtesting.accounting import apply_weight_drift
+
+ # One extra leading flat (no-trade) row versus test_weight_drift.py's
+ # own version of this scenario -- gives the initial entry (itself
+ # subject to the turnover cap, per that test's own docstring) a valid
+ # prior-period reference price; every index below is shifted by +1
+ # accordingly (drift shock at 21, schedule at 22, etc.).
+ n = 41
+ dates = pd.date_range("2024-01-01", periods=n, freq="D")
+ executed = pd.DataFrame(
+ {"A": [0.0] + [0.5] * (n - 1), "B": [0.0] + [0.5] * (n - 1)}, index=dates
+ )
+ asset_returns = pd.DataFrame(
+ {"A": [np.nan] + [0.0] * (n - 1), "B": [np.nan] + [0.0] * (n - 1)},
+ index=dates,
+ )
+ asset_returns.loc[dates[21], "A"] = 0.20
+ rebalance_date = pd.DataFrame(False, index=dates, columns=["A", "B"])
+ rebalance_date.loc[dates[22]] = True
+ cap = 0.05
+
+ drifted, trade_changes, _provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ rebalance_date=rebalance_date,
+ maximum_turnover=cap,
+ )
+ equity = pd.Series(100.0, index=dates)
+ prices = pd.DataFrame({"A": [10.0] * n, "B": [10.0] * n}, index=dates)
+ trades = _build(drifted, trade_changes, equity, prices)
+
+ # B also has a real row on both these dates (the schedule flag marks
+ # both columns "fresh" even though B's own target doesn't numerically
+ # change; B's actual weight still drifted slightly, since A's outsized
+ # gain grows total equity and so shrinks B's share of it) -- select A
+ # specifically rather than assuming a single row per date.
+ partial_row = trades[(trades["timestamp"] == dates[22]) & (trades["symbol"] == "A")]
+ remainder_row = trades[
+ (trades["timestamp"] == dates[23]) & (trades["symbol"] == "A")
+ ]
+ assert len(partial_row) == 1
+ assert len(remainder_row) == 1
+ # The value organic drift ACTUALLY pushed A to entering this row (0.5 *
+ # 1.2 / 1.1), not 0.5 -- this is precisely the discrepancy this whole
+ # fix is about: the previous ROW's own reported value (0.5, before the
+ # shock landed) differs from what genuinely entered this row.
+ assert partial_row.iloc[0]["previous_weight"] == pytest.approx(0.5 * 1.2 / 1.1)
+ assert partial_row.iloc[0]["new_weight"] == pytest.approx(0.5204545454545454)
+ assert remainder_row.iloc[0]["previous_weight"] == pytest.approx(0.5204545454545454)
+ assert remainder_row.iloc[0]["new_weight"] == pytest.approx(0.5, abs=1e-9)
+ assert np.allclose(
+ (trades["new_weight"] - trades["previous_weight"]).to_numpy(),
+ trades["weight_change"].to_numpy(),
+ )
+
+
+def test_new_minus_previous_always_equals_change_across_a_full_drift_run() -> None:
+ """Property-style regression guard: across every row a broader,
+ multi-asset drift scenario produces (mixed drift, scheduled
+ rebalances, and a maximum_weight breach/correction all in one run),
+ `new_weight - previous_weight == weight_change` must hold for EVERY
+ trade-log row, not just the hand-picked ones the scenario-specific
+ tests above check."""
+ from quantlab.backtesting.accounting import apply_weight_drift
+
+ # A leading flat (no-trade) row (see the other tests above) plus one
+ # guaranteed large shock mixed into the random walk -- the fuzz alone
+ # can't be relied on to reliably breach maximum_weight for every seed,
+ # but the invariant below must be checked across a real correction,
+ # not just ordinary small-drift rows.
+ n = 61
+ dates = pd.date_range("2024-01-01", periods=n, freq="D")
+ rng = np.random.default_rng(3)
+ a_returns = np.concatenate([[np.nan], rng.normal(0.0, 0.02, n - 1)])
+ b_returns = np.concatenate([[np.nan], rng.normal(0.0, 0.015, n - 1)])
+ a_returns[15] = 0.3
+ executed = pd.DataFrame(
+ {"A": [0.0] + [0.5] * (n - 1), "B": [0.0] + [0.5] * (n - 1)},
+ index=dates,
+ )
+ asset_returns = pd.DataFrame({"A": a_returns, "B": b_returns}, index=dates)
+ rebalance_date = pd.DataFrame(False, index=dates, columns=["A", "B"])
+ rebalance_date.iloc[::10] = True
+
+ drifted, trade_changes, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=0.55,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ rebalance_date=rebalance_date,
+ )
+ assert provenance.drift_compliance_forced.to_numpy().any(), (
+ "expected at least one drift-compliance correction in this "
+ "scenario -- otherwise the invariant check below doesn't "
+ "actually exercise that path"
+ )
+
+ equity = pd.Series(100.0, index=dates)
+ prices = pd.DataFrame({"A": [10.0] * n, "B": [10.0] * n}, index=dates)
+ trades = _build(
+ drifted,
+ trade_changes,
+ equity,
+ prices,
+ executed_desired=drifted,
+ executed_constrained=drifted,
+ executed_signal_diag=drifted,
+ executed_allocated_diag=drifted,
+ executed_desired_diag=drifted,
+ executed_drift_compliance_forced=provenance.drift_compliance_forced,
+ executed_drift_compliance_pending=provenance.drift_compliance_pending,
+ )
+
+ assert len(trades) > 0
+ assert np.allclose(
+ (trades["new_weight"] - trades["previous_weight"]).to_numpy(),
+ trades["weight_change"].to_numpy(),
+ )
+ # side must always agree with the sign of weight_change.
+ assert ((trades["side"] == "buy") == (trades["weight_change"] > 0)).all()
+
+
+def test_trade_log_reason_frames_must_be_all_or_nothing() -> None:
+ executed, changes, equity, prices = _inputs()
+
+ with pytest.raises(BacktestError, match="all together or not at all"):
+ _build(executed, changes, equity, prices, executed_desired=executed)
+
+
+def test_trade_log_reason_frame_axes_must_match_executed_weights() -> None:
+ executed, changes, equity, prices = _inputs()
+ mismatched = executed.rename(columns={"AAA": "BBB"})
+ reason_kwargs = {
+ "executed_desired": executed,
+ "executed_constrained": executed,
+ "executed_signal_diag": executed,
+ "executed_allocated_diag": executed,
+ "executed_desired_diag": mismatched,
+ }
+
+ with pytest.raises(BacktestError, match="executed_desired_diag"):
+ _build(executed, changes, equity, prices, **reason_kwargs)
+
+
+def test_trade_log_populates_reason_when_all_frames_are_supplied() -> None:
+ """A minimal end-to-end sanity check that supplying the reason frames
+ actually reaches _classify_reason -- full pipeline scenarios (turnover
+ cap, vol-targeting, tradability, ruin) live in test_trade_reasons.py."""
+ executed, changes, equity, prices = _inputs()
+ # The fill at index 1 goes 0.0 -> 1.0; make the desired target agree
+ # (no constraint) and the signal change since the (implicit, flat)
+ # prior rebalance, so this resolves to a clean strategy_signal fill.
+ desired = executed.copy()
+ signal = pd.DataFrame({"AAA": [0.0, 1.0, 1.0]}, index=executed.index)
+ reason_kwargs = {
+ "executed_desired": desired,
+ "executed_constrained": desired,
+ "executed_signal_diag": signal,
+ "executed_allocated_diag": desired,
+ "executed_desired_diag": desired,
+ }
+
+ trades = _build(executed, changes, equity, prices, **reason_kwargs)
+
+ assert trades["trigger_reason_code"].tolist() == ["strategy_signal"]
+ assert trades["trigger_reason_detail_code"].tolist() == [None]
+ assert trades["trigger_reason_details"].iloc[0] is not None
+ assert trades["adjustment_reason_codes"].tolist() == [None]
+
+
+def _base_reason_kwargs(
+ executed: pd.DataFrame,
+) -> dict[str, pd.DataFrame]:
+ desired = executed.copy()
+ signal = pd.DataFrame({"AAA": [0.0, 1.0, 1.0]}, index=executed.index)
+ return {
+ "executed_desired": desired,
+ "executed_constrained": desired,
+ "executed_signal_diag": signal,
+ "executed_allocated_diag": desired,
+ "executed_desired_diag": desired,
+ }
+
+
+def test_trade_log_strategy_reason_frames_must_be_supplied_together() -> None:
+ executed, changes, equity, prices = _inputs()
+
+ with pytest.raises(BacktestError, match="must be supplied all together"):
+ _build(
+ executed,
+ changes,
+ equity,
+ prices,
+ **_base_reason_kwargs(executed),
+ executed_strategy_reason_code=executed,
+ )
+
+
+def test_trade_log_strategy_reason_frames_require_the_base_reason_frames() -> None:
+ executed, changes, equity, prices = _inputs()
+ strategy_code = pd.DataFrame(
+ {"AAA": [None, "oversold_entry", None]}, index=executed.index
+ )
+
+ with pytest.raises(BacktestError, match="requires the reason-attribution frames"):
+ _build(
+ executed,
+ changes,
+ equity,
+ prices,
+ executed_strategy_reason_code=strategy_code,
+ executed_strategy_reason_details=strategy_code,
+ )
+
+
+def test_trade_log_populates_strategy_specific_reason_when_supplied() -> None:
+ executed, changes, equity, prices = _inputs()
+ strategy_code = pd.DataFrame(
+ {"AAA": [None, "oversold_entry", None]}, index=executed.index, dtype=object
+ )
+ strategy_details = pd.DataFrame(
+ {"AAA": [None, "z-score -2.5000 crossed entry threshold -2.0000", None]},
+ index=executed.index,
+ dtype=object,
+ )
+
+ trades = _build(
+ executed,
+ changes,
+ equity,
+ prices,
+ **_base_reason_kwargs(executed),
+ executed_strategy_reason_code=strategy_code,
+ executed_strategy_reason_details=strategy_details,
+ )
+
+ assert trades["trigger_reason_code"].tolist() == ["strategy_signal"]
+ assert trades["trigger_reason_detail_code"].tolist() == ["oversold_entry"]
+ assert trades["trigger_reason_details"].iloc[0] == (
+ "signal 0.0000 -> 1.0000 since last rebalance; "
+ "z-score -2.5000 crossed entry threshold -2.0000"
+ )
+
+
+def test_trade_log_constraint_provenance_requires_the_base_reason_frames() -> None:
+ executed, changes, equity, prices = _inputs()
+ touch = _touch(
+ touched=pd.DataFrame({"AAA": [False, True, False]}, index=executed.index),
+ before=executed,
+ after=executed,
+ )
+
+ with pytest.raises(BacktestError, match="requires the reason-attribution frames"):
+ _build(
+ executed,
+ changes,
+ equity,
+ prices,
+ constraint_provenance={"maximum_weight": touch},
+ )
+
+
+def test_trade_log_constraint_provenance_rejects_mismatched_axes() -> None:
+ executed, changes, equity, prices = _inputs()
+ mismatched = executed.rename(columns={"AAA": "BBB"})
+ touch = _touch(touched=mismatched, before=executed, after=executed)
+
+ pattern = r"constraint_provenance\['maximum_weight'\]"
+ with pytest.raises(BacktestError, match=pattern):
+ _build(
+ executed,
+ changes,
+ equity,
+ prices,
+ **_base_reason_kwargs(executed),
+ constraint_provenance={"maximum_weight": touch},
+ )
+
+
+def test_trade_log_populates_precise_constraint_code_when_provenance_is_supplied() -> (
+ None
+):
+ executed, changes, equity, prices = _inputs()
+ # Row 1 (the only fill) landed exactly on a post-constraint target
+ # (1.0) that ConstraintSet itself already trimmed from the desired
+ # 1.2 -- supplying provenance must yield the precise constraint name.
+ desired = pd.DataFrame({"AAA": [0.0, 1.2, 1.0]}, index=executed.index)
+ constrained = pd.DataFrame({"AAA": [0.0, 1.0, 1.0]}, index=executed.index)
+ reason_kwargs = _base_reason_kwargs(executed)
+ reason_kwargs["executed_desired"] = desired
+ reason_kwargs["executed_constrained"] = constrained
+ touched = pd.DataFrame({"AAA": [False, True, False]}, index=executed.index)
+ touch = _touch(touched=touched, before=desired, after=constrained)
+
+ trades = _build(
+ executed,
+ changes,
+ equity,
+ prices,
+ **reason_kwargs,
+ constraint_provenance={"maximum_weight": touch},
+ )
+
+ assert trades["adjustment_reason_codes"].tolist() == ["maximum_weight"]
+ assert trades["adjustment_reason_details"].iloc[0] == (
+ "maximum_weight: 1.2000 -> 1.0000"
+ )
+
+
+@pytest.mark.parametrize(
+ ("previous", "new", "expected"),
+ [
+ (0.0, 0.5, "entry_long"),
+ (0.0, -0.5, "entry_short"),
+ (0.5, 0.0, "exit_long"),
+ (-0.5, 0.0, "exit_short"),
+ (0.5, -0.5, "reverse_long_to_short"),
+ (-0.5, 0.5, "reverse_short_to_long"),
+ (0.3, 0.6, "increase_long"),
+ (0.6, 0.3, "reduce_long"),
+ (-0.3, -0.6, "increase_short"),
+ (-0.6, -0.3, "reduce_short"),
+ (0.6e-12, -0.6e-12, "flat_to_flat"),
+ ],
+)
+def test_classify_action(previous: float, new: float, expected: str) -> None:
+ assert _classify_action(previous, new) == expected
+
+
@pytest.mark.parametrize(
("name", "value"),
[
@@ -112,3 +658,489 @@ def per_symbol_cost(
def test_trade_log_rejects_invalid_per_symbol_slippage() -> None:
with pytest.raises(BacktestError, match=r"slippage costs.*non-negative"):
_build(*_inputs(), slippage_model=_NegativeSlippage())
+
+
+class _ReasonKwargs(TypedDict, total=False):
+ new: float
+ previous: float
+ executed_desired: float
+ executed_desired_prev: float
+ executed_constrained: float
+ signal_now: float
+ signal_prev: float
+ allocated_now: float
+ allocated_prev: float
+ desired_diag_now: float
+ desired_diag_prev: float
+
+
+def _reason_kwargs(**overrides: Any) -> _ReasonKwargs:
+ """A baseline where nothing looks changed anywhere in the pipeline --
+ each test overrides only the specific comparison it wants to exercise,
+ so a passing test proves *that* branch fired, not an accidental
+ combination of several at once."""
+ base: dict[str, Any] = {
+ "new": 0.5,
+ "previous": 0.5,
+ "executed_desired": 0.5,
+ "executed_desired_prev": 0.5,
+ "executed_constrained": 0.5,
+ "signal_now": 1.0,
+ "signal_prev": 1.0,
+ "allocated_now": 0.5,
+ "allocated_prev": 0.5,
+ "desired_diag_now": 0.5,
+ "desired_diag_prev": 0.5,
+ }
+ base.update(overrides)
+ return cast(_ReasonKwargs, base)
+
+
+def test_classify_reason_contributing_constraint() -> None:
+ reason = _classify_reason(
+ **_reason_kwargs(),
+ contributing_constraints=["maximum_weight"],
+ constraint_before={"maximum_weight": 0.7892},
+ constraint_after={"maximum_weight": 0.7000},
+ )
+ assert reason.adjustment_codes == "maximum_weight"
+ assert reason.adjustment_details == "maximum_weight: 0.7892 -> 0.7000"
+ assert reason.trigger_code is None
+
+
+def test_classify_reason_redistribution_detail_text_is_stage_specific() -> None:
+ """Each redistribution-capable constraint gets ITS OWN honest text --
+ never a generic "another position was capped" sentence borrowed from
+ maximum_weight."""
+ for base_name, expected_fragment in (
+ ("maximum_weight", "another position was capped"),
+ ("minimum_weight", "dust/small positions were removed"),
+ ("maximum_positions", "dropped to satisfy maximum_positions"),
+ ):
+ name = f"{base_name}_redistribution"
+ reason = _classify_reason(
+ **_reason_kwargs(),
+ contributing_constraints=[name],
+ constraint_before={name: 0.3761},
+ constraint_after={name: 0.3770},
+ )
+ assert reason.adjustment_codes == name
+ assert expected_fragment in (reason.adjustment_details or "")
+ # Never implies the asset itself exceeded a threshold.
+ assert "0.3761 -> 0.3770" in (reason.adjustment_details or "")
+
+
+def test_classify_reason_tradability_touched() -> None:
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.3, executed_constrained=0.5),
+ tradability_touched=True,
+ )
+ assert reason.adjustment_codes == "tradability"
+ assert "closed" in (reason.adjustment_details or "")
+
+
+def test_classify_reason_tradability_compliance_limited_has_distinct_text() -> None:
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.3, executed_constrained=0.5),
+ tradability_touched=True,
+ tradability_compliance_limited=True,
+ )
+ assert reason.adjustment_codes == "tradability"
+ assert "feasibility limit" in (reason.adjustment_details or "")
+
+
+def test_classify_reason_turnover_touched() -> None:
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.3, executed_constrained=0.5),
+ turnover_touched=True,
+ turnover_actively_limited=True,
+ )
+ assert reason.adjustment_codes == "turnover_cap"
+ assert "turnover-capped" in (reason.adjustment_details or "")
+
+
+def test_classify_reason_turnover_touched_catchup_has_distinct_text() -> None:
+ """A row still catching up an earlier episode's debt, but not itself
+ actively capped, must say so -- not claim it's being capped today."""
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.3, executed_constrained=0.5),
+ turnover_touched=True,
+ turnover_actively_limited=False,
+ )
+ assert reason.adjustment_codes == "turnover_cap"
+ assert "previously deferred" in (reason.adjustment_details or "")
+
+
+def test_classify_reason_multi_cause_adjustment_constraint_and_turnover() -> None:
+ """The core bug this whole redesign fixes: a constraint AND turnover_
+ cap acting on the SAME trade must both be visible, in ADJUSTMENT_ORDER
+ (constraints before turnover_cap), never one masking the other."""
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.3, executed_constrained=0.5),
+ contributing_constraints=["maximum_weight"],
+ constraint_before={"maximum_weight": 0.9},
+ constraint_after={"maximum_weight": 0.5},
+ turnover_touched=True,
+ turnover_actively_limited=True,
+ )
+ assert reason.adjustment_codes == "maximum_weight+turnover_cap"
+ assert "maximum_weight: 0.9000 -> 0.5000" in (reason.adjustment_details or "")
+ assert "turnover_cap" in (reason.adjustment_details or "")
+
+
+def test_classify_reason_multi_cause_adjustment_constraint_and_tradability() -> None:
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.3, executed_constrained=0.5),
+ contributing_constraints=["maximum_weight"],
+ constraint_before={"maximum_weight": 0.9},
+ constraint_after={"maximum_weight": 0.5},
+ tradability_touched=True,
+ )
+ assert reason.adjustment_codes == "maximum_weight+tradability"
+
+
+def test_classify_reason_trigger_and_adjustment_coexist() -> None:
+ """A strategy-driven entry that is ALSO capped by a constraint must
+ show BOTH -- the original masking bug this redesign fixes."""
+ reason = _classify_reason(
+ **_reason_kwargs(
+ new=0.3,
+ executed_constrained=0.5,
+ signal_now=1.0,
+ signal_prev=0.0,
+ ),
+ strategy_detail_code="oversold_entry",
+ contributing_constraints=["maximum_weight"],
+ constraint_before={"maximum_weight": 0.9},
+ constraint_after={"maximum_weight": 0.5},
+ )
+ assert reason.trigger_code == "strategy_signal"
+ assert reason.trigger_detail_code == "oversold_entry"
+ assert reason.adjustment_codes == "maximum_weight"
+
+
+def test_classify_reason_strategy_signal_wins_over_downstream_changes() -> None:
+ """Even when the allocator/desired-target ALSO changed (a signal change
+ always cascades downstream), strategy_signal must be reported -- the
+ most upstream, most specific cause -- not portfolio_rebalance or
+ volatility_target_adjustment."""
+ reason = _classify_reason(
+ **_reason_kwargs(
+ signal_now=1.0,
+ signal_prev=0.0,
+ allocated_now=0.6,
+ allocated_prev=0.4,
+ desired_diag_now=0.6,
+ desired_diag_prev=0.4,
+ )
+ )
+ assert (reason.trigger_code, reason.trigger_detail_code) == (
+ "strategy_signal",
+ None,
+ )
+
+
+def test_classify_reason_portfolio_rebalance() -> None:
+ reason = _classify_reason(**_reason_kwargs(allocated_now=0.6, allocated_prev=0.4))
+ assert (reason.trigger_code, reason.trigger_detail_code) == (
+ "portfolio_rebalance",
+ None,
+ )
+
+
+def test_classify_reason_volatility_target_adjustment() -> None:
+ reason = _classify_reason(
+ **_reason_kwargs(desired_diag_now=0.6, desired_diag_prev=0.4)
+ )
+ assert (reason.trigger_code, reason.trigger_detail_code) == (
+ "volatility_target_adjustment",
+ None,
+ )
+
+
+def test_classify_reason_position_rescaling_when_target_still_drifting() -> None:
+ """No trigger, no known adjustment layer, but the pre-turnover target
+ itself is still drifting row-over-row -- the pairs_trading price/beta
+ residual case."""
+ reason = _classify_reason(
+ **_reason_kwargs(
+ new=0.5, previous=0.3, executed_desired=0.55, executed_desired_prev=0.5
+ )
+ )
+ assert reason.adjustment_codes == "position_rescaling"
+ assert reason.trigger_code is None
+
+
+def test_classify_reason_deferred_catchup_when_target_is_static() -> None:
+ """Nothing upstream changed since the last rebalance, the pre-turnover
+ target has been STATIC, yet the position still moved -- a turnover-
+ cap/tradability shortfall completing with no real cause identifiable
+ (genuinely unknown, not one of the real provenance signals)."""
+ reason = _classify_reason(**_reason_kwargs(new=0.5, previous=0.3))
+ assert reason.adjustment_codes == "deferred_catchup"
+ assert reason.trigger_code is None
+
+
+def test_classify_reason_position_rescaling_never_fires_alongside_a_trigger() -> None:
+ """Strict fallback guard (point 1): a value combination that would
+ satisfy position_rescaling's own condition must still be preempted by
+ a real trigger -- position_rescaling is reached ONLY via the `elif`
+ after trigger is confirmed None."""
+ reason = _classify_reason(
+ **_reason_kwargs(
+ new=0.5,
+ previous=0.3,
+ executed_desired=0.55,
+ executed_desired_prev=0.5,
+ signal_now=1.0,
+ signal_prev=0.0,
+ )
+ )
+ assert reason.trigger_code == "strategy_signal"
+ assert reason.adjustment_codes is None
+
+
+def test_classify_reason_position_rescaling_never_fires_alongside_real_adjustment() -> (
+ None
+):
+ """Strict fallback guard: a real adjustment layer (here, a contributing
+ constraint) must preempt position_rescaling even though the drifting-
+ target condition also holds."""
+ reason = _classify_reason(
+ **_reason_kwargs(
+ new=0.3,
+ previous=0.3,
+ executed_constrained=0.5,
+ executed_desired=0.55,
+ executed_desired_prev=0.5,
+ ),
+ contributing_constraints=["maximum_weight"],
+ constraint_before={"maximum_weight": 0.9},
+ constraint_after={"maximum_weight": 0.5},
+ )
+ assert reason.adjustment_codes == "maximum_weight"
+ assert "position_rescaling" not in reason.adjustment_codes
+
+
+def test_classify_reason_unknown_when_nothing_explains_the_row() -> None:
+ reason = _classify_reason(**_reason_kwargs())
+ assert reason == TradeReason(
+ trigger_code="unknown",
+ trigger_detail_code=None,
+ trigger_details="no upstream driver identified",
+ adjustment_codes=None,
+ adjustment_details=None,
+ )
+
+
+def test_classify_reason_strategy_detail_code_overrides_the_generic_text() -> None:
+ """reason_detail_code becomes the precise code, but reason_details
+ keeps the generic "signal X -> Y" text WITH the strategy-specific
+ text appended, never the specific text alone."""
+ reason = _classify_reason(
+ **_reason_kwargs(
+ signal_now=1.0,
+ signal_prev=0.0,
+ strategy_detail_code="oversold_entry",
+ strategy_details="z-score -2.5000 crossed entry threshold -2.0000",
+ )
+ )
+ assert (reason.trigger_code, reason.trigger_detail_code) == (
+ "strategy_signal",
+ "oversold_entry",
+ )
+ assert reason.trigger_details == (
+ "signal 0.0000 -> 1.0000 since last rebalance; "
+ "z-score -2.5000 crossed entry threshold -2.0000"
+ )
+
+
+def test_classify_reason_strategy_signal_without_detail_code_is_unchanged() -> None:
+ reason = _classify_reason(**_reason_kwargs(signal_now=1.0, signal_prev=0.0))
+ assert (reason.trigger_code, reason.trigger_detail_code) == (
+ "strategy_signal",
+ None,
+ )
+ assert reason.trigger_details is not None
+ assert "since last rebalance" in reason.trigger_details
+
+
+def test_classify_reason_forced_liquidation_overrides_every_other_adjustment() -> None:
+ """Once ruined, no other layer's specific clip value still explains
+ the executed weight -- forced_liquidation replaces the whole
+ adjustment list rather than composing with it."""
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.0, previous=0.5, executed_constrained=0.5),
+ contributing_constraints=["maximum_weight"],
+ constraint_before={"maximum_weight": 0.9},
+ constraint_after={"maximum_weight": 0.5},
+ turnover_touched=True,
+ turnover_actively_limited=True,
+ forced_liquidation=True,
+ )
+ assert reason.adjustment_codes == "forced_liquidation"
+
+
+def test_classify_reason_forced_liquidation_never_overrides_trigger() -> None:
+ """The strategy's own wish (trigger) survives even when the executed
+ weight was forced to zero -- the two are independent concepts."""
+ reason = _classify_reason(
+ **_reason_kwargs(
+ new=0.0,
+ previous=0.5,
+ executed_constrained=0.5,
+ signal_now=1.0,
+ signal_prev=0.5,
+ ),
+ forced_liquidation=True,
+ )
+ assert reason.trigger_code == "strategy_signal"
+ assert reason.adjustment_codes == "forced_liquidation"
+
+
+def test_classify_reason_drift_compliance_overrides_ordinary_constraints() -> None:
+ """A row whose magnitude comes from the drift-compliance LP is NOT
+ decision-pipeline-driven at all, so it overrides an ordinary
+ constraint adjustment that would otherwise also apply to the same
+ row -- the constraint's own before/after clip value becomes moot."""
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.3, previous=0.5, executed_constrained=0.5),
+ contributing_constraints=["maximum_weight"],
+ constraint_before={"maximum_weight": 0.9},
+ constraint_after={"maximum_weight": 0.5},
+ drift_compliance_forced=True,
+ )
+ assert reason.adjustment_codes == "drift_compliance"
+
+
+def test_classify_reason_drift_compliance_pending_is_its_own_code() -> None:
+ """A still-unresolved drift breach (responsible symbol/group still
+ untradable) gets its own distinct code, not conflated with a landed
+ correction."""
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.3, previous=0.3), drift_compliance_pending=True
+ )
+ assert reason.adjustment_codes == "drift_compliance_pending"
+
+
+def test_classify_reason_stop_loss_overrides_drift_compliance() -> None:
+ """A stop-loss/take-profit breach detected on the drift-corrected
+ weight is a still more specific, more severe cause and wins over a
+ drift-compliance adjustment on the same row."""
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.0, previous=0.4),
+ drift_compliance_forced=True,
+ stop_loss_triggered=True,
+ )
+ assert reason.adjustment_codes == "stop_loss"
+
+
+def test_classify_reason_forced_liquidation_overrides_drift_compliance() -> None:
+ """Portfolio ruin is more severe than a drift-compliance correction."""
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.0, previous=0.4),
+ drift_compliance_forced=True,
+ forced_liquidation=True,
+ )
+ assert reason.adjustment_codes == "forced_liquidation"
+
+
+def test_classify_reason_every_branch_emits_only_adjustment_order_codes() -> None:
+ """Exhaustive sweep of every branch of _classify_reason (point 3):
+ every code it can ever emit must be a member of ADJUSTMENT_ORDER."""
+ scenarios: list[dict[str, Any]] = [
+ {
+ "contributing_constraints": ["maximum_weight"],
+ "constraint_before": {"maximum_weight": 0.9},
+ "constraint_after": {"maximum_weight": 0.5},
+ },
+ {
+ "contributing_constraints": ["maximum_weight_redistribution"],
+ "constraint_before": {"maximum_weight_redistribution": 0.3},
+ "constraint_after": {"maximum_weight_redistribution": 0.31},
+ },
+ {"tradability_touched": True},
+ {"tradability_touched": True, "tradability_compliance_limited": True},
+ {"turnover_touched": True, "turnover_actively_limited": True},
+ {"turnover_touched": True, "turnover_actively_limited": False},
+ {"stop_loss_triggered": True},
+ {"take_profit_triggered": True},
+ {"drift_compliance_forced": True},
+ {"drift_compliance_pending": True},
+ {"forced_liquidation": True},
+ ]
+ for extra in scenarios:
+ reason = _classify_reason(
+ **_reason_kwargs(new=0.3, previous=0.3, executed_constrained=0.5), **extra
+ )
+ if reason.adjustment_codes is not None:
+ for code in reason.adjustment_codes.split("+"):
+ assert code in ADJUSTMENT_ORDER
+
+ fallback_reason = _classify_reason(
+ **_reason_kwargs(
+ new=0.5, previous=0.3, executed_desired=0.55, executed_desired_prev=0.5
+ )
+ )
+ assert fallback_reason.adjustment_codes in ADJUSTMENT_ORDER
+ catchup_reason = _classify_reason(**_reason_kwargs(new=0.5, previous=0.3))
+ assert catchup_reason.adjustment_codes in ADJUSTMENT_ORDER
+
+
+# --------------------------------------------------------------------------- #
+# serialize_adjustment_codes / parse_adjustment_codes
+# --------------------------------------------------------------------------- #
+def test_serialize_adjustment_codes_orders_by_pipeline_order_not_input_order() -> None:
+ assert (
+ serialize_adjustment_codes(["turnover_cap", "maximum_weight", "tradability"])
+ == "maximum_weight+tradability+turnover_cap"
+ )
+
+
+def test_serialize_adjustment_codes_single_name_has_no_separator() -> None:
+ assert serialize_adjustment_codes(["maximum_weight"]) == "maximum_weight"
+
+
+def test_serialize_adjustment_codes_deduplicates() -> None:
+ assert (
+ serialize_adjustment_codes(["maximum_weight", "maximum_weight", "tradability"])
+ == "maximum_weight+tradability"
+ )
+
+
+def test_serialize_adjustment_codes_covers_every_adjustment_order_entry() -> None:
+ """Round-trips the full canonical order in one call as a sanity check
+ that ADJUSTMENT_ORDER and serialize_adjustment_codes stay in sync."""
+ assert serialize_adjustment_codes(ADJUSTMENT_ORDER) == "+".join(ADJUSTMENT_ORDER)
+
+
+def test_serialize_adjustment_codes_rejects_unknown_code() -> None:
+ with pytest.raises(BacktestError, match="Unknown adjustment code"):
+ serialize_adjustment_codes(["not_a_real_code"])
+
+
+def test_parse_adjustment_codes_round_trips_serialize() -> None:
+ codes = ["maximum_gross_exposure", "long_only"]
+ assert parse_adjustment_codes(serialize_adjustment_codes(codes)) == [
+ "long_only",
+ "maximum_gross_exposure",
+ ]
+
+
+def test_parse_adjustment_codes_single_token_has_nothing_to_split() -> None:
+ assert parse_adjustment_codes("tradability") == ["tradability"]
+
+
+def test_parse_adjustment_codes_strict_rejects_unknown_code() -> None:
+ with pytest.raises(BacktestError, match="Unknown adjustment code"):
+ parse_adjustment_codes("not_a_real_code")
+
+
+def test_parse_adjustment_codes_permissive_preserves_unknown_code() -> None:
+ assert parse_adjustment_codes("not_a_real_code", strict=False) == [
+ "not_a_real_code"
+ ]
+ assert parse_adjustment_codes("maximum_weight+not_a_real_code", strict=False) == [
+ "maximum_weight",
+ "not_a_real_code",
+ ]
diff --git a/tests/unit/test_trade_reasons.py b/tests/unit/test_trade_reasons.py
new file mode 100644
index 0000000..f50b0d4
--- /dev/null
+++ b/tests/unit/test_trade_reasons.py
@@ -0,0 +1,847 @@
+"""End-to-end reason-attribution scenarios via the real BacktestEngine.
+
+Unlike test_trade_log.py's direct _classify_reason unit tests (which pin
+down the classifier's priority logic in isolation), these tests exercise
+the actual engine-side plumbing added to feed it: capturing signals/
+allocated/desired-target, resampling them to rebalance dates, and aligning
+them to accounting.executed_weights via the same executed_weights() shift
+run_accounting uses internally. A scripted, deterministic BaseStrategy
+subclass is used throughout instead of a registered strategy name, for
+exact control over when the signal changes -- passed directly to
+BacktestEngine.run() (which takes strategy/allocator as instances, not
+resolved from config), matching the pattern already established by
+test_reporting_hardening.py.
+"""
+
+from __future__ import annotations
+
+from typing import cast
+
+import numpy as np
+import pandas as pd
+import pytest
+from tests.conftest import make_ohlcv
+
+from quantlab.backtesting.engine import BacktestEngine
+from quantlab.config import ExperimentConfig
+from quantlab.execution.execution_model import ExecutionModel
+from quantlab.portfolio.allocator import (
+ EqualWeightAllocator,
+ InverseVolatilityAllocator,
+ build_allocator,
+)
+from quantlab.portfolio.rebalancing import rebalance_dates
+from quantlab.strategies.base import BaseStrategy, SignalReasons
+from quantlab.strategies.mean_reversion import MeanReversionStrategy
+
+
+class _ScriptedStrategy(BaseStrategy):
+ """Returns a hand-specified signal path, ignoring the market data.
+
+ ``schedule`` maps a symbol to its full signal path (one value per row
+ of whatever ``data`` the engine hands it, aligned positionally) --
+ lets a test dictate exactly which date a signal changes, rather than
+ reverse-engineering a real strategy's parameters to do it indirectly.
+ """
+
+ name = "scripted"
+
+ def __init__(self, schedule: dict[str, list[float]]) -> None:
+ self.schedule = schedule
+
+ def generate_signals(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> pd.DataFrame:
+ prices = self._prices(data)
+ signals = pd.DataFrame(0.0, index=prices.index, columns=prices.columns)
+ for symbol, path in self.schedule.items():
+ signals[symbol] = path
+ return self._validate_signals(signals, prices)
+
+
+class _ScriptedStrategyWithReasons(BaseStrategy):
+ """Like ``_ScriptedStrategy``, but also implements ``explain_signals()``
+ with a hand-specified per-row reason schedule.
+
+ Lets a test dictate exactly which RAW row carries the "true"
+ transition reason, independent of which row a later rebalance/
+ execution step ends up consuming it on -- the crux of the alignment
+ fix under test.
+ """
+
+ name = "scripted_with_reasons"
+
+ def __init__(
+ self,
+ schedule: dict[str, list[float]],
+ reason_schedule: dict[str, list[str | None]],
+ ) -> None:
+ self.schedule = schedule
+ self.reason_schedule = reason_schedule
+
+ def generate_signals(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> pd.DataFrame:
+ prices = self._prices(data)
+ signals = pd.DataFrame(0.0, index=prices.index, columns=prices.columns)
+ for symbol, path in self.schedule.items():
+ signals[symbol] = path
+ return self._validate_signals(signals, prices)
+
+ def explain_signals(
+ self, data: pd.DataFrame, features: pd.DataFrame | None = None
+ ) -> SignalReasons:
+ prices = self._prices(data)
+ detail_code = np.full(prices.shape, None, dtype=object)
+ details = np.full(prices.shape, None, dtype=object)
+ for symbol, path in self.reason_schedule.items():
+ column_index = prices.columns.get_loc(symbol)
+ for row_index, code in enumerate(path):
+ if code is not None:
+ detail_code[row_index, column_index] = code
+ details[row_index, column_index] = f"scripted: {code}"
+ return self._validate_signal_reasons(
+ pd.DataFrame(
+ detail_code, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ pd.DataFrame(
+ details, index=prices.index, columns=prices.columns, dtype=object
+ ),
+ prices,
+ )
+
+
+def _config(**portfolio_overrides: object) -> ExperimentConfig:
+ return ExperimentConfig.from_dict(
+ {
+ "experiment_name": "trade_reasons",
+ "data": {
+ "instruments": [{"symbol": "A", "source": "csv", "calendar": "XNYS"}],
+ "start_date": "2020-01-01",
+ "end_date": "2020-03-01",
+ },
+ "strategy": {"name": "buy_and_hold"}, # unused: an instance is passed
+ "portfolio": {"allocator": "equal_weight", **portfolio_overrides},
+ "execution": {
+ "commission_bps": 0.0,
+ "spread_bps": 0.0,
+ "slippage_bps": 0.0,
+ },
+ "backtest": {"initial_capital": 100_000},
+ }
+ )
+
+
+def _run(schedule: dict[str, list[float]], config: ExperimentConfig) -> pd.DataFrame:
+ n = len(next(iter(schedule.values())))
+ data = make_ohlcv("A", [100.0] * n, start="2020-01-01")
+ result = BacktestEngine().run(
+ data,
+ _ScriptedStrategy(schedule),
+ EqualWeightAllocator(),
+ ExecutionModel.from_config(config.execution),
+ config,
+ )
+ return result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+
+
+def _run_strategy(
+ strategy: BaseStrategy, prices: list[float], config: ExperimentConfig
+) -> pd.DataFrame:
+ data = make_ohlcv("A", prices, start="2020-01-01")
+ result = BacktestEngine().run(
+ data,
+ strategy,
+ EqualWeightAllocator(),
+ ExecutionModel.from_config(config.execution),
+ config,
+ )
+ return result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+
+
+def test_turnover_cap_then_deferred_catchup_on_the_same_entry() -> None:
+ """Signal jumps 0 -> 1 and stays there; a tight maximum_turnover (0.5)
+ forces the entry to land in two exact half-steps (0.5 then 1.0) instead
+ of one -- round numbers (0.5 x 2 = 1.0 exactly) sidestep float-precision
+ flakiness. Under the trigger/adjustment model, BOTH fills carry a real
+ `turnover_cap` adjustment (the second is an episode-scoped catch-up of
+ the same still-unresolved decision, not `deferred_catchup` -- that
+ fallback is reserved for a genuinely unknown cause) -- and the FIRST
+ fill ALSO correctly shows the real `strategy_signal` trigger that
+ caused it, no longer masked by the constraint (the core bug this
+ redesign fixes)."""
+ n = 20
+ schedule = {"A": [0.0] * 5 + [1.0] * (n - 5)}
+ config = _config(rebalance_frequency="daily", maximum_turnover=0.5)
+
+ trades = _run(schedule, config)
+
+ assert len(trades) == 2
+ assert trades.loc[0, "action"] == "entry_long"
+ assert trades.loc[0, "trigger_reason_code"] == "strategy_signal"
+ assert trades.loc[0, "adjustment_reason_codes"] == "turnover_cap"
+ assert "turnover-capped" in str(trades.loc[0, "adjustment_reason_details"])
+ assert trades.loc[1, "action"] == "increase_long"
+ assert trades.loc[1, "trigger_reason_code"] is None
+ assert trades.loc[1, "adjustment_reason_codes"] == "turnover_cap"
+ assert "previously deferred" in str(trades.loc[1, "adjustment_reason_details"])
+
+
+def test_clean_signal_driven_entry_with_no_binding_constraint() -> None:
+ """No turnover cap, no portfolio constraint active -- the entry reaches
+ its full desired size in one fill, so this is a clean strategy_signal,
+ and (being the symbol's very first trade) needs no special-casing."""
+ n = 10
+ schedule = {"A": [0.0] * 3 + [1.0] * (n - 3)}
+ config = _config(rebalance_frequency="daily")
+
+ trades = _run(schedule, config)
+
+ assert len(trades) == 1
+ assert trades.loc[0, "previous_weight"] == pytest.approx(0.0)
+ assert trades.loc[0, "new_weight"] == pytest.approx(1.0)
+ assert trades.loc[0, "action"] == "entry_long"
+ assert trades.loc[0, "trigger_reason_code"] == "strategy_signal"
+ assert trades.loc[0, "trigger_reason_detail_code"] is None
+ assert trades.loc[0, "trigger_reason_details"] is not None
+ assert trades.loc[0, "adjustment_reason_codes"] is None
+
+
+def test_reverse_long_to_short_reports_the_correct_action_and_reason() -> None:
+ """A single-step long-to-short flip, unconstrained -- the action must
+ say "reverse", not the generic side="sell" a naive buy/sell label would
+ give a covering-and-shorting fill indistinguishable from a partial
+ reduction."""
+ n = 12
+ schedule = {"A": [0.0] * 3 + [1.0] * 3 + [-1.0] * (n - 6)}
+ config = _config(rebalance_frequency="daily")
+
+ trades = _run(schedule, config)
+
+ assert len(trades) == 2
+ assert trades.loc[0, "action"] == "entry_long"
+ flip = trades.loc[1]
+ assert cast(float, flip["previous_weight"]) == pytest.approx(1.0)
+ assert cast(float, flip["new_weight"]) == pytest.approx(-1.0)
+ assert cast(str, flip["action"]) == "reverse_long_to_short"
+ assert cast(str, flip["trigger_reason_code"]) == "strategy_signal"
+
+
+def test_portfolio_constraint_sub_code_when_max_weight_trims_the_target() -> None:
+ """maximum_weight=0.4 trims the allocator's desired 1.0 down to 0.4
+ inside ConstraintSet itself, before turnover-cap ever runs (no turnover
+ cap configured here) -- must read as the precise constraint name
+ (maximum_weight) in `adjustment_reason_codes`. Under the trigger/
+ adjustment model, this trade ALSO correctly keeps its `strategy_signal`
+ trigger -- the entry is no longer masked by the constraint that capped
+ its size (the core bug this redesign fixes)."""
+ n = 10
+ schedule = {"A": [0.0] * 3 + [1.0] * (n - 3)}
+ config = _config(rebalance_frequency="daily", maximum_weight=0.4)
+
+ trades = _run(schedule, config)
+
+ assert len(trades) == 1
+ assert trades.loc[0, "new_weight"] == pytest.approx(0.4)
+ assert trades.loc[0, "trigger_reason_code"] == "strategy_signal"
+ assert trades.loc[0, "adjustment_reason_codes"] == "maximum_weight"
+
+
+def test_multiple_constraints_combine_into_one_adjustment_reason_codes() -> None:
+ """maximum_weight caps the single desired weight (1.0 -> 0.5), then
+ maximum_gross_exposure (tighter than the post-cap gross) rescales it
+ further (0.5 -> 0.3) -- both constraints genuinely fired on the same
+ fill and must both show up, joined via the canonical "+" convention
+ in pipeline-execution order, not just the last one to run -- alongside
+ the real `strategy_signal` trigger that caused the entry."""
+ n = 10
+ schedule = {"A": [0.0] * 3 + [1.0] * (n - 3)}
+ config = _config(
+ rebalance_frequency="daily", maximum_weight=0.5, maximum_gross_exposure=0.3
+ )
+
+ trades = _run(schedule, config)
+
+ assert len(trades) == 1
+ assert trades.loc[0, "new_weight"] == pytest.approx(0.3)
+ assert trades.loc[0, "trigger_reason_code"] == "strategy_signal"
+ expected_codes = "maximum_weight+maximum_gross_exposure"
+ assert trades.loc[0, "adjustment_reason_codes"] == expected_codes
+ details = str(trades.loc[0, "adjustment_reason_details"])
+ assert "maximum_weight" in details
+ assert "maximum_gross_exposure" in details
+
+
+def test_mean_reversion_strategy_signal_gets_precise_reason_via_full_engine() -> None:
+ """The strategy-specific reason from MeanReversionStrategy.
+ explain_signals() must reach the trade log through the FULL engine
+ pipeline -- rebalance-date sampling, the extra-delay shift and the
+ executed_weights alignment, via the positional gather in engine.py --
+ not just work when the classifier or the strategy is tested alone."""
+ n = 60
+ prices = list(np.full(40, 100.0)) + list(np.linspace(100, 70, n - 40))
+ config = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "mean_reversion_full_engine",
+ "data": {
+ "instruments": [{"symbol": "A", "source": "csv", "calendar": "XNYS"}],
+ "start_date": "2020-01-01",
+ "end_date": "2020-06-01",
+ },
+ "strategy": {"name": "mean_reversion"}, # unused: an instance is passed
+ "portfolio": {"allocator": "equal_weight", "rebalance_frequency": "daily"},
+ "execution": {
+ "commission_bps": 0.0,
+ "spread_bps": 0.0,
+ "slippage_bps": 0.0,
+ },
+ "backtest": {"initial_capital": 100_000},
+ }
+ )
+ strategy = MeanReversionStrategy(
+ lookback_period=20, entry_threshold=1.5, exit_threshold=0.5, long_only=True
+ )
+
+ trades = _run_strategy(strategy, prices, config)
+
+ entries = trades[trades["action"] == "entry_long"]
+ assert len(entries) == 1
+ assert entries.iloc[0]["trigger_reason_code"] == "strategy_signal"
+ assert entries.iloc[0]["trigger_reason_detail_code"] == "oversold_entry"
+ assert "entry threshold" in entries.iloc[0]["trigger_reason_details"]
+
+
+# --------------------------------------------------------------------------- #
+# position_strategy_origin: driven purely by decision_proxy's own regime
+# (flat/long/short via sign) -- cleared only when decision_proxy itself
+# returns to flat, never by a downstream layer, and insensitive to a
+# continuous signal's own magnitude drift.
+# --------------------------------------------------------------------------- #
+def test_position_strategy_origin_entry_exit_flat_then_new_entry() -> None:
+ n = 20
+ schedule = {"A": [0.0] * 3 + [1.0] * 4 + [0.0] * 4 + [1.0] * (n - 11)}
+ reason_schedule: dict[str, list[str | None]] = {"A": [None] * n}
+ reason_schedule["A"][3] = "first_entry"
+ reason_schedule["A"][7] = "first_exit"
+ reason_schedule["A"][11] = "second_entry"
+ config = _config(rebalance_frequency="daily")
+ strategy = _ScriptedStrategyWithReasons({"A": schedule["A"]}, reason_schedule)
+ data = make_ohlcv("A", [100.0] * n, start="2020-01-01")
+ result = BacktestEngine().run(
+ data,
+ strategy,
+ EqualWeightAllocator(),
+ ExecutionModel.from_config(config.execution),
+ config,
+ )
+ trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+ entries = trades[trades["action"] == "entry_long"].reset_index(drop=True)
+ exits = trades[trades["action"] == "exit_long"].reset_index(drop=True)
+
+ assert len(entries) == 2
+ assert len(exits) == 1
+ assert entries.loc[0, "position_strategy_origin_code"] == "first_entry"
+ # Origin is cleared the moment decision_proxy itself returns to flat.
+ assert pd.isna(exits.loc[0, "position_strategy_origin_timestamp"])
+ assert exits.loc[0, "position_strategy_origin_code"] is None
+ assert entries.loc[1, "position_strategy_origin_code"] == "second_entry"
+ assert cast(
+ pd.Timestamp, entries.loc[1, "position_strategy_origin_timestamp"]
+ ) > cast(pd.Timestamp, entries.loc[0, "position_strategy_origin_timestamp"])
+
+
+def test_position_strategy_origin_reversal_replaces_not_merges() -> None:
+ n = 15
+ schedule = {"A": [0.0] * 3 + [1.0] * 4 + [-1.0] * (n - 7)}
+ reason_schedule: dict[str, list[str | None]] = {"A": [None] * n}
+ reason_schedule["A"][3] = "long_entry"
+ reason_schedule["A"][7] = "short_entry"
+ config = _config(rebalance_frequency="daily")
+ strategy = _ScriptedStrategyWithReasons({"A": schedule["A"]}, reason_schedule)
+ data = make_ohlcv("A", [100.0] * n, start="2020-01-01")
+ result = BacktestEngine().run(
+ data,
+ strategy,
+ EqualWeightAllocator(),
+ ExecutionModel.from_config(config.execution),
+ config,
+ )
+ trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+
+ reversal = trades[trades["action"] == "reverse_long_to_short"].iloc[0]
+ assert reversal["position_strategy_origin_code"] == "short_entry"
+
+
+def test_position_strategy_origin_insensitive_to_continuous_magnitude_drift() -> None:
+ """A continuous signal's own magnitude drift (0.4 -> 0.5 -> 0.3 -> 0.6,
+ same regime throughout) must never recreate the origin -- only a
+ flat<->non-flat regime change does. A second, constant-signal symbol B
+ is included so signal_proportional's relative split actually moves A's
+ executed weight as A's own magnitude drifts (a single-asset universe
+ would always normalize to a constant sign-only weight, masking drift)."""
+ n = 20
+ schedule_a = [0.0] * 3 + [0.4, 0.5, 0.3, 0.6] + [0.0] * (n - 7)
+ schedule_b = [0.5] * n
+ reason_schedule: dict[str, list[str | None]] = {
+ "A": [None] * n,
+ "B": [None] * n,
+ }
+ reason_schedule["A"][3] = "continuous_entry"
+ config = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "trade_reasons_continuous_drift",
+ "data": {
+ "instruments": [
+ {"symbol": "A", "source": "csv", "calendar": "XNYS"},
+ {"symbol": "B", "source": "csv", "calendar": "XNYS"},
+ ],
+ "start_date": "2020-01-01",
+ "end_date": "2020-03-01",
+ },
+ "strategy": {"name": "buy_and_hold"}, # unused: an instance is passed
+ "portfolio": {"allocator": "equal_weight", "rebalance_frequency": "daily"},
+ "execution": {
+ "commission_bps": 0.0,
+ "spread_bps": 0.0,
+ "slippage_bps": 0.0,
+ },
+ "backtest": {"initial_capital": 100_000},
+ }
+ )
+ strategy = _ScriptedStrategyWithReasons(
+ {"A": schedule_a, "B": schedule_b}, reason_schedule
+ )
+ data_a = make_ohlcv("A", [100.0] * n, start="2020-01-01")
+ data_b = make_ohlcv("B", [100.0] * n, start="2020-01-01")
+ data = pd.concat([data_a, data_b], ignore_index=True)
+ result = BacktestEngine().run(
+ data,
+ strategy,
+ build_allocator("signal_proportional"),
+ ExecutionModel.from_config(config.execution),
+ config,
+ )
+ trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+ held_trades = trades[
+ trades["action"].isin(["entry_long", "increase_long", "reduce_long"])
+ ]
+
+ assert len(held_trades) >= 2 # entry + at least one magnitude-drift fill
+ origins = held_trades["position_strategy_origin_code"].unique().tolist()
+ assert origins == ["continuous_entry"]
+ timestamps = held_trades["position_strategy_origin_timestamp"].unique()
+ assert len(timestamps) == 1
+
+
+def test_position_strategy_origin_survives_allocator_warmup_before_first_trade() -> (
+ None
+):
+ """buy_and_hold-style scenario (points 2/7): the strategy's decision
+ becomes active immediately, but InverseVolatilityAllocator only
+ produces its first non-zero weight once its volatility window fills
+ -- the resulting first trade's TRIGGER is portfolio_rebalance (no NEW
+ signal transition that day), but position_strategy_origin must still
+ correctly point back to the original strategic decision -- confirming
+ it tracks the strategic regime, not the executed-weight episode."""
+ n = 40
+ schedule = {"A": [1.0] * n} # active from day 0
+ reason_schedule: dict[str, list[str | None]] = {"A": [None] * n}
+ reason_schedule["A"][0] = "price_became_available"
+ config = _config(rebalance_frequency="daily")
+ strategy = _ScriptedStrategyWithReasons(schedule, reason_schedule)
+ rng = np.random.default_rng(0)
+ prices = list(100.0 * np.cumprod(1.0 + rng.normal(0.0005, 0.01, n)))
+ data = make_ohlcv("A", prices, start="2020-01-01")
+ result = BacktestEngine().run(
+ data,
+ strategy,
+ # A short volatility_window guarantees the warmup mismatch
+ # (signal active from day 0, allocator only produces its first
+ # non-zero weight once its own window fills) resolves well within
+ # this test's 40-row window.
+ InverseVolatilityAllocator(volatility_window=10),
+ ExecutionModel.from_config(config.execution),
+ config,
+ )
+ trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+
+ assert len(trades) >= 1
+ first_trade = trades.iloc[0]
+ assert first_trade["action"] == "entry_long"
+ assert first_trade["trigger_reason_code"] == "portfolio_rebalance"
+ assert first_trade["position_strategy_origin_code"] == "price_became_available"
+ assert pd.notna(first_trade["position_strategy_origin_timestamp"])
+ assert first_trade["position_strategy_origin_timestamp"] <= first_trade["timestamp"]
+
+
+def test_forced_liquidation_after_ruin_is_correctly_unrepresented_not_fabricated() -> (
+ None
+):
+ """A full short position hit by a price spike large enough to floor
+ the period return at exactly -100% (see accounting.py's own
+ `_floor_at_total_loss`) ruins the portfolio. `AccountingResult.
+ force_flat` (see `_run_accounting_steps`'s own docstring: "preventing
+ a closing trade with no remaining capital") deliberately zeroes
+ `weight_changes` on every ruined date -- there is genuinely no
+ capital left to execute a real closing trade, so NO trade-log row is
+ -- correctly -- ever produced for the liquidation moment itself, even
+ though `positions`/`equity_curve` show the position and equity
+ dropping to zero there. The `forced_liquidation` adjustment wiring
+ (`executed_forced_liquidation`, sourced from the real
+ `AccountingResult.ruined`) exists for whichever cell/row combination
+ WOULD have a recorded change on a ruined date; today's accounting
+ semantics make that combination unreachable, and this test pins that
+ down explicitly so a future accounting change that DOES produce such
+ a row is caught by the adjacent `_classify_reason` unit test
+ (`test_classify_reason_forced_liquidation_*` in test_trade_log.py)
+ rather than silently reverting to `unknown`/`deferred_catchup`.
+
+ `model_weight_drift` is explicitly disabled: a position held constant
+ for several days before one catastrophic spike is exactly the shape
+ `apply_weight_drift`'s OWN, separate per-episode bankruptcy guard
+ (`E <= EPSILON`) is designed to catch -- with drift enabled (the
+ default), that guard fires first and flattens the position before
+ `AccountingResult`'s own absolute-equity `ruined` mechanism, the one
+ this test specifically targets, ever sees the floored return. The two
+ guards are independent and BOTH legitimate (see `apply_weight_drift`'s
+ own docstring); this test isolates the one it is actually about."""
+ n = 15
+ schedule = {"A": [0.0] * 3 + [-1.0] * (n - 3)} # full short from day 3
+ # Price flat through day 4, then a +300% spike on day 5 -- applied to
+ # the already-short position decided the prior day, this floors that
+ # period's return at exactly -100% (total loss).
+ prices = [100.0] * 5 + [400.0] * (n - 5)
+ config = _config(rebalance_frequency="daily", model_weight_drift=False)
+ data = make_ohlcv("A", prices, start="2020-01-01")
+ result = BacktestEngine().run(
+ data,
+ _ScriptedStrategy(schedule),
+ EqualWeightAllocator(),
+ ExecutionModel.from_config(config.execution),
+ config,
+ )
+ trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+
+ # Ruin genuinely happened (equity hits exactly zero, and the position
+ # is force-flattened starting the NEXT date -- `ruined` itself is a
+ # shift(1) of the equity condition, see run_accounting) ...
+ assert (result.equity_curve <= 0.0).any()
+ first_ruin_date = result.equity_curve.index[result.equity_curve <= 0.0][0]
+ after_ruin = result.positions["A"].loc[result.positions.index > first_ruin_date]
+ assert len(after_ruin) > 0
+ assert (after_ruin == 0.0).all()
+ # ... but no trade row exists for it (no capital to execute a real
+ # closing trade) -- exactly one trade total, the original entry.
+ assert len(trades) == 1
+ assert trades.iloc[0]["action"] == "entry_short"
+ # No row anywhere is left unattributed or mis-attributed as a result.
+ assert not (trades["trigger_reason_code"] == "unknown").any()
+ assert not (trades["adjustment_reason_codes"] == "deferred_catchup").any()
+
+
+# --------------------------------------------------------------------------- #
+# Alignment: the strategy-specific reason must follow the transition that
+# actually produced the executed signal value, not the row a later
+# rebalance/execution step happens to sample.
+# --------------------------------------------------------------------------- #
+def _weekly_rebalance_positions(n: int) -> list[int]:
+ """Row positions of each weekly rebalance date for an n-row XNYS index
+ starting 2020-01-01 -- computed via the real rebalance_dates(), not
+ guessed, so the test never silently drifts from actual calendar
+ behaviour."""
+ data = make_ohlcv("A", [100.0] * n, start="2020-01-01")
+ index = pd.DatetimeIndex(data["timestamp"].drop_duplicates().sort_values())
+ dates = rebalance_dates(index, "weekly", calendar="XNYS")
+ return [int(position) for position in index.get_indexer(dates)]
+
+
+def test_off_cycle_strategy_reason_survives_until_the_next_rebalance() -> None:
+ """A transition on a day that is NOT a rebalance date must still be
+ the reason attached to the trade once the persisting signal is
+ finally rebalanced/executed -- not None (the sampled rebalance-date
+ row's own, empty, reason cell) and not some unrelated later reason."""
+ n = 30
+ positions = _weekly_rebalance_positions(n)
+ # positions e.g. [0, 3, 8, 13, ...] -- pick a row strictly between the
+ # 2nd and 3rd rebalance date (never rely on a fixed magic number).
+ previous_rebalance, next_rebalance = positions[1], positions[2]
+ off_cycle = previous_rebalance + 1
+ assert off_cycle < next_rebalance, "need at least one off-cycle row"
+
+ schedule_a = [0.0] * off_cycle + [1.0] * (n - off_cycle)
+ reason_schedule_a: list[str | None] = [None] * n
+ reason_schedule_a[off_cycle] = "off_cycle_entry"
+
+ config = _config(rebalance_frequency="weekly")
+ strategy = _ScriptedStrategyWithReasons({"A": schedule_a}, {"A": reason_schedule_a})
+ data = make_ohlcv("A", [100.0] * n, start="2020-01-01")
+ result = BacktestEngine().run(
+ data,
+ strategy,
+ EqualWeightAllocator(),
+ ExecutionModel.from_config(config.execution),
+ config,
+ )
+ trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+
+ assert len(trades) == 1
+ assert trades.loc[0, "trigger_reason_code"] == "strategy_signal"
+ assert trades.loc[0, "trigger_reason_detail_code"] == "off_cycle_entry"
+ assert "scripted: off_cycle_entry" in str(trades.loc[0, "trigger_reason_details"])
+
+
+def test_the_last_of_two_off_cycle_transitions_wins() -> None:
+ """Two transitions happen before the next rebalance consumes them --
+ the trade must carry the LATER transition's reason, not the earlier
+ one (a newer transition always overwrites an older one, see
+ engine.py's last_transition_seed)."""
+ n = 30
+ positions = _weekly_rebalance_positions(n)
+ previous_rebalance, next_rebalance = positions[1], positions[2]
+ first_step = previous_rebalance + 1
+ second_step = first_step + 1
+ assert second_step < next_rebalance, (
+ "need two off-cycle rows before the next rebalance"
+ )
+
+ schedule_a = (
+ [0.0] * first_step
+ + [0.5] * (second_step - first_step)
+ + [1.0] * (n - second_step)
+ )
+ reason_schedule_a: list[str | None] = [None] * n
+ reason_schedule_a[first_step] = "first_step"
+ reason_schedule_a[second_step] = "second_step"
+
+ config = _config(rebalance_frequency="weekly")
+ strategy = _ScriptedStrategyWithReasons({"A": schedule_a}, {"A": reason_schedule_a})
+ data = make_ohlcv("A", [100.0] * n, start="2020-01-01")
+ result = BacktestEngine().run(
+ data,
+ strategy,
+ EqualWeightAllocator(),
+ ExecutionModel.from_config(config.execution),
+ config,
+ )
+ trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+
+ assert len(trades) == 1
+ assert trades.loc[0, "trigger_reason_detail_code"] == "second_step"
+ reason_details = str(trades.loc[0, "trigger_reason_details"])
+ assert "scripted: second_step" in reason_details
+ assert "first_step" not in reason_details
+
+
+def test_off_cycle_strategy_reason_survives_an_execution_delay() -> None:
+ """The alignment fix must still work once an extra execution_delay
+ shift is layered on top of the rebalance-date sampling."""
+ n = 30
+ positions = _weekly_rebalance_positions(n)
+ previous_rebalance, next_rebalance = positions[1], positions[2]
+ off_cycle = previous_rebalance + 1
+ assert off_cycle < next_rebalance, "need at least one off-cycle row"
+
+ schedule_a = [0.0] * off_cycle + [1.0] * (n - off_cycle)
+ reason_schedule_a: list[str | None] = [None] * n
+ reason_schedule_a[off_cycle] = "off_cycle_entry"
+
+ config = _config(rebalance_frequency="weekly")
+ strategy = _ScriptedStrategyWithReasons({"A": schedule_a}, {"A": reason_schedule_a})
+ data = make_ohlcv("A", [100.0] * n, start="2020-01-01")
+ result = BacktestEngine().run(
+ data,
+ strategy,
+ EqualWeightAllocator(),
+ ExecutionModel.from_config(config.execution),
+ config,
+ execution_delay=2,
+ )
+ trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+
+ assert len(trades) == 1
+ assert trades.loc[0, "trigger_reason_detail_code"] == "off_cycle_entry"
+ assert "scripted: off_cycle_entry" in str(trades.loc[0, "trigger_reason_details"])
+
+
+def test_off_cycle_strategy_reason_survives_a_non_tradable_gap() -> None:
+ """The alignment fix must still work when the symbol has its own
+ calendar closures (a mixed-calendar universe, so engine.py actually
+ computes a per-symbol ``tradable`` mask instead of taking the
+ ``tradable=None`` fast path)."""
+ n = 30
+ positions = _weekly_rebalance_positions(n)
+ previous_rebalance, next_rebalance = positions[1], positions[2]
+ off_cycle = previous_rebalance + 1
+ assert off_cycle < next_rebalance, "need at least one off-cycle row"
+
+ schedule_a = [0.0] * off_cycle + [1.0] * (n - off_cycle)
+ schedule_b = [0.0] * n
+ reason_schedule_a: list[str | None] = [None] * n
+ reason_schedule_a[off_cycle] = "off_cycle_entry"
+ reason_schedule_b: list[str | None] = [None] * n
+
+ # Two instruments on DIFFERENT calendars -- forces engine.py to build
+ # a real per-symbol tradable mask (uniform_calendar returns None
+ # otherwise, and the whole tradable-mask machinery is skipped).
+ config = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "trade_reasons_tradability",
+ "data": {
+ "instruments": [
+ {"symbol": "A", "source": "csv", "calendar": "XNYS"},
+ {"symbol": "B", "source": "csv", "calendar": "24/7"},
+ ],
+ "start_date": "2020-01-01",
+ "end_date": "2020-03-01",
+ },
+ "strategy": {"name": "buy_and_hold"}, # unused: an instance is passed
+ "portfolio": {"allocator": "equal_weight", "rebalance_frequency": "weekly"},
+ "execution": {
+ "commission_bps": 0.0,
+ "spread_bps": 0.0,
+ "slippage_bps": 0.0,
+ },
+ "backtest": {"initial_capital": 100_000, "periods_per_year": 252},
+ }
+ )
+ strategy = _ScriptedStrategyWithReasons(
+ {"A": schedule_a, "B": schedule_b},
+ {"A": reason_schedule_a, "B": reason_schedule_b},
+ )
+ data_a = make_ohlcv("A", [100.0] * n, start="2020-01-01")
+ data_b = make_ohlcv("B", [100.0] * n, start="2020-01-01")
+ data = pd.concat([data_a, data_b], ignore_index=True)
+ result = BacktestEngine().run(
+ data,
+ strategy,
+ EqualWeightAllocator(),
+ ExecutionModel.from_config(config.execution),
+ config,
+ )
+ trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+
+ assert len(trades) == 1
+ assert trades.loc[0, "trigger_reason_detail_code"] == "off_cycle_entry"
+ assert "scripted: off_cycle_entry" in str(trades.loc[0, "trigger_reason_details"])
+
+
+# --------------------------------------------------------------------------- #
+# stop_loss_pct/take_profit_pct: end-to-end through the real BacktestEngine,
+# operating on the REAL executed position (never the raw signal) -- see
+# quantlab.backtesting.accounting._detect_stop_loss_take_profit.
+# --------------------------------------------------------------------------- #
+class _ScriptedPairStrategy(_ScriptedStrategy):
+ """Like `_ScriptedStrategy`, but declares its two symbols as one
+ `position_groups()` -- the pairs_trading pattern, for an end-to-end
+ test that a stop-loss triggers on the GROUP's combined P&L."""
+
+ name = "scripted_pair"
+
+ def __init__(
+ self, schedule: dict[str, list[float]], symbol_a: str, symbol_b: str
+ ) -> None:
+ super().__init__(schedule)
+ self._symbol_a = symbol_a
+ self._symbol_b = symbol_b
+
+ def position_groups(self) -> tuple[tuple[str, ...], ...] | None:
+ return ((self._symbol_a, self._symbol_b),)
+
+
+def test_stop_loss_pct_closes_a_real_position_and_the_trade_log_shows_it() -> None:
+ """Long throughout, -6% then another -6% (cumulative -11.64%, past a
+ 10% stop): the loss-realizing bar keeps its return, and the position
+ is closed the FOLLOWING bar with a real, non-zero exit trade whose
+ adjustment_reason_codes is exactly 'stop_loss' -- end-to-end proof
+ that the mechanism operates on the real executed position and is
+ correctly surfaced in the trade log."""
+ n = 8
+ schedule = {"A": [1.0] * n}
+ prices = [100.0, 100.0, 94.0, 88.36, 88.36, 88.36, 88.36, 88.36]
+ config = _config(rebalance_frequency="daily")
+ strategy = _ScriptedStrategy(schedule)
+ strategy.stop_loss_pct = 0.10
+
+ trades = _run_strategy(strategy, prices, config)
+
+ exits = trades[trades["action"] == "exit_long"]
+ assert len(exits) == 1
+ assert exits.iloc[0]["adjustment_reason_codes"] == "stop_loss"
+ assert "stop_loss_pct" in str(exits.iloc[0]["adjustment_reason_details"])
+
+
+def test_stop_loss_pct_none_by_default_changes_nothing_end_to_end() -> None:
+ """The critical non-regression guarantee at the engine level: a
+ strategy that never sets stop_loss_pct/take_profit_pct (the default
+ on every built-in strategy) must produce a trade log identical to
+ today's, with no 'stop_loss'/'take_profit' adjustment ever appearing."""
+ n = 8
+ schedule = {"A": [1.0] * n}
+ prices = [100.0, 100.0, 94.0, 88.36, 88.36, 88.36, 88.36, 88.36]
+ config = _config(rebalance_frequency="daily")
+ strategy = _ScriptedStrategy(schedule)
+
+ trades = _run_strategy(strategy, prices, config)
+
+ assert not (trades["adjustment_reason_codes"] == "stop_loss").any()
+ assert not (trades["adjustment_reason_codes"] == "take_profit").any()
+ assert len(trades[trades["action"] == "exit_long"]) == 0
+
+
+def test_position_groups_stop_loss_closes_both_legs_together() -> None:
+ """A scripted pair strategy (mirroring pairs_trading's own
+ `position_groups()`) with legs A=+1.0/B=-1.0 scaled down to +0.5/-0.5
+ mid-hold (a rebalance) -- A drops 20%/20%, B is flat, so the GROUP's
+ combined return per unit of ITS OWN exposure breaches a 15% stop
+ (cumulative 0.90*0.90-1=-19%) regardless of the leg-size change.
+ BOTH legs must show the exit AND the 'stop_loss' code on the SAME
+ date -- proof position_groups() is honored end-to-end, not just at
+ the accounting-layer unit test level."""
+ schedule_a = [1.0, 1.0, 0.5, 0.5, 0.5, 0.5]
+ schedule_b = [-1.0, -1.0, -0.5, -0.5, -0.5, -0.5]
+ prices_a = [100.0, 100.0, 80.0, 64.0, 64.0, 64.0]
+ prices_b = [100.0, 100.0, 100.0, 100.0, 100.0, 100.0]
+ config = ExperimentConfig.from_dict(
+ {
+ "experiment_name": "trade_reasons_stop_loss_pair",
+ "data": {
+ "instruments": [
+ {"symbol": "A", "source": "csv", "calendar": "XNYS"},
+ {"symbol": "B", "source": "csv", "calendar": "XNYS"},
+ ],
+ "start_date": "2020-01-01",
+ "end_date": "2020-03-01",
+ },
+ "strategy": {"name": "buy_and_hold"}, # unused: an instance is passed
+ "portfolio": {"allocator": "equal_weight", "rebalance_frequency": "daily"},
+ "execution": {
+ "commission_bps": 0.0,
+ "spread_bps": 0.0,
+ "slippage_bps": 0.0,
+ },
+ "backtest": {"initial_capital": 100_000},
+ }
+ )
+ strategy = _ScriptedPairStrategy({"A": schedule_a, "B": schedule_b}, "A", "B")
+ strategy.stop_loss_pct = 0.15
+ data_a = make_ohlcv("A", prices_a, start="2020-01-01")
+ data_b = make_ohlcv("B", prices_b, start="2020-01-01")
+ data = pd.concat([data_a, data_b], ignore_index=True)
+
+ result = BacktestEngine().run(
+ data,
+ strategy,
+ EqualWeightAllocator(),
+ ExecutionModel.from_config(config.execution),
+ config,
+ )
+
+ trades_a = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True)
+ trades_b = result.trades[result.trades["symbol"] == "B"].reset_index(drop=True)
+ stop_a = trades_a[trades_a["adjustment_reason_codes"] == "stop_loss"]
+ stop_b = trades_b[trades_b["adjustment_reason_codes"] == "stop_loss"]
+ assert len(stop_a) == 1
+ assert len(stop_b) == 1
+ assert stop_a.iloc[0]["timestamp"] == stop_b.iloc[0]["timestamp"]
+ assert stop_a.iloc[0]["action"] == "exit_long"
+ assert stop_b.iloc[0]["action"] == "exit_short"
diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py
index 5c6fac1..729f879 100644
--- a/tests/unit/test_validation.py
+++ b/tests/unit/test_validation.py
@@ -4,7 +4,7 @@
import math
from pathlib import Path
-from typing import Any, TypedDict
+from typing import Any, TypedDict, cast
import numpy as np
import pandas as pd
@@ -115,8 +115,8 @@ def _cost_sensitive_config(commission_bps: float = 50.0) -> ExperimentConfig:
"name": "mean_reversion",
"parameters": {
"lookback_period": 10,
- "entry_zscore": 1.0,
- "exit_zscore": 0.1,
+ "entry_threshold": 1.0,
+ "exit_threshold": 0.1,
},
},
"portfolio": {"allocator": "equal_weight", "rebalance_frequency": "daily"},
@@ -135,7 +135,7 @@ def _cost_sensitive_config(commission_bps: float = 50.0) -> ExperimentConfig:
# Pinned explicitly: run_walk_forward_stress_tests() resolves
# its grid from this config (parameter_grid_for_config), not
# from an argument, so it must match the grid used below.
- "parameter_grid": {"entry_zscore": [0.5, 3.0]},
+ "parameter_grid": {"entry_threshold": [0.5, 3.0]},
},
}
)
@@ -368,6 +368,7 @@ def _flaky_select(self: WalkForwardValidator, *args: object, **kwargs: object):
train_window=300,
validation_window=120,
test_window=120,
+ step=120,
expanding=True,
execution_delay=0,
parameter_grid={"lookback_period": [60, 120]},
@@ -455,6 +456,7 @@ def _flaky_select(self: WalkForwardValidator, *args: object, **kwargs: object):
train_window=300,
validation_window=120,
test_window=120,
+ step=120,
expanding=True,
execution_delay=0,
parameter_grid={"lookback_period": [60, 120]},
@@ -539,6 +541,7 @@ def _flaky_select(self: WalkForwardValidator, *args: object, **kwargs: object):
train_window=300,
validation_window=120,
test_window=120,
+ step=120,
expanding=True,
execution_delay=0,
parameter_grid={"lookback_period": [60, 120]},
@@ -621,6 +624,7 @@ def _flaky_select(self: WalkForwardValidator, *args: object, **kwargs: object):
train_window=300,
validation_window=120,
test_window=120,
+ step=120,
expanding=True,
execution_delay=0,
parameter_grid={"lookback_period": [60, 120]},
@@ -863,13 +867,13 @@ def test_run_walk_forward_stress_tests_reselects_parameters_under_higher_costs()
):
"""The methodological point of the whole process-level/returns-level
split: a Walk-forward mode stress scenario must genuinely re-run
- selection, not rescale a fixed baseline's weights. entry_zscore=0.5
+ selection, not rescale a fixed baseline's weights. entry_threshold=0.5
trades far more often than 3.0 on this mean-reverting panel, so it must
lose ground once commission is stressed 5x — and the stress-test
function's own numbers must come from that re-selected run."""
data = _cost_sensitive_panel()
config = _cost_sensitive_config(commission_bps=50.0)
- grid = {"entry_zscore": [0.5, 3.0]}
+ grid = {"entry_threshold": [0.5, 3.0]}
windows: _WalkForwardWindows = {
"train_window": 150,
"validation_window": 60,
@@ -879,7 +883,9 @@ def test_run_walk_forward_stress_tests_reselects_parameters_under_higher_costs()
wf_baseline = WalkForwardValidator(config).run(data, parameter_grid=grid, **windows)
assert wf_baseline.oos_result is not None
- baseline_choices = [fold.best_params["entry_zscore"] for fold in wf_baseline.folds]
+ baseline_choices = [
+ fold.best_params["entry_threshold"] for fold in wf_baseline.folds
+ ]
# The high-turnover parameter must win at least one fold at baseline cost
# — otherwise there is nothing for higher costs to knock it away from.
assert 0.5 in baseline_choices
@@ -887,7 +893,7 @@ def test_run_walk_forward_stress_tests_reselects_parameters_under_higher_costs()
x5_config = scale_costs(config, commission_mult=5.0)
wf_x5 = WalkForwardValidator(x5_config).run(data, parameter_grid=grid, **windows)
assert wf_x5.oos_result is not None
- x5_choices = [fold.best_params["entry_zscore"] for fold in wf_x5.folds]
+ x5_choices = [fold.best_params["entry_threshold"] for fold in wf_x5.folds]
# The core assertion: re-running walk-forward selection under 5x
# commission actually changes which parameter wins on at least one fold.
@@ -912,7 +918,7 @@ def test_run_with_weight_cache_matches_plain_run() -> None:
by-product of the same computation, not a different one."""
data = _cost_sensitive_panel()
config = _cost_sensitive_config(commission_bps=50.0)
- grid = {"entry_zscore": [0.5, 3.0]}
+ grid = {"entry_threshold": [0.5, 3.0]}
windows: _WalkForwardWindows = {
"train_window": 150,
"validation_window": 60,
@@ -942,7 +948,7 @@ def test_run_with_weight_cache_resumes_from_a_checkpoint_and_matches_a_fresh_run
resumable and bit-for-bit reproducible on its own."""
data = _cost_sensitive_panel()
config = _cost_sensitive_config(commission_bps=50.0)
- grid = {"entry_zscore": [0.5, 3.0]}
+ grid = {"entry_threshold": [0.5, 3.0]}
windows: _WalkForwardWindows = {
"train_window": 150,
"validation_window": 60,
@@ -1003,7 +1009,7 @@ def test_run_with_weight_cache_refuses_a_checkpoint_with_a_mismatched_candidate_
data = _cost_sensitive_panel()
config = _cost_sensitive_config(commission_bps=50.0)
- grid = {"entry_zscore": [0.5, 3.0]}
+ grid = {"entry_threshold": [0.5, 3.0]}
windows: _WalkForwardWindows = {
"train_window": 150,
"validation_window": 60,
@@ -1040,9 +1046,10 @@ def _flaky_capture(self: WalkForwardValidator, *args: object, **kwargs: object):
train_window=150,
validation_window=60,
test_window=60,
+ step=60,
expanding=True,
execution_delay=0,
- parameter_grid={"entry_zscore": [0.5, 3.0]},
+ parameter_grid={"entry_threshold": [0.5, 3.0]},
)
loaded = load_checkpoint(checkpoint_path, provenance)
assert loaded is not None
@@ -1084,7 +1091,7 @@ def test_run_with_weight_cache_refuses_a_checkpoint_with_a_corrupted_candidate_f
data = _cost_sensitive_panel()
config = _cost_sensitive_config(commission_bps=50.0)
- grid = {"entry_zscore": [0.5, 3.0]}
+ grid = {"entry_threshold": [0.5, 3.0]}
windows: _WalkForwardWindows = {
"train_window": 150,
"validation_window": 60,
@@ -1121,9 +1128,10 @@ def _flaky_capture(self: WalkForwardValidator, *args: object, **kwargs: object):
train_window=150,
validation_window=60,
test_window=60,
+ step=60,
expanding=True,
execution_delay=0,
- parameter_grid={"entry_zscore": [0.5, 3.0]},
+ parameter_grid={"entry_threshold": [0.5, 3.0]},
)
loaded = load_checkpoint(checkpoint_path, provenance)
assert loaded is not None
@@ -1164,7 +1172,7 @@ def test_rescore_with_costs_matches_a_fresh_scenario_run() -> None:
faster way to compute the same answer, not an approximation."""
data = _cost_sensitive_panel()
config = _cost_sensitive_config(commission_bps=50.0)
- grid = {"entry_zscore": [0.5, 3.0]}
+ grid = {"entry_threshold": [0.5, 3.0]}
windows: _WalkForwardWindows = {
"train_window": 150,
"validation_window": 60,
@@ -1206,7 +1214,9 @@ def test_run_walk_forward_stress_tests_best_days_removed_reuses_baseline() -> No
# and windows (resolve_walk_forward_windows(config)), so both must match
# what the baseline below is built with.
config = _config_with_grid(grid)
- train_window, validation_window, test_window = resolve_walk_forward_windows(config)
+ train_window, validation_window, test_window, _step = resolve_walk_forward_windows(
+ config
+ )
wf_baseline = WalkForwardValidator(config).run(
data,
parameter_grid=grid,
@@ -1312,7 +1322,9 @@ def test_run_walk_forward_stress_tests_reports_scenario_progress() -> None:
# way real callers (dashboard/CLI) do — otherwise its fold count
# wouldn't match the weight cache's, which this progress accounting
# relies on.
- train_window, validation_window, test_window = resolve_walk_forward_windows(config)
+ train_window, validation_window, test_window, _step = resolve_walk_forward_windows(
+ config
+ )
wf_baseline = WalkForwardValidator(config).run(
data,
parameter_grid=grid,
@@ -1359,7 +1371,9 @@ def test_run_walk_forward_stress_tests_resumes_the_weight_cache_build(
data = _panel()
grid = {"lookback_period": [60, 120]}
config = _config_with_grid(grid)
- train_window, validation_window, test_window = resolve_walk_forward_windows(config)
+ train_window, validation_window, test_window, _step = resolve_walk_forward_windows(
+ config
+ )
wf_baseline = WalkForwardValidator(config).run(
data,
parameter_grid=grid,
@@ -1435,7 +1449,9 @@ def test_run_walk_forward_stress_tests_resume_after_cost_block_runs_every_later_
data = _panel()
config = _config_with_grid({})
- train_window, validation_window, test_window = resolve_walk_forward_windows(config)
+ train_window, validation_window, test_window, _step = resolve_walk_forward_windows(
+ config
+ )
wf_baseline = WalkForwardValidator(config).run(
data,
parameter_grid={},
@@ -1490,7 +1506,9 @@ def test_run_walk_forward_stress_tests_refuses_a_checkpoint_with_wrong_scenario_
data = _panel()
config = _config_with_grid({})
- train_window, validation_window, test_window = resolve_walk_forward_windows(config)
+ train_window, validation_window, test_window, _step = resolve_walk_forward_windows(
+ config
+ )
wf_baseline = WalkForwardValidator(config).run(
data,
parameter_grid={},
@@ -1543,7 +1561,9 @@ def test_run_walk_forward_stress_tests_refuses_a_checkpoint_with_an_inconsistent
data = _panel()
config = _config_with_grid({})
- train_window, validation_window, test_window = resolve_walk_forward_windows(config)
+ train_window, validation_window, test_window, _step = resolve_walk_forward_windows(
+ config
+ )
wf_baseline = WalkForwardValidator(config).run(
data,
parameter_grid={},
@@ -1709,14 +1729,13 @@ def test_walk_forward_parameter_sensitivity_refuses_a_checkpoint_with_a_garbage_
def test_walk_forward_parameter_sensitivity_recovers_from_a_pd_na_checkpoint(
tmp_path: Path,
) -> None:
- """A checkpointed cell whose swept-parameter value is ``pd.NA`` used to
- crash the resume path with ``TypeError: boolean value of NA is
- ambiguous`` deep inside the comparison that checks a cell's value
- against its expected combination -- an exception that then propagated
- out of ``load_checkpoint`` despite its own contract that a corrupted
- checkpoint is only ever skipped, never raised. Must now be treated as
- "not a match" and trigger a fresh recompute instead of crashing the
- whole sweep."""
+ """A checkpointed cell whose swept-parameter value is ``pd.NA`` must be
+ treated as "not a match" and trigger a fresh recompute instead of
+ crashing the whole sweep: the comparison that checks a cell's value
+ against its expected combination must never let ``TypeError: boolean
+ value of NA is ambiguous`` propagate out of ``load_checkpoint``,
+ whose own contract is that a corrupted checkpoint is only ever
+ skipped, never raised."""
from quantlab.validation.checkpoint import compute_provenance, save_checkpoint
data = _panel()
@@ -1795,7 +1814,39 @@ def test_bootstrap_summary_percentiles() -> None:
)
# Percentile ordering holds.
for _, row in summary.iterrows():
- assert row["p05"] <= row["median"] <= row["p95"]
+ assert row["p_lower"] <= row["median"] <= row["p_upper"]
+
+
+def test_bootstrap_summary_confidence_level_is_configurable() -> None:
+ """A narrower confidence level must yield a tighter p_lower/p_upper band.
+
+ 0.90 (the default) reproduces the original fixed 5th/95th percentile
+ interval exactly; a lower confidence level (e.g. 0.50 -> 25th/75th)
+ must be strictly narrower for a large enough sample.
+ """
+ rng = np.random.default_rng(0)
+ returns = pd.Series(rng.normal(0.0005, 0.01, 500))
+ boot = bootstrap_returns(returns, n_iterations=300, block_size=5, seed=42)
+
+ default_summary = boot.summary()
+ explicit_90 = boot.summary(confidence_level=0.90)
+ pd.testing.assert_frame_equal(default_summary, explicit_90)
+
+ narrow_summary = boot.summary(confidence_level=0.50)
+ wide = default_summary.set_index("statistic")
+ narrow = narrow_summary.set_index("statistic")
+ for statistic in wide.index:
+ wide_width = cast(float, wide.at[statistic, "p_upper"]) - cast(
+ float, wide.at[statistic, "p_lower"]
+ )
+ narrow_width = cast(float, narrow.at[statistic, "p_upper"]) - cast(
+ float, narrow.at[statistic, "p_lower"]
+ )
+ assert narrow_width < wide_width
+
+ for level in (0.0, 1.0, -0.1, 1.5):
+ with pytest.raises(ValueError, match="confidence_level"):
+ boot.summary(confidence_level=level)
def test_bootstrap_is_reproducible() -> None:
@@ -1875,6 +1926,101 @@ def _flaky_backtest(*args: object, **kwargs: object): # type: ignore[no-untyped
)
+def test_stress_tests_with_custom_scenario_lists_produces_expected_names() -> None:
+ """Multiple configured values per scenario type must each produce their
+ own row, in the fixed order (commission, slippage, delay, best-days,
+ universe), and an empty list must produce none for that type."""
+ data = _panel()
+ config = _config().revalidated_copy(
+ update={
+ "robustness": _config().robustness.revalidated_copy(
+ update={
+ "stress_test": {
+ "enabled": True,
+ "commission_multipliers": [3.0],
+ "slippage_multipliers": [],
+ "execution_delays": [1, 2],
+ "best_days_removed": [5],
+ "reduce_universe_by": [1],
+ }
+ }
+ )
+ }
+ )
+ table = run_stress_tests(data, config)
+ assert list(table["scenario"]) == [
+ "baseline",
+ "commission x3",
+ "execution delay +1",
+ "execution delay +2",
+ "best 5 days removed",
+ "reduced universe (-1)",
+ ]
+ assert (table["status"] == "ok").all()
+
+
+def test_stress_tests_with_custom_scenario_lists_resumes_from_a_checkpoint(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Checkpoint/resume must stay correct when scenario counts per type
+ differ from the historical fixed 1-1-1-1-1 shape (the dynamic block/
+ row-count bookkeeping this rewrite introduced is the actual risk)."""
+ import quantlab.validation.robustness as robustness_module
+
+ data = _panel()
+ config = _config().revalidated_copy(
+ update={
+ "robustness": _config().robustness.revalidated_copy(
+ update={
+ "stress_test": {
+ "enabled": True,
+ "commission_multipliers": [2.0, 3.0],
+ "slippage_multipliers": [1.5],
+ "execution_delays": [1],
+ "best_days_removed": [10, 20],
+ "reduce_universe_by": [1],
+ }
+ }
+ )
+ }
+ )
+ checkpoint_path = tmp_path / "checkpoint.pkl"
+
+ real_backtest = robustness_module.run_backtest_from_config
+ calls = {"n": 0}
+
+ def _flaky_backtest(*args: object, **kwargs: object): # type: ignore[no-untyped-def]
+ calls["n"] += 1
+ # 1=baseline, 2=commission x2, 3=commission x3, 4=slippage x1.5 --
+ # interrupt mid-way through the cost scenarios.
+ if calls["n"] == 4:
+ raise RuntimeError("simulated interruption")
+ return real_backtest(*args, **kwargs) # type: ignore[arg-type]
+
+ monkeypatch.setattr(robustness_module, "run_backtest_from_config", _flaky_backtest)
+ with pytest.raises(RuntimeError, match="simulated interruption"):
+ run_stress_tests(data, config, checkpoint_path=checkpoint_path)
+ assert checkpoint_path.is_file()
+ monkeypatch.undo()
+
+ resumed = run_stress_tests(data, config, checkpoint_path=checkpoint_path)
+ assert not checkpoint_path.is_file()
+ fresh = run_stress_tests(data, config)
+ pd.testing.assert_frame_equal(
+ resumed.reset_index(drop=True), fresh.reset_index(drop=True)
+ )
+ assert list(resumed["scenario"]) == [
+ "baseline",
+ "commission x2",
+ "commission x3",
+ "slippage x1.5",
+ "execution delay +1",
+ "best 10 days removed",
+ "best 20 days removed",
+ "reduced universe (-1)",
+ ]
+
+
@pytest.mark.slow
def test_stress_tests_refuses_a_structurally_plausible_but_incoherent_checkpoint(
tmp_path: Path,
diff --git a/tests/unit/test_validation_hardening.py b/tests/unit/test_validation_hardening.py
index ebc0b2e..b845f70 100644
--- a/tests/unit/test_validation_hardening.py
+++ b/tests/unit/test_validation_hardening.py
@@ -105,11 +105,11 @@ def _config(*, benchmark: str | None = None) -> ExperimentConfig:
"mean_reversion",
{
"lookback_period": 20,
- "entry_zscore": 2.0,
- "exit_zscore": 0.5,
- "stop_zscore": 4.0,
+ "entry_threshold": 2.0,
+ "exit_threshold": 0.5,
+ "stop_threshold": 4.0,
},
- {"lookback_period", "entry_zscore"},
+ {"lookback_period", "entry_threshold"},
),
(
"trend_following",
@@ -122,12 +122,12 @@ def _config(*, benchmark: str | None = None) -> ExperimentConfig:
"symbol_a": "AAA",
"symbol_b": "BBB",
"formation_window": 252,
- "zscore_window": 63,
- "entry_zscore": 2.0,
- "exit_zscore": 0.5,
- "stop_zscore": 4.0,
+ "indicator_window": 63,
+ "entry_threshold": 2.0,
+ "exit_threshold": 0.5,
+ "stop_threshold": 4.0,
},
- {"formation_window", "zscore_window", "entry_zscore"},
+ {"formation_window", "indicator_window", "entry_threshold"},
),
],
)
@@ -170,6 +170,44 @@ def test_default_walk_forward_grid_covers_each_builtin_strategy_with_valid_combi
_with_params(config, combination)
+@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"])
+def test_default_grid_treats_null_thresholds_as_the_indicators_own_default(
+ strategy_name: str,
+) -> None:
+ parameters: dict[str, Any] = {
+ "entry_threshold": None,
+ "exit_threshold": None,
+ }
+ portfolio = (
+ {"allocator": "signal_proportional"} if strategy_name == "pairs_trading" else {}
+ )
+ if strategy_name == "pairs_trading":
+ parameters.update({"symbol_a": "AAA", "symbol_b": "BBB"})
+ config = ExperimentConfig.from_dict(
+ {
+ "experiment_name": f"grid_null_thresholds_{strategy_name}",
+ "data": {
+ "instruments": [
+ {"symbol": "AAA", "source": "csv", "calendar": "XNYS"},
+ {"symbol": "BBB", "source": "csv", "calendar": "XNYS"},
+ ],
+ "start_date": "2010-01-01",
+ "end_date": "2020-12-31",
+ },
+ "strategy": {"name": strategy_name, "parameters": parameters},
+ "portfolio": portfolio,
+ "backtest": {"benchmark_kind": "cash"},
+ }
+ )
+
+ grid = default_parameter_grid(config)
+
+ assert "entry_threshold" in grid
+ assert grid["entry_threshold"]
+ for combination in _grid_combinations(grid):
+ _with_params(config, combination)
+
+
def test_default_cross_sectional_long_short_grid_remains_disjoint() -> None:
config = ExperimentConfig.from_dict(
{
@@ -252,7 +290,7 @@ def _pairs_walk_forward_config() -> ExperimentConfig:
"symbol_a": "AAA",
"symbol_b": "BBB",
"formation_window": 252,
- "zscore_window": 63,
+ "indicator_window": 63,
},
},
"portfolio": {
@@ -277,8 +315,8 @@ def test_walk_forward_skips_only_structurally_unwarmed_pair_combinations(
validation=index[500:626],
test=index[626:752],
)
- insufficient = {"formation_window": 504, "zscore_window": 126}
- usable = {"formation_window": 252, "zscore_window": 63}
+ insufficient = {"formation_window": 504, "indicator_window": 126}
+ usable = {"formation_window": 252, "indicator_window": 63}
evaluated: list[dict[str, Any]] = []
def fake_evaluate(
@@ -290,7 +328,7 @@ def fake_evaluate(
evaluated.append(
{
"formation_window": candidate.strategy_parameters["formation_window"],
- "zscore_window": candidate.strategy_parameters["zscore_window"],
+ "indicator_window": candidate.strategy_parameters["indicator_window"],
}
)
# A sufficiently warmed-up strategy may legitimately choose to stay flat.
@@ -328,7 +366,7 @@ def test_walk_forward_explains_when_every_combination_is_still_in_warmup() -> No
WalkForwardValidator(config)._select_on_validation(
pd.DataFrame(),
window,
- [{"formation_window": 504, "zscore_window": 126}],
+ [{"formation_window": 504, "indicator_window": 126}],
_SCORERS["sharpe"],
periods_per_year=252,
risk_free_rate=0.0,
@@ -480,7 +518,7 @@ def fake_run(data: pd.DataFrame, config: ExperimentConfig, **_: object) -> objec
)
table = run_stress_tests(data, _config(benchmark="BENCH"))
assert "BENCH" in seen_reduced_symbols
- reduced = table.loc[table["scenario"] == "reduced universe"].iloc[0]
+ reduced = table.loc[table["scenario"] == "reduced universe (-1)"].iloc[0]
assert reduced["status"] == "ok"
@@ -502,7 +540,7 @@ def fake_run(data: pd.DataFrame, config: ExperimentConfig, **_: object) -> objec
}
)
table = run_stress_tests(data, _config())
- reduced = table.loc[table["scenario"] == "reduced universe"].iloc[0]
+ reduced = table.loc[table["scenario"] == "reduced universe (-1)"].iloc[0]
assert reduced["status"] == "failed"
assert "synthetic reduced-universe failure" in reduced["error"]
diff --git a/tests/unit/test_weight_drift.py b/tests/unit/test_weight_drift.py
new file mode 100644
index 0000000..6491861
--- /dev/null
+++ b/tests/unit/test_weight_drift.py
@@ -0,0 +1,1368 @@
+"""Direct tests for `apply_weight_drift` (the weight-drift feedback loop).
+
+Each test asserts against either a GENUINELY independent reference (a
+different computational path -- price levels and share counts, never a
+copy of `apply_weight_drift`'s own return-compounding recursion, which
+would silently reproduce the same bug it's meant to catch) or a precise
+hand-derived expectation.
+"""
+
+from __future__ import annotations
+
+from typing import Any, cast
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from quantlab.backtesting.accounting import apply_weight_drift, run_accounting
+from quantlab.constants import EPSILON
+from quantlab.exceptions import BacktestError
+
+
+def _reference_drift_from_prices(
+ executed: pd.DataFrame, asset_returns: pd.DataFrame
+) -> pd.DataFrame:
+ """Independent reference: reconstructs PRICE LEVELS from returns and
+ tracks SHARE-COUNT-implied dollar exposure plus an EXPLICIT residual
+ cash balance -- a genuinely different computational path from
+ `apply_weight_drift`'s own dollar/E return-compounding recursion, so
+ it cannot silently share the same bug.
+
+ ``price_entering[t]`` is each asset's price level just BEFORE row t's
+ own return is applied (i.e. entering row t) -- consistent with the
+ expectation that ``executed[t]`` (this function's own output, and
+ `apply_weight_drift`'s) is likewise a PRE-period value. Any weight not
+ allocated at the anchor (``1 - sum(target)``) is tracked as explicit,
+ zero-return cash -- normalizing by ``sum(dollar)`` alone (ignoring
+ that residual) would be wrong for a partially-invested portfolio.
+ """
+ columns = executed.columns
+ growth = asset_returns.fillna(0.0) + 1.0
+ cum_growth = growth.cumprod()
+ price_entering = cum_growth.shift(1).fillna(1.0)
+
+ out = pd.DataFrame(0.0, index=executed.index, columns=columns)
+ shares = pd.Series(0.0, index=columns)
+ cash = 0.0
+ previous: np.ndarray | None = None
+ for raw_date in executed.index:
+ date = pd.Timestamp(raw_date)
+ row = cast("pd.Series", executed.loc[date])
+ row_np = row.to_numpy(dtype=float)
+ is_anchor = previous is None or bool(
+ np.any(np.abs(row_np - previous) > EPSILON)
+ )
+ previous = row_np
+ price_now = cast("pd.Series", price_entering.loc[date])
+ if is_anchor:
+ shares = (row / price_now.replace(0.0, np.nan)).fillna(0.0)
+ cash = 1.0 - float(row.sum())
+ out.loc[date] = row
+ else:
+ dollar = shares * price_now
+ total = cash + float(dollar.sum())
+ out.loc[date] = (dollar / total) if total != 0.0 else 0.0
+ return out
+
+
+def test_price_round_trip_produces_the_mathematically_correct_nav() -> None:
+ """50/50 A/B, A gains 10% then loses exactly 1/11 (9.0909...%), B
+ never moves, no rebalancing, no fees. The true final NAV is 1.0
+ exactly (A round-trips back to its starting price: 1.10 * 10/11 = 1.0)
+ and NOT ONE unit of turnover beyond the single initial entry should
+ ever be attributed to the pure-price drift in between."""
+ dates = pd.date_range("2024-01-01", periods=3, freq="D")
+ held = pd.DataFrame({"A": [0.5, 0.5, 0.5], "B": [0.5, 0.5, 0.5]}, index=dates)
+ asset_returns = pd.DataFrame(
+ {"A": [np.nan, 0.10, -1.0 / 11.0], "B": [np.nan, 0.0, 0.0]}, index=dates
+ )
+ from quantlab.config import ExecutionConfig
+ from quantlab.execution.execution_model import ExecutionModel
+
+ # First: zero costs, to check the exact NAV claim cleanly.
+ free_execution_model = ExecutionModel.from_config(ExecutionConfig())
+ free_result = run_accounting(
+ held, asset_returns, free_execution_model, 100_000.0, model_weight_drift=True
+ )
+ assert free_result.equity.iloc[-1] == pytest.approx(100_000.0, rel=1e-9)
+ # Turnover: 1.0 on the entry (0 -> 50/50), exactly 0.0 on the pure-
+ # drift row -- never "interpreted as a daily transaction".
+ assert free_result.turnover.tolist() == pytest.approx([0.0, 1.0, 0.0])
+
+ # Second: nonzero costs -- if drift were ever mistaken for a trade,
+ # this would show up as a nonzero cost drag on the pure-drift row.
+ execution_model = ExecutionModel.from_config(
+ ExecutionConfig(commission_bps=10.0, spread_bps=10.0, slippage_bps=10.0)
+ )
+ result = run_accounting(
+ held, asset_returns, execution_model, 100_000.0, model_weight_drift=True
+ )
+ assert result.costs.total.iloc[2] == pytest.approx(0.0, abs=1e-9)
+ assert result.costs.total.iloc[1] > 0.0
+
+
+def test_hand_computed_drift_matches_a_genuinely_independent_price_reference() -> None:
+ dates = pd.date_range("2024-01-01", periods=5, freq="D")
+ executed = pd.DataFrame(
+ {
+ "A": [0.0, 0.5, 0.5, 0.5, 0.4],
+ "B": [0.0, 0.3, 0.3, 0.3, 0.2],
+ },
+ index=dates,
+ )
+ asset_returns = pd.DataFrame(
+ {
+ "A": [np.nan, 0.0, 0.10, 0.02, 0.0],
+ "B": [np.nan, 0.0, -0.05, 0.01, 0.0],
+ },
+ index=dates,
+ )
+ drifted, trade_changes, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ expected = _reference_drift_from_prices(executed, asset_returns)
+ pd.testing.assert_frame_equal(drifted, expected, check_exact=False)
+ assert not provenance.drift_compliance_forced.to_numpy().any()
+ assert not provenance.drift_compliance_pending.to_numpy().any()
+
+ # Row 1 is the anchor (0 -> 0.5/0.3): a real trade, output == target.
+ assert drifted.loc[dates[1], "A"] == pytest.approx(0.5)
+ assert trade_changes.loc[dates[1], "A"] == pytest.approx(0.5)
+ # Row 2 is a pure-drift row, but row 1's OWN return was flat (0%), so
+ # the weight ENTERING row 2 is still unchanged at 0.5 -- zero trade.
+ assert cast(float, trade_changes.loc[dates[2]].abs().sum()) == pytest.approx(0.0)
+ assert drifted.loc[dates[2], "A"] == pytest.approx(0.5)
+ # Row 3 is where drift actually becomes visible: row 2's OWN +10%/-5%
+ # returns are what move the weight ENTERING row 3 away from 0.5/0.3.
+ assert cast(float, trade_changes.loc[dates[3]].abs().sum()) == pytest.approx(0.0)
+ assert drifted.loc[dates[3], "A"] != pytest.approx(0.5)
+ # Row 4 is a fresh anchor (a real rebalance) -- output exactly as
+ # given, and the trade delta is the REAL size (target minus whatever
+ # drift alone would have produced), not simply the row-to-row diff of
+ # `drifted` (which would conflate the rebalance with prior drift).
+ assert drifted.loc[dates[4], "A"] == pytest.approx(0.4)
+ assert drifted.loc[dates[4], "B"] == pytest.approx(0.2)
+ # The trade delta is the REAL size (target minus whatever drift alone
+ # would have produced entering row 4, i.e. row 3's own return applied
+ # on top of `drifted.loc[dates[3]]`) -- genuinely nonzero, and NOT
+ # simply `target - drifted.loc[dates[3]]` (that would ignore row 3's
+ # own return, which further moved the pre-anchor drifted value).
+ assert trade_changes.loc[dates[4], "A"] != pytest.approx(0.0)
+ row3 = pd.Timestamp(dates[3])
+ naive_diff = 0.4 - cast(float, drifted.loc[row3, "A"])
+ assert trade_changes.loc[dates[4], "A"] != pytest.approx(naive_diff)
+
+
+def test_pre_period_weight_is_not_double_counted_against_its_own_return() -> None:
+ """The core bug this rewrite fixes: `apply_weight_drift`'s output for
+ row t must be the weight HELD ENTERING row t (pre-return), never the
+ weight AFTER row t's own return has already been baked in -- reusing
+ the post-return value would double-count that return when accounting.py
+ multiplies it by `asset_returns[t]` a second time."""
+ dates = pd.date_range("2024-01-01", periods=3, freq="D")
+ executed = pd.DataFrame({"A": [0.5, 0.5, 0.5]}, index=dates)
+ asset_returns = pd.DataFrame({"A": [np.nan, 0.20, 0.0]}, index=dates)
+ drifted, trade_changes, _ = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ # Row 0 is the anchor: weight entering row 0 is 0.5 (the target itself).
+ assert drifted.loc[dates[0], "A"] == pytest.approx(0.5)
+ # Row 1: weight ENTERING row 1 -- i.e. BEFORE row 1's own (flat, 0%)
+ # return -- is still just 0.5 (only row 0's anchor has happened so
+ # far; row 1 hasn't earned its own return yet from this frame's point
+ # of view). A buggy "post-return" convention would instead already
+ # show row 0's +20% baked in here, which never happened for row 1.
+ assert drifted.loc[dates[1], "A"] == pytest.approx(0.5)
+ assert cast(float, trade_changes.loc[dates[1]].abs().sum()) == pytest.approx(0.0)
+
+
+def test_constant_target_on_schedule_still_rebalances_back_to_target() -> None:
+ """A scheduled rebalance whose freshly-decided target happens to
+ numerically equal the immediately preceding one (e.g. a constant
+ 50/50 target under a daily schedule) must still be treated as a real
+ trade back to target -- value-diffing `executed` against its own
+ previous row alone cannot detect this, since the two numbers are
+ identical. Without `rebalance_date`, the position would silently keep
+ drifting away from target forever with zero recorded turnover."""
+ dates = pd.date_range("2024-01-01", periods=4, freq="D")
+ executed = pd.DataFrame(
+ {"A": [0.5, 0.5, 0.5, 0.5], "B": [0.5, 0.5, 0.5, 0.5]}, index=dates
+ )
+ asset_returns = pd.DataFrame(
+ {"A": [0.0, 0.20, 0.0, 0.0], "B": [0.0, 0.0, 0.0, 0.0]}, index=dates
+ )
+ tradable = pd.DataFrame(True, index=dates, columns=["A", "B"])
+
+ # Without rebalance_date: the position drifts to ~54.55/45.45 after
+ # A's +20% and never snaps back, even though every day is nominally a
+ # scheduled (daily) rebalance.
+ without_schedule, _, _ = apply_weight_drift(
+ executed,
+ asset_returns,
+ tradable,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ assert without_schedule.loc[dates[2], "A"] == pytest.approx(0.6 / 1.1)
+ assert without_schedule.loc[dates[3], "A"] == pytest.approx(0.6 / 1.1)
+
+ # With rebalance_date=True every day (a daily schedule): the position
+ # correctly snaps back to 50/50 the next row after it drifted -- the fix.
+ rebalance_date = pd.DataFrame(True, index=dates, columns=["A", "B"])
+ with_schedule, trade_changes, _ = apply_weight_drift(
+ executed,
+ asset_returns,
+ tradable,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ rebalance_date=rebalance_date,
+ )
+ assert with_schedule.loc[dates[2], "A"] == pytest.approx(0.5)
+ assert with_schedule.loc[dates[3], "A"] == pytest.approx(0.5)
+ # A real, nonzero trade is recorded snapping back to target.
+ assert cast(float, trade_changes.loc[dates[2]].abs().sum()) > 0.0
+
+
+def test_rebalance_date_between_schedule_dates_still_drifts_normally() -> None:
+ """A non-daily schedule (`rebalance_date` True only on a few rows)
+ must still let drift accumulate normally BETWEEN those rows -- the
+ fix must not force a snap-back on every row, only on genuine
+ scheduled dates."""
+ dates = pd.date_range("2024-01-01", periods=5, freq="D")
+ executed = pd.DataFrame(
+ {"A": [0.5] * 5, "B": [0.5] * 5},
+ index=dates,
+ )
+ asset_returns = pd.DataFrame(
+ {"A": [0.0, 0.20, 0.0, 0.0, 0.0], "B": [0.0, 0.0, 0.0, 0.0, 0.0]},
+ index=dates,
+ )
+ tradable = pd.DataFrame(True, index=dates, columns=["A", "B"])
+ # Rebalance only on the first and last row (like a weekly/monthly
+ # schedule where the middle rows aren't rebalance dates).
+ rebalance_date = pd.DataFrame(
+ {
+ "A": [True, False, False, False, True],
+ "B": [True, False, False, False, True],
+ },
+ index=dates,
+ )
+
+ drifted, trade_changes, _ = apply_weight_drift(
+ executed,
+ asset_returns,
+ tradable,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ rebalance_date=rebalance_date,
+ )
+ # Drift continues normally through the non-rebalance rows.
+ assert drifted.loc[dates[2], "A"] == pytest.approx(0.6 / 1.1)
+ assert drifted.loc[dates[3], "A"] == pytest.approx(0.6 / 1.1)
+ # The scheduled rebalance date snaps back to target with a real trade.
+ assert drifted.loc[dates[4], "A"] == pytest.approx(0.5)
+ assert cast(float, trade_changes.loc[dates[4]].abs().sum()) > 0.0
+
+
+def test_closed_asset_dollar_frozen_but_weight_still_drifts() -> None:
+ """A closed asset's own dollar exposure does not move (its
+ `asset_return` is 0 on a synthetic closure bar), but its WEIGHT still
+ drifts purely through E's own movement from the other, tradable
+ asset's real return."""
+ dates = pd.date_range("2024-01-01", periods=4, freq="D")
+ executed = pd.DataFrame(
+ {"A": [0.5, 0.5, 0.5, 0.5], "B": [0.3, 0.3, 0.3, 0.3]}, index=dates
+ )
+ asset_returns = pd.DataFrame(
+ {"A": [np.nan, 0.0, 0.20, 0.0], "B": [np.nan, 0.0, 0.0, 0.0]}, index=dates
+ )
+ drifted, _, _ = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ # Weight ENTERING row 3 reflects row 2's +20% A move (E grew to 1.10);
+ # B's dollar exposure never moved, but its weight (0.3/1.10) shrank.
+ assert drifted.loc[dates[3], "B"] == pytest.approx(0.3 / 1.10)
+ assert drifted.loc[dates[3], "B"] != pytest.approx(0.3)
+
+
+def test_bankruptcy_guard_flattens_and_never_produces_inf_or_nan() -> None:
+ """A leveraged/short scenario engineered so relative E crosses <= 0.
+ The row whose OWN return causes the ruin still reports its real
+ (catastrophic) pre-ruin weight -- only the FOLLOWING row is flat."""
+ dates = pd.date_range("2024-01-01", periods=4, freq="D")
+ executed = pd.DataFrame(
+ {"A": [2.0, 2.0, 2.0, 2.0]}, index=dates
+ ) # 2x leveraged long
+ asset_returns = pd.DataFrame(
+ {"A": [np.nan, -0.60, 0.0, 0.05]}, index=dates
+ ) # -60% move: gross_return = 2.0 * -0.60 = -1.20 -> E <= 0 by row 2
+ drifted, trade_changes, _ = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ values = drifted.to_numpy()
+ assert np.isfinite(values).all()
+ assert not np.isnan(values).any()
+ # Row 0 is the anchor (2.0 decided/executed there); row 1 is the
+ # first drift row, and the weight ENTERING it is still 2.0 (nothing
+ # has drifted yet -- row 0 itself had a flat/undefined return).
+ assert drifted.loc[dates[1], "A"] == pytest.approx(2.0)
+ # Row 1's OWN return (-60%) is what wipes the position out (gross_
+ # return = 2.0 * -0.60 = -1.20 -> E <= 0 while advancing past row 1) --
+ # so row 2 is the first row whose ENTERING weight is force-flattened.
+ assert drifted.loc[dates[2], "A"] == pytest.approx(0.0)
+ # No phantom closing trade is charged for the forced flatten itself.
+ assert trade_changes.loc[dates[2], "A"] == pytest.approx(0.0)
+
+
+def test_maximum_weight_breach_correction_lands_next_row_never_same_row() -> None:
+ """Look-ahead / temporal-convention test: a breach detected at row t
+ must leave row t's own (entering-t) output untouched (bit-for-bit
+ identical to a no-cap control run), and the correction must land
+ starting row t+1, never at t itself."""
+ dates = pd.date_range("2024-01-01", periods=5, freq="D")
+ executed = pd.DataFrame({"A": [0.0, 0.5, 0.5, 0.5, 0.5]}, index=dates)
+ asset_returns = pd.DataFrame({"A": [np.nan, 0.0, 1.0, 0.0, 0.0]}, index=dates)
+
+ uncapped, _, _ = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ capped, capped_changes, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=0.6,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+
+ # Weight ENTERING row 3 already reflects row 2's +100% A move (E grew
+ # to 1.5, weight = 1.0/1.5 = 0.6667) -- genuinely breaching 0.6.
+ breach_row = pd.Timestamp(dates[3])
+ assert uncapped.loc[breach_row, "A"] == pytest.approx(1.0 / 1.5)
+ assert capped.loc[breach_row, "A"] == pytest.approx(uncapped.loc[breach_row, "A"])
+ assert cast(float, capped.loc[breach_row, "A"]) > 0.6
+ assert bool(provenance.drift_compliance_pending.loc[breach_row, "A"])
+
+ landed_row = pd.Timestamp(dates[4])
+ assert capped.loc[landed_row, "A"] == pytest.approx(0.6)
+ assert bool(provenance.drift_compliance_forced.loc[landed_row, "A"])
+ assert capped_changes.loc[landed_row, "A"] == pytest.approx(
+ 0.6 - cast(float, capped.loc[breach_row, "A"])
+ )
+
+
+def test_position_group_correction_moves_both_legs_coherently() -> None:
+ """A declared position group's breach correction must scale both legs
+ via one shared `k_g`, preserving the drifted ratio exactly -- never a
+ single leg moving alone."""
+ dates = pd.date_range("2024-01-01", periods=6, freq="D")
+ executed = pd.DataFrame(
+ {
+ "X": [0.0, 0.45, 0.45, 0.45, 0.45, 0.45],
+ "Y": [0.0, -0.20, -0.20, -0.20, -0.20, -0.20],
+ },
+ index=dates,
+ )
+ asset_returns = pd.DataFrame(
+ {
+ "X": [np.nan, 0.0, 0.0, 0.30, 0.0, 0.0],
+ "Y": [np.nan, 0.0, 0.0, 0.0, 0.0, 0.0],
+ },
+ index=dates,
+ )
+ drifted, _, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ [("X", "Y")],
+ maximum_weight=0.5,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ # Row 1 is the anchor; row 3's OWN +30% X return (applied while
+ # advancing past row 3) is what makes the weight ENTERING row 4 breach.
+ breach_row = pd.Timestamp(dates[4])
+ assert cast(float, drifted.loc[breach_row, "X"]) > 0.5
+ breach_ratio = cast(float, drifted.loc[breach_row, "X"]) / cast(
+ float, drifted.loc[breach_row, "Y"]
+ )
+
+ landed_row = pd.Timestamp(dates[5])
+ assert drifted.loc[landed_row, "X"] == pytest.approx(0.5)
+ assert bool(provenance.drift_compliance_forced.loc[landed_row, "X"])
+ assert bool(provenance.drift_compliance_forced.loc[landed_row, "Y"])
+ landed_ratio = cast(float, drifted.loc[landed_row, "X"]) / cast(
+ float, drifted.loc[landed_row, "Y"]
+ )
+ assert landed_ratio == pytest.approx(breach_ratio)
+
+
+def test_closed_asset_responsible_for_breach_resolves_once_it_reopens() -> None:
+ """A breach caused by a currently-untradable asset cannot be fixed
+ immediately -- the residual is carried as `pending` and resolves once
+ the asset reopens, landing one bar after the LP can finally solve it
+ without slack."""
+ dates = pd.date_range("2024-01-01", periods=7, freq="D")
+ executed = pd.DataFrame({"A": [0.0, 0.4, 0.4, 0.4, 0.4, 0.4, 0.4]}, index=dates)
+ asset_returns = pd.DataFrame(
+ {"A": [np.nan, 0.0, 0.0, 0.30, 0.0, 0.0, 0.0]}, index=dates
+ )
+ tradable = pd.DataFrame(
+ {"A": [True, True, True, False, False, True, True]}, index=dates
+ )
+ drifted, _, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ tradable,
+ None,
+ maximum_weight=0.45,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ # Row 1 is the anchor; row 3's OWN +30% return (applied while
+ # advancing past row 3) is what makes the weight ENTERING row 4
+ # breach -- and row 4 is closed, so it is unresolvable that row.
+ breach_row = pd.Timestamp(dates[4])
+ assert drifted.loc[breach_row, "A"] == pytest.approx(0.52 / 1.12)
+ assert cast(float, drifted.loc[breach_row, "A"]) > 0.45
+ assert bool(provenance.drift_compliance_pending.loc[breach_row, "A"])
+ # Row 5 (`dates[5]`) is where A actually reopens -- the correction
+ # lands there, at the EARLIEST row it is achievable using that row's
+ # own information (not one row later): landing is not deferred an
+ # extra row beyond what tradability itself requires.
+ reopens_row = pd.Timestamp(dates[5])
+ assert drifted.loc[reopens_row, "A"] == pytest.approx(0.45)
+ assert bool(provenance.drift_compliance_forced.loc[reopens_row, "A"])
+ # Stays resolved on the following row -- no further correction needed.
+ assert drifted.loc[dates[6], "A"] == pytest.approx(0.45)
+ assert not bool(provenance.drift_compliance_forced.loc[dates[6], "A"])
+
+
+def test_model_weight_drift_defaults_to_no_drift_via_run_accounting() -> None:
+ """Regression gate: `run_accounting`'s `model_weight_drift=False`
+ reproduces the plain constant-weight step function exactly."""
+ from quantlab.config import ExecutionConfig
+ from quantlab.execution.execution_model import ExecutionModel
+
+ dates = pd.date_range("2024-01-01", periods=4, freq="D")
+ held = pd.DataFrame({"A": [0.5, 0.5, 0.5, 0.5]}, index=dates)
+ returns = pd.DataFrame({"A": [np.nan, 0.10, 0.02, -0.01]}, index=dates)
+ execution_model = ExecutionModel.from_config(ExecutionConfig())
+ result = run_accounting(
+ held, returns, execution_model, 100_000.0, model_weight_drift=False
+ )
+ # Constant-weight step function: every row's executed weight equals
+ # the held weight shifted by exactly one period (no drift).
+ assert result.executed_weights.loc[dates[2], "A"] == pytest.approx(0.5)
+ assert not result.drift_compliance_forced.to_numpy().any()
+ assert not result.drift_compliance_pending.to_numpy().any()
+
+
+def test_run_accounting_rebalance_date_forces_on_schedule_rebalance() -> None:
+ """`run_accounting`'s own `rebalance_date` parameter must reach
+ `apply_weight_drift` and produce real turnover on a scheduled date
+ whose freshly-decided target coincidentally matches the previous one
+ -- not silently absorbed into drift. Without `rebalance_date`,
+ turnover on that date is exactly zero; with it, nonzero."""
+ from quantlab.config import ExecutionConfig
+ from quantlab.execution.execution_model import ExecutionModel
+
+ dates = pd.date_range("2024-01-01", periods=4, freq="D")
+ held = pd.DataFrame(
+ {"A": [0.5, 0.5, 0.5, 0.5], "B": [0.5, 0.5, 0.5, 0.5]}, index=dates
+ )
+ returns = pd.DataFrame(
+ {"A": [np.nan, 0.20, 0.0, 0.0], "B": [np.nan, 0.0, 0.0, 0.0]}, index=dates
+ )
+ execution_model = ExecutionModel.from_config(ExecutionConfig())
+
+ without_schedule = run_accounting(
+ held, returns, execution_model, 100_000.0, model_weight_drift=True
+ )
+ assert without_schedule.turnover.loc[dates[2]] == pytest.approx(0.0)
+ assert without_schedule.executed_weights.loc[dates[2], "A"] == pytest.approx(
+ 0.6 / 1.1
+ )
+
+ with_schedule = run_accounting(
+ held,
+ returns,
+ execution_model,
+ 100_000.0,
+ model_weight_drift=True,
+ rebalance_date=pd.DataFrame(True, index=dates, columns=["A", "B"]),
+ )
+ assert with_schedule.turnover.loc[dates[2]] > 0.0
+
+
+def test_closed_column_never_anchors_just_because_another_column_does() -> None:
+ """A closed instrument must never be force-reset to its stale decided
+ value just because a DIFFERENT, open instrument's own schedule/value
+ change anchors the same row -- it must keep drifting undisturbed,
+ with zero trade/turnover attributed to it, until it is itself
+ genuinely decided on a date it is actually tradable."""
+ dates = pd.date_range("2024-01-01", periods=4, freq="D")
+ executed = pd.DataFrame({"A": [0.5] * 4, "B": [0.5] * 4}, index=dates)
+ asset_returns = pd.DataFrame(
+ {"A": [0.0, 0.20, 0.0, 0.0], "B": [0.0, 0.0, 0.20, 0.0]}, index=dates
+ )
+ # A is closed on date[2], the day B's own schedule fires.
+ tradable = pd.DataFrame(True, index=dates, columns=["A", "B"])
+ tradable.loc[dates[2], "A"] = False
+ rebalance_date = pd.DataFrame(False, index=dates, columns=["A", "B"])
+ rebalance_date.loc[dates[2], "B"] = True
+
+ drifted, trade_changes, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ tradable,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ rebalance_date=rebalance_date,
+ )
+ # A keeps drifting from its own +20% move entering date[2] -- never
+ # reset to the stale 0.5 target just because B anchors this row.
+ assert drifted.loc[dates[2], "A"] == pytest.approx(0.6 / 1.1)
+ assert trade_changes.loc[dates[2], "A"] == pytest.approx(0.0)
+ # B genuinely anchors (its own schedule date): real trade recorded.
+ assert drifted.loc[dates[2], "B"] == pytest.approx(0.5)
+ assert trade_changes.loc[dates[2], "B"] != pytest.approx(0.0)
+ assert not provenance.drift_compliance_forced.to_numpy().any()
+
+ # A reopens on date[3] with no fresh decision of its own: it should
+ # continue drifting from where it actually was (still not snapped to
+ # any stale target), not suddenly reset either.
+ assert trade_changes.loc[dates[3], "A"] == pytest.approx(0.0)
+
+
+def test_full_portfolio_anchor_unaffected_by_partial_anchor_logic() -> None:
+ """When EVERY column anchors together (the ordinary single-calendar
+ case), behavior must stay byte-identical to a plain whole-row reset:
+ both columns land exactly on target with a real trade, and `E`/`
+ dollar` fully renormalize."""
+ dates = pd.date_range("2024-01-01", periods=3, freq="D")
+ executed = pd.DataFrame({"A": [0.5] * 3, "B": [0.5] * 3}, index=dates)
+ asset_returns = pd.DataFrame(
+ {"A": [0.0, 0.20, 0.0], "B": [0.0, 0.0, 0.0]}, index=dates
+ )
+ rebalance_date = pd.DataFrame(True, index=dates, columns=["A", "B"])
+ drifted, trade_changes, _ = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ rebalance_date=rebalance_date,
+ )
+ assert drifted.loc[dates[2], "A"] == pytest.approx(0.5)
+ assert drifted.loc[dates[2], "B"] == pytest.approx(0.5)
+ assert cast(float, trade_changes.loc[dates[2]].abs().sum()) > 0.0
+
+
+def test_maximum_turnover_caps_an_anchor_catch_up_and_carries_the_remainder() -> None:
+ """A scheduled anchor's catch-up trade must respect `maximum_turnover`
+ exactly like an ordinary decision-level rebalance does -- landing
+ partially, carrying the unresolved remainder forward, and never
+ exceeding the cap on any single row. The initial entry (magnitude 1.0
+ from the conventional `w_{-1} = 0` treatment `cap_turnover` already
+ uses) is itself subject to the same cap, so it needs several rows to
+ fully resolve before the interesting scheduled-catch-up scenario
+ (drift away from a numerically unchanged target, then a schedule date)
+ even begins."""
+ n = 40
+ dates = pd.date_range("2024-01-01", periods=n, freq="D")
+ executed = pd.DataFrame({"A": [0.5] * n, "B": [0.5] * n}, index=dates)
+ asset_returns = pd.DataFrame({"A": [0.0] * n, "B": [0.0] * n}, index=dates)
+ asset_returns.loc[dates[20], "A"] = 0.20
+ rebalance_date = pd.DataFrame(False, index=dates, columns=["A", "B"])
+ rebalance_date.loc[dates[21]] = True
+ cap = 0.05
+
+ drifted, trade_changes, _ = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ rebalance_date=rebalance_date,
+ maximum_turnover=cap,
+ )
+ # Never exceeds the cap on any row, including the initial entry.
+ assert trade_changes.abs().sum(axis=1).max() <= cap + 1e-9
+ # The initial entry (from an implicit all-cash start) fully resolves
+ # well before the drift/schedule scenario begins at row 20.
+ assert drifted.loc[dates[19], "A"] == pytest.approx(0.5)
+ assert drifted.loc[dates[19], "B"] == pytest.approx(0.5)
+ # The schedule fires at row 21, but the catch-up doesn't land in one
+ # shot -- it is genuinely throttled by the cap.
+ assert drifted.loc[dates[21], "A"] == pytest.approx(0.5204545454545454)
+ assert cast(float, trade_changes.loc[dates[21]].abs().sum()) == pytest.approx(cap)
+ # The remainder is fully caught up by the very next row, with no
+ # further schedule/value trigger needed.
+ assert drifted.loc[dates[22], "A"] == pytest.approx(0.5, abs=1e-9)
+ assert drifted.loc[dates[22], "B"] == pytest.approx(0.5, abs=1e-9)
+ assert drifted.loc[dates[-1], "A"] == pytest.approx(0.5, abs=1e-9)
+
+
+def test_partial_anchor_respects_maximum_turnover_and_debt_survives_a_closure() -> None:
+ """Two invariants checked together: (1) `maximum_turnover` must bound
+ a MIXED-tradability partial anchor's catch-up exactly like a
+ whole-portfolio one -- never applying a partial anchor's fresh
+ decision directly and uncapped; (2) a turnover-capped catch-up debt
+ must never trade a column while it is closed -- never chasing
+ `catchup_target` on a column regardless of that column's own
+ tradability. A is tradable only for the very first row (its own
+ capped entry), then closed for the rest of the window, while B
+ anchors on every row and is always tradable."""
+ dates = pd.date_range("2024-01-01", periods=4, freq="D")
+ executed = pd.DataFrame({"A": [0.3] * 4, "B": [0.3, 0.3, 0.9, 0.9]}, index=dates)
+ asset_returns = pd.DataFrame({"A": [0.0] * 4, "B": [0.0] * 4}, index=dates)
+ tradable = pd.DataFrame(
+ {"A": [True, False, False, False], "B": [True] * 4}, index=dates
+ )
+ cap = 0.2
+
+ drifted, trade_changes, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ tradable,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ maximum_turnover=cap,
+ )
+ # Never exceeds the cap on any row, including the mixed-tradability
+ # partial-anchor rows.
+ assert trade_changes.abs().sum(axis=1).max() <= cap + 1e-9
+ # A's own entry (target 0.3) is capped below its full size on row 0
+ # (shared budget with B's own simultaneous entry) and then genuinely
+ # frozen -- zero further trade -- for every row it stays closed,
+ # never silently caught up while untradable.
+ assert drifted.loc[dates[0], "A"] == pytest.approx(0.1)
+ assert drifted.loc[dates[0], "A"] != pytest.approx(0.3)
+ for date in dates[1:]:
+ assert drifted.loc[date, "A"] == pytest.approx(0.1)
+ assert trade_changes.loc[date, "A"] == pytest.approx(0.0)
+ # B's own debt (entry, then the row-2 anchor to 0.9) keeps resolving,
+ # entirely unaffected by A sitting closed with its own debt untouched.
+ b_last = cast(float, drifted.loc[dates[-1], "B"])
+ b_first = cast(float, drifted.loc[dates[0], "B"])
+ assert b_last < 0.9
+ assert b_last > b_first
+ assert not provenance.drift_compliance_forced.to_numpy().any()
+ assert not provenance.drift_compliance_pending.to_numpy().any()
+
+
+def test_a_new_partial_decision_never_wipes_an_unrelated_columns_debt() -> None:
+ """A fresh decision on ONE column must only supersede that column's
+ own outstanding ordinary debt, never wipe an unrelated column's own
+ still-resolving debt or the WHOLE portfolio's turnover-catch-up
+ state. Scenario: target A=B=1, a
+ turnover-capped first fill lands both at 0.25, then a fresh decision
+ arrives for B ALONE (B=0.80) -- A must
+ keep converging toward 1.0, and B must converge toward its OWN new
+ target without exceeding the shared cap."""
+ dates = pd.date_range("2024-01-01", periods=3, freq="D")
+ executed = pd.DataFrame({"A": [1.0, 1.0, 1.0], "B": [1.0, 1.0, 0.80]}, index=dates)
+ asset_returns = pd.DataFrame({"A": [0.0] * 3, "B": [0.0] * 3}, index=dates)
+ cap = 0.5
+
+ drifted, trade_changes, _ = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ maximum_turnover=cap,
+ )
+ assert trade_changes.abs().sum(axis=1).max() <= cap + 1e-9
+ # First (shared) fill: both capped at 0.25.
+ assert drifted.loc[dates[0], "A"] == pytest.approx(0.25)
+ assert drifted.loc[dates[0], "B"] == pytest.approx(0.25)
+ # A's OWN debt (toward 1.0) keeps converging even after B gets its own
+ # fresh, unrelated decision -- never stuck at 0.25 forever.
+ a1 = cast(float, drifted.loc[dates[1], "A"])
+ a2 = cast(float, drifted.loc[dates[2], "A"])
+ assert a2 > a1 > 0.25
+ # B converges toward its NEW target (0.80), never overshooting past it.
+ b2 = cast(float, drifted.loc[dates[2], "B"])
+ assert b2 <= 0.80 + 1e-9
+ assert b2 > 0.25
+
+
+def test_mixing_closed_drift_with_a_fresh_partial_target_stays_compliant() -> None:
+ """Regression test: combining a currently-closed, already-drifted
+ column with another column's freshly-decided partial target can
+ create a NEW hard-limit violation neither had alone -- this must
+ never be visible in the output, not even for one row, since every
+ input needed to detect and fix it is already known before this row's
+ own output is finalized (unlike organic drift, which genuinely needs
+ a one-row lag). `maximum_gross_exposure=1.1` is chosen so drift is
+ load-bearing: A's own UNDRAFTED target (0.5) plus B's fresh target
+ (0.6) sum to exactly 1.1 -- compliant on its own, proven by the
+ no-drift control below -- and it is only A's organic drift (closed,
+ to 0.5833 after its own 0.4 return) combined with B's fresh decision
+ that tips gross exposure over the cap."""
+ dates = pd.date_range("2024-01-01", periods=4, freq="D")
+ executed = pd.DataFrame(
+ {"A": [0.5, 0.5, 0.5, 0.5], "B": [0.3, 0.3, 0.3, 0.6]}, index=dates
+ )
+ asset_returns = pd.DataFrame(
+ {"A": [0.0, 0.4, 0.0, 0.0], "B": [0.0] * 4}, index=dates
+ )
+ tradable = pd.DataFrame(
+ {"A": [True, True, False, False], "B": [True] * 4}, index=dates
+ )
+ cap = 1.1
+
+ # Control: with no drift at all (A's own return held at zero), the
+ # same executed/tradable/cap combination never breaches -- proving
+ # the violation below genuinely requires drift, not just mixing.
+ no_drift_returns = pd.DataFrame({"A": [0.0] * 4, "B": [0.0] * 4}, index=dates)
+ control, _, control_provenance = apply_weight_drift(
+ executed,
+ no_drift_returns,
+ tradable,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=cap,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ assert control.abs().sum(axis=1).max() <= cap + 1e-9
+ assert not control_provenance.drift_compliance_forced.to_numpy().any()
+
+ drifted, _, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ tradable,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=cap,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ gross = drifted.abs().sum(axis=1)
+ assert gross.max() <= cap + 1e-9
+ # The mixing row (B's fresh target combined with A's closed, drifted
+ # value) is corrected in the SAME row -- no look-ahead lag needed.
+ assert bool(provenance.drift_compliance_forced.loc[dates[3], "B"])
+ assert not bool(provenance.drift_compliance_pending.loc[dates[3]].any())
+
+
+def test_drift_compliance_forced_correction_is_exempt_from_the_turnover_cap() -> None:
+ """A hard-risk-limit-forced drift-compliance correction must NEVER be
+ subject to `maximum_turnover` -- capping it could leave a genuine
+ `maximum_weight`/exposure breach uncorrected indefinitely, which is
+ strictly worse than a large one-off corrective trade. Enough rows are
+ given for the (also turnover-capped) initial entry to fully resolve
+ well before the drift/violation scenario begins."""
+ n = 130
+ dates = pd.date_range("2024-01-01", periods=n, freq="D")
+ executed = pd.DataFrame({"A": [0.5] * n, "B": [0.5] * n}, index=dates)
+ asset_returns = pd.DataFrame({"A": [0.0] * n, "B": [0.0] * n}, index=dates)
+ asset_returns.loc[dates[110], "A"] = 1.0
+ cap = 0.01
+
+ drifted, trade_changes, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=0.55,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ maximum_turnover=cap,
+ )
+ assert drifted.loc[dates[109], "A"] == pytest.approx(0.5)
+ # Row 111 (drift-only, no correction landed yet) genuinely breaches
+ # maximum_weight -- the breach is real and reported, not pre-empted.
+ assert drifted.loc[dates[111], "A"] == pytest.approx(2.0 / 3.0)
+ forced_row = provenance.drift_compliance_forced.any(axis=1)
+ assert forced_row.sum() == 1
+ landed_date = drifted.index[forced_row][0]
+ assert landed_date == dates[112]
+ # The correction's own trade size on its landing row exceeds the tiny
+ # turnover cap -- proving it was never throttled by it.
+ assert trade_changes.loc[landed_date].abs().sum() > cap
+ assert cast(float, drifted.loc[landed_date, "A"]) <= 0.55 + 1e-9
+
+
+def test_compliance_correction_is_never_delayed_behind_ordinary_debt() -> None:
+ """Regression test: a queued drift-compliance correction must be
+ checked and landed BEFORE ordinary rebalance debt is processed, every
+ row -- an unrelated column's own outstanding, still-resolving
+ turnover-capped debt must never delay or throttle a compliance
+ correction on a DIFFERENT column. C carries a large, slowly-resolving
+ ordinary debt the entire time A's own maximum_weight breach is
+ detected and lands -- A's correction still lands in full, on the same
+ exempt-from-`maximum_turnover` schedule as if C's debt did not exist."""
+ n = 130
+ dates = pd.date_range("2024-01-01", periods=n, freq="D")
+ executed = pd.DataFrame({"A": [0.5] * n, "B": [0.5] * n}, index=dates)
+ asset_returns = pd.DataFrame({"A": [0.0] * n, "B": [0.0] * n}, index=dates)
+ asset_returns.loc[dates[110], "A"] = 1.0
+ cap = 0.01
+ executed["C"] = 0.0
+ executed.loc[dates[50] :, "C"] = 0.2
+ asset_returns["C"] = 0.0
+
+ drifted, trade_changes, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=0.55,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ maximum_turnover=cap,
+ )
+ # C's own ordinary debt is genuinely still outstanding (mid-catch-up,
+ # not yet at its 0.2 target) when A's compliance breach lands.
+ forced_row = provenance.drift_compliance_forced["A"]
+ assert forced_row.any()
+ landed_date = drifted.index[forced_row][0]
+ assert 0.0 < cast(float, drifted.loc[landed_date, "C"]) < 0.2
+ # A's correction still lands in full, exempt from the tiny cap.
+ assert cast(float, drifted.loc[landed_date, "A"]) == pytest.approx(0.55)
+ assert cast(float, trade_changes.loc[landed_date, "A"]) != pytest.approx(0.0)
+ assert abs(cast(float, trade_changes.loc[landed_date, "A"])) > cap
+
+
+def test_landed_compliance_correction_never_uses_a_stale_closed_asset_value() -> None:
+ """Regression test: when a queued drift-compliance correction finally
+ lands, a column the LP never moved (here, A -- closed after its own
+ initial entry, fixed by the LP's own equality constraint) must
+ reflect its CURRENT, naturally-continued weight at landing time,
+ never the STALE value it had back on the row the breach was first
+ detected. A closed asset's weight keeps drifting via `E` even while a
+ compliance correction is pending elsewhere; landing the stale
+ detection-time snapshot would silently force-reset it -- the exact
+ closed-asset-gets-traded bug this function exists to prevent, just
+ reached through the compliance path instead of the schedule-anchor
+ path."""
+ dates = pd.date_range("2024-01-01", periods=6, freq="D")
+ executed = pd.DataFrame({"A": [0.5] * 6, "B": [0.3] * 6}, index=dates)
+ asset_returns = pd.DataFrame(
+ {"A": [0.0] * 6, "B": [0.0, 1.0, 0.3, 0.2, 0.0, 0.0]}, index=dates
+ )
+ # A is tradable only long enough to enter at the anchor row, then
+ # closed for the rest of the window.
+ tradable = pd.DataFrame(
+ {"A": [True, False, False, False, False, False], "B": [True] * 6}, index=dates
+ )
+
+ drifted, trade_changes, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ tradable,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=0.85,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ # Breach detected at row 3 (2024-01-04, gross ~0.865 > 0.85), landed
+ # at row 4 (2024-01-05) -- the standard one-row-lag temporal
+ # convention, unaffected by this fix.
+ assert bool(provenance.drift_compliance_pending.loc[dates[3], "B"])
+ assert bool(provenance.drift_compliance_forced.loc[dates[4], "B"])
+ # A is never marked as moved/forced -- the LP fixed it, never traded it.
+ assert not provenance.drift_compliance_forced.loc[dates[4], "A"]
+ assert trade_changes.loc[dates[4], "A"] == pytest.approx(0.0)
+ # A's own weight at landing time reflects its CURRENT continued drift
+ # (it moved between detection and landing purely because B's own
+ # further return shifted E), NOT the stale value frozen at detection.
+ assert drifted.loc[dates[3], "A"] == pytest.approx(0.337838, abs=1e-5)
+ assert drifted.loc[dates[4], "A"] == pytest.approx(0.305623, abs=1e-5)
+ assert drifted.loc[dates[4], "A"] != pytest.approx(drifted.loc[dates[3], "A"])
+ # The landed row is fully compliant.
+ assert cast(float, drifted.loc[dates[4]].abs().sum()) <= 0.85 + 1e-6
+
+
+def test_best_effort_correction_actually_lands_on_open_columns_while_blocked() -> None:
+ """Regression test: when the breach is caused by a currently-closed
+ column the LP cannot move (A, fixed by its own equality constraint,
+ genuinely too large to fix even by fully zeroing every other column),
+ `restore_drift_compliance` still proposes the best ACHIEVABLE
+ improvement using whatever IS tradable (B) -- this must actually be
+ APPLIED to the row's own output, not merely recomputed and discarded
+ every day while the correction sits pending forever. A surges (its
+ own price return, still open) right before closing, leaving a
+ genuinely irresolvable breach; B must still be walked toward zero on
+ the very next row, even though the overall breach remains `pending`
+ (A alone already exceeds the cap)."""
+ dates = pd.date_range("2024-01-01", periods=6, freq="D")
+ executed = pd.DataFrame({"A": [0.3] * 6, "B": [0.05] * 6}, index=dates)
+ asset_returns = pd.DataFrame({"A": [0.0] * 6, "B": [0.0] * 6}, index=dates)
+ asset_returns.loc[dates[2], "A"] = 3.0 # A quadruples right before closing.
+ tradable = pd.DataFrame(
+ {"A": [True, True, True, False, False, False], "B": [True] * 6}, index=dates
+ )
+
+ drifted, trade_changes, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ tradable,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=0.5,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+ # A's own surge (applied while advancing past row 2) breaches the cap
+ # entering row 3, the row A closes -- detected there, not yet acted on.
+ breach_row = pd.Timestamp(dates[3])
+ assert cast(float, drifted.loc[breach_row].abs().sum()) > 0.5
+ assert bool(provenance.drift_compliance_pending.loc[breach_row, "A"])
+ assert trade_changes.loc[breach_row, "B"] == pytest.approx(0.0)
+ # The following row: B is walked to its best achievable value (zero,
+ # the only feasible reduction) -- a REAL trade, not merely recomputed
+ # and left unapplied. The overall breach stays pending (A alone still
+ # exceeds the cap; nothing further is achievable), but B's own
+ # component of the fix is genuinely done.
+ applied_row = pd.Timestamp(dates[4])
+ assert drifted.loc[applied_row, "B"] == pytest.approx(0.0, abs=1e-9)
+ assert trade_changes.loc[applied_row, "B"] != pytest.approx(0.0)
+ assert bool(provenance.drift_compliance_pending.loc[applied_row, "A"])
+ # B never moves again once it has nothing left to give.
+ assert drifted.loc[dates[5], "B"] == pytest.approx(0.0, abs=1e-9)
+ assert trade_changes.loc[dates[5], "B"] == pytest.approx(0.0)
+
+
+def test_apply_weight_drift_rejects_a_non_boolean_rebalance_date_directly() -> None:
+ """`apply_weight_drift` itself -- not just `run_accounting` -- must
+ reject a non-boolean `rebalance_date` (e.g. the string `'False'`,
+ which would otherwise silently coerce to truthy) rather than letting
+ it corrupt anchor detection."""
+ dates = pd.date_range("2024-01-01", periods=3, freq="D")
+ executed = pd.DataFrame({"A": [0.5] * 3, "B": [0.5] * 3}, index=dates)
+ asset_returns = pd.DataFrame({"A": [0.0] * 3, "B": [0.0] * 3}, index=dates)
+ bad_rebalance_date = pd.DataFrame(
+ {"A": ["False", "True", "False"], "B": ["False", "True", "False"]},
+ index=dates,
+ )
+ with pytest.raises(BacktestError, match="boolean"):
+ apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ rebalance_date=bad_rebalance_date,
+ )
+
+
+def _valid_apply_weight_drift_args() -> tuple[pd.DataFrame, pd.DataFrame]:
+ dates = pd.date_range("2024-01-01", periods=3, freq="D")
+ executed = pd.DataFrame({"A": [0.5] * 3, "B": [0.5] * 3}, index=dates)
+ asset_returns = pd.DataFrame({"A": [0.0] * 3, "B": [0.0] * 3}, index=dates)
+ return executed, asset_returns
+
+
+@pytest.mark.parametrize(
+ ("override", "match"),
+ [
+ ("asset_returns_not_a_frame", "asset_returns must be a pandas DataFrame"),
+ ("asset_returns_dup_index", "asset_returns index must not contain duplicate"),
+ (
+ "asset_returns_dup_columns",
+ "asset_returns columns must not contain duplicate",
+ ),
+ ("asset_returns_missing_symbol", "asset_returns must cover every executed"),
+ ("asset_returns_non_numeric", "asset_returns must contain only numeric"),
+ ("asset_returns_infinity", "asset_returns must not contain Infinity"),
+ ("asset_returns_below_total_loss", r"asset_returns must not contain simple"),
+ (
+ "asset_returns_missing_held_return",
+ "asset_returns is missing a return for a held position",
+ ),
+ ("tradable_not_a_frame", "tradable must be a pandas DataFrame"),
+ ("tradable_dup_index", "tradable index must not contain duplicate"),
+ ("tradable_missing_values", "tradable must not contain missing values"),
+ ("tradable_non_boolean", "tradable must contain only boolean"),
+ ("rebalance_date_not_a_frame", "rebalance_date must be a pandas DataFrame"),
+ (
+ "rebalance_date_dup_index",
+ "rebalance_date index must not contain duplicate",
+ ),
+ (
+ "rebalance_date_axis_mismatch",
+ "rebalance_date must have the same dates and symbols",
+ ),
+ (
+ "rebalance_date_missing_values",
+ "rebalance_date must not contain missing values",
+ ),
+ ],
+)
+def test_apply_weight_drift_rejects_malformed_direct_call_arguments(
+ override: str, match: str
+) -> None:
+ """`apply_weight_drift` is a directly-callable public function (see its
+ own docstring) that must not silently accept malformed
+ `asset_returns`/`tradable`/`rebalance_date` -- unlike `run_accounting`,
+ a caller can invoke it directly with entirely unvalidated data. Each
+ case here reaches this function's own validation block, not
+ `run_accounting`'s (which never delegates to `apply_weight_drift` for
+ a malformed-input test, since it validates and raises first)."""
+ executed, valid_asset_returns = _valid_apply_weight_drift_args()
+ dates = executed.index
+ # Typed `Any`, not `pd.DataFrame | None`: several branches below
+ # deliberately assign a wrong-typed value (a bare string) to prove
+ # apply_weight_drift's own runtime validation rejects it -- that is
+ # the point of this test, not a type error to suppress per line.
+ asset_returns: Any = valid_asset_returns
+ tradable: Any = None
+ rebalance_date: Any = None
+
+ if override == "asset_returns_not_a_frame":
+ asset_returns = "not a frame"
+ elif override == "asset_returns_dup_index":
+ asset_returns = asset_returns.copy()
+ asset_returns.index = pd.DatetimeIndex([dates[0], dates[0], dates[2]])
+ elif override == "asset_returns_dup_columns":
+ asset_returns = asset_returns.copy()
+ asset_returns.columns = ["A", "A"]
+ elif override == "asset_returns_missing_symbol":
+ asset_returns = asset_returns.drop(columns=["B"])
+ elif override == "asset_returns_non_numeric":
+ asset_returns = asset_returns.astype(object)
+ asset_returns.iloc[1, 0] = "not a number"
+ elif override == "asset_returns_infinity":
+ asset_returns = asset_returns.copy()
+ asset_returns.iloc[1, 0] = np.inf
+ elif override == "asset_returns_below_total_loss":
+ asset_returns = asset_returns.copy()
+ asset_returns.iloc[1, 0] = -1.5
+ elif override == "asset_returns_missing_held_return":
+ asset_returns = asset_returns.copy()
+ asset_returns.iloc[1, 0] = np.nan
+ elif override == "tradable_not_a_frame":
+ tradable = "not a frame"
+ elif override == "tradable_dup_index":
+ tradable = pd.DataFrame(True, index=dates, columns=["A", "B"])
+ tradable.index = pd.DatetimeIndex([dates[0], dates[0], dates[2]])
+ elif override == "tradable_missing_values":
+ tradable = pd.DataFrame({"A": [True, None, True], "B": [True] * 3}, index=dates)
+ elif override == "tradable_non_boolean":
+ tradable = pd.DataFrame(
+ {"A": ["False", "True", "False"], "B": ["True"] * 3}, index=dates
+ )
+ elif override == "rebalance_date_not_a_frame":
+ rebalance_date = "not a frame"
+ elif override == "rebalance_date_dup_index":
+ rebalance_date = pd.DataFrame(False, index=dates, columns=["A", "B"])
+ rebalance_date.index = pd.DatetimeIndex([dates[0], dates[0], dates[2]])
+ elif override == "rebalance_date_axis_mismatch":
+ rebalance_date = pd.DataFrame(False, index=dates, columns=["A"])
+ elif override == "rebalance_date_missing_values":
+ rebalance_date = pd.DataFrame(
+ {"A": [False, None, False], "B": [False] * 3}, index=dates
+ )
+
+ with pytest.raises(BacktestError, match=match):
+ apply_weight_drift(
+ executed,
+ asset_returns,
+ tradable,
+ None,
+ maximum_weight=None,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ rebalance_date=rebalance_date,
+ )
+
+
+def test_forced_and_pending_are_never_both_true_for_the_same_cell(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Regression test: the same-row re-check (below the ordinary-debt
+ step) is a SECOND, independent `_try_restore` call that can re-
+ implicate a column Step 1 (the compliance-debt branch above) already
+ landed and marked `forced=True` THIS row -- e.g. Step 1 fully
+ resolves column A's own `maximum_weight` breach, but the resulting
+ gross exposure (combined with column B) is a NEW violation the
+ same-row re-check discovers, only partially fixable, re-marking A
+ `pending=True` too. `forced`/`pending` must never both be True for
+ the same cell in the same row -- whichever call's write to `landed`
+ is temporally LAST (the same-row re-check, since it runs after Step
+ 1) must be the one whose verdict survives, since it is what the
+ final output value actually reflects. `restore_drift_compliance` is
+ mocked to force this exact sequence deterministically -- the natural
+ LP's own behavior makes this specific overlap rare enough that a
+ hand-constructed numeric scenario proved too fragile to rely on."""
+ import quantlab.backtesting.accounting as acct_mod
+ from quantlab.portfolio.drift_compliance import DriftComplianceResult
+
+ dates = pd.date_range("2024-01-01", periods=2, freq="D")
+ executed = pd.DataFrame({"A": [0.5, 0.5], "B": [0.3, 0.3]}, index=dates)
+ asset_returns = pd.DataFrame({"A": [0.0, 0.0], "B": [0.0, 0.0]}, index=dates)
+
+ calls = {"n": 0}
+
+ def fake_restore(row: np.ndarray, *args: object, **kwargs: object) -> object:
+ calls["n"] += 1
+ if calls["n"] == 1:
+ # Row 0: initial maximum_weight breach on A -- carried pending.
+ return DriftComplianceResult(corrected=row.copy(), pending=True)
+ corrected = row.copy()
+ corrected[0] = 0.3 # A resolved to exactly the maximum_weight cap.
+ if calls["n"] == 2:
+ # Row 1, Step 1: A's own maximum_weight breach fully resolved.
+ # Gross exposure (A+B = 0.3+0.3 = 0.6) still exceeds the 0.5
+ # cap this leaves behind, so the same-row re-check below will
+ # find a genuine violation and actually invoke this mock again
+ # (its own entry guard checks real, unmocked constraints).
+ return DriftComplianceResult(corrected=corrected, pending=False)
+ # Row 1, same-row re-check: resolves the NEW gross-exposure
+ # violation, re-implicating A -- only partially achievable.
+ corrected2 = corrected.copy()
+ corrected2[0] = 0.25
+ return DriftComplianceResult(corrected=corrected2, pending=True)
+
+ monkeypatch.setattr(acct_mod, "restore_drift_compliance", fake_restore)
+
+ _, _, provenance = apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=0.3,
+ maximum_gross_exposure=0.5,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+
+ overlap = provenance.drift_compliance_forced & provenance.drift_compliance_pending
+ assert not overlap.to_numpy().any()
+ # The same-row re-check's own verdict (still pending) is what survives
+ # for A, matching the actual final landed value (0.25, not 0.3).
+ assert bool(provenance.drift_compliance_pending.loc[dates[1], "A"])
+ assert not bool(provenance.drift_compliance_forced.loc[dates[1], "A"])
+
+
+def test_a_row_believed_fully_restored_but_still_violating_raises(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Regression test: `restore_drift_compliance` reporting `pending=False`
+ (fully compliant) is trusted at face value -- a formulation bug in the
+ LP that still reports success despite leaving a real violation behind
+ must not silently slip through and get reported as a clean "compliance
+ restored" trade-log event. `restore_drift_compliance` is mocked to
+ return exactly this broken response (unchanged, still-breaching
+ `corrected`, `pending=False`) regardless of how many times the loop
+ calls it this row -- the row-walk must raise once it reaches the end
+ of the row still believing no compliance debt remains outstanding."""
+ import quantlab.backtesting.accounting as acct_mod
+ from quantlab.portfolio.drift_compliance import DriftComplianceResult
+
+ dates = pd.date_range("2024-01-01", periods=1, freq="D")
+ executed = pd.DataFrame({"A": [0.5]}, index=dates)
+ asset_returns = pd.DataFrame({"A": [np.nan]}, index=dates)
+
+ def broken_restore(row: np.ndarray, *args: object, **kwargs: object) -> object:
+ # Always claims success without actually fixing anything --
+ # simulates a bug in the LP's own formulation/status handling.
+ return DriftComplianceResult(corrected=row.copy(), pending=False)
+
+ monkeypatch.setattr(acct_mod, "restore_drift_compliance", broken_restore)
+
+ with pytest.raises(BacktestError, match="believed fully compliant"):
+ apply_weight_drift(
+ executed,
+ asset_returns,
+ None,
+ None,
+ maximum_weight=0.3,
+ maximum_gross_exposure=None,
+ maximum_net_exposure=None,
+ long_only=False,
+ )
+
+
+def _random_drift_scenario(rng: np.random.Generator) -> dict[str, object]:
+ """Build one randomized, but internally consistent, drift scenario."""
+ n_cols = int(rng.integers(2, 5))
+ n_rows = int(rng.integers(25, 60))
+ columns = [f"S{i}" for i in range(n_cols)]
+ dates = pd.date_range("2024-01-01", periods=n_rows, freq="D")
+
+ long_only = bool(rng.random() < 0.3)
+ # Anchor weights compliant by construction: split a random total gross
+ # budget across columns, with a random sign per column (all-positive
+ # under long_only).
+ gross_budget = float(rng.uniform(0.3, 0.9))
+ raw = rng.random(n_cols)
+ raw = raw / raw.sum() * gross_budget
+ signs = np.ones(n_cols) if long_only else rng.choice([-1.0, 1.0], size=n_cols)
+ anchor = raw * signs
+ executed = pd.DataFrame(np.tile(anchor, (n_rows, 1)), index=dates, columns=columns)
+
+ asset_returns = pd.DataFrame(
+ rng.normal(0.0, 0.03, size=(n_rows, n_cols)), index=dates, columns=columns
+ )
+ asset_returns.iloc[0] = np.nan
+ # Occasional larger shocks, to actually exercise compliance breaches
+ # and the LP, not just small in-bounds drift.
+ shock_mask = rng.random(size=(n_rows, n_cols)) < 0.08
+ asset_returns = asset_returns.mask(
+ shock_mask, asset_returns + rng.normal(0.0, 0.25, size=(n_rows, n_cols))
+ )
+ asset_returns.iloc[0] = np.nan
+
+ tradable = pd.DataFrame(
+ rng.random(size=(n_rows, n_cols)) > 0.15, index=dates, columns=columns
+ )
+ tradable.iloc[0] = True # the anchor row must be tradable
+
+ rebalance_date = pd.DataFrame(
+ rng.random(size=(n_rows, n_cols)) < 0.05, index=dates, columns=columns
+ )
+
+ maximum_weight = float(rng.uniform(0.15, 0.6)) if rng.random() < 0.7 else None
+ maximum_gross_exposure = (
+ float(rng.uniform(0.4, 1.5)) if rng.random() < 0.7 else None
+ )
+ maximum_net_exposure = float(rng.uniform(0.2, 1.2)) if rng.random() < 0.5 else None
+ maximum_turnover = float(rng.uniform(0.02, 0.3)) if rng.random() < 0.5 else None
+
+ return {
+ "executed": executed,
+ "asset_returns": asset_returns,
+ "tradable": tradable,
+ "rebalance_date": rebalance_date,
+ "maximum_weight": maximum_weight,
+ "maximum_gross_exposure": maximum_gross_exposure,
+ "maximum_net_exposure": maximum_net_exposure,
+ "long_only": long_only,
+ "maximum_turnover": maximum_turnover,
+ }
+
+
+def test_drift_invariants_hold_across_randomized_deterministic_scenarios() -> None:
+ """Deterministic fuzz test: across many randomized drift scenarios
+ (variable columns, returns including large shocks, tradability gaps,
+ schedules, and hard-risk-limit combinations), `apply_weight_drift`
+ must never produce a NaN/Inf weight, never raise (the internal
+ "believed fully compliant but still violates" guard alone already
+ re-verifies every non-pending row's actual compliance across every
+ draw below), and never let ordinary (non-compliance-forced) turnover
+ exceed `maximum_turnover`. A fixed seed keeps this reproducible."""
+ rng = np.random.default_rng(20260209)
+ n_scenarios = 200
+ exercised_compliance_forced = False
+ exercised_maximum_turnover_binding = False
+
+ for _ in range(n_scenarios):
+ scenario = _random_drift_scenario(rng)
+ drifted, trade_changes, provenance = apply_weight_drift(
+ cast(pd.DataFrame, scenario["executed"]),
+ cast(pd.DataFrame, scenario["asset_returns"]),
+ cast(pd.DataFrame, scenario["tradable"]),
+ None,
+ maximum_weight=cast("float | None", scenario["maximum_weight"]),
+ maximum_gross_exposure=cast(
+ "float | None", scenario["maximum_gross_exposure"]
+ ),
+ maximum_net_exposure=cast("float | None", scenario["maximum_net_exposure"]),
+ long_only=cast(bool, scenario["long_only"]),
+ rebalance_date=cast(pd.DataFrame, scenario["rebalance_date"]),
+ maximum_turnover=cast("float | None", scenario["maximum_turnover"]),
+ )
+
+ assert np.isfinite(drifted.to_numpy()).all()
+ assert np.isfinite(trade_changes.to_numpy()).all()
+
+ if provenance.drift_compliance_forced.to_numpy().any():
+ exercised_compliance_forced = True
+
+ maximum_turnover = scenario["maximum_turnover"]
+ if maximum_turnover is not None:
+ # Compliance-forced/pending rows are explicitly EXEMPT from
+ # maximum_turnover (see apply_weight_drift's own docstring) --
+ # only rows with no compliance activity at all are checked.
+ compliance_active = (
+ provenance.drift_compliance_forced.to_numpy()
+ | provenance.drift_compliance_pending.to_numpy()
+ ).any(axis=1)
+ ordinary_turnover = trade_changes.abs().sum(axis=1).to_numpy()
+ ordinary_only = ordinary_turnover[~compliance_active]
+ assert (ordinary_only <= cast(float, maximum_turnover) + 1e-6).all()
+ if len(ordinary_only) and (ordinary_only > 1e-9).any():
+ exercised_maximum_turnover_binding = True
+
+ # Not vacuous: the randomized shocks/caps must have actually exercised
+ # both the compliance-restoration path and a real turnover cap at
+ # least once across 200 draws.
+ assert exercised_compliance_forced
+ assert exercised_maximum_turnover_binding