Tutorial 06 - Cross-sectional rotation and portfolio construction

Tutorial Navigation

Track Tutorial notebook
Roadmap Tutorial 00 - Roadmap
Strategy Lab 01 Trend-Following Lab
Tutorial Sequence 01 Real Market Data and Feature Factory
Tutorial Sequence 02 Decomposition-aware MA and MACD
Strategy Lab 02 Oscillation-Reversion Lab
Strategy Expansion 03 Method-Specific Variants
Tutorial Sequence 03 Residual Mean Reversion
Strategy Expansion 04 Component Pair Trading
Tutorial Sequence 04 Donchian Breakout
Tutorial Sequence 05 Pair-Spread Stat-Arb
Tutorial Sequence 06 Cross-Sectional Rotation
Native SSA Replay 07 Native SSA High-Return / Low-Drawdown

Executed Notebook

This notebook turns DeTime features into a portfolio ranking language: trend decides direction, cycle adjusts timing, residual controls overextension and volume/reconstruction features control reliability.

In [1]
import matplotlib.pyplot as plt
import pandas as pd

from quant_trading.data import load_bundled_real_ohlcv_panel, ohlcv_audit_report
from quant_trading.features import walkforward_decompose_ohlcv
from quant_trading.decomposition_features import feature_coverage_report, build_feature_table
from quant_trading.strategy_baselines import buy_and_hold_weights
from quant_trading.strategy_rotation import (
    classic_momentum_rotation_weights, detime_cross_sectional_score,
    detime_long_short_rotation_weights, detime_rotation_weights,
    rotation_diagnostic_table, volume_availability,
)
from quant_trading.validation import compare_weight_strategies, turnover_report

1. Load real offline market data

In [2]
tickers = ["AUDUSD=X", "NZDUSD=X", "EURUSD=X", "GBPUSD=X"]
ohlcv = load_bundled_real_ohlcv_panel(tickers, min_observations=120)
ohlcv = {field: table.tail(760).copy() for field, table in ohlcv.items()}
prices = ohlcv["Close"]
volumes = ohlcv.get("Volume")
print("volume_available:", volume_availability(volumes))
ohlcv_audit_report(ohlcv)

2. Build walk-forward asset features

In [3]
features = walkforward_decompose_ohlcv(
    ohlcv,
    method="STL",
    period="auto",
    period_candidates=(63, 126, 252),
    train_window=504,
    step=5,
    z_window=63,
)
feature_coverage_report(features).query("feature in ['trend_slope', 'trend_strength', 'cycle_slope', 'residual_z', 'residual_abs_z', 'volume_participation']").head(18)
In [4]
build_feature_table(prices, features).tail(3).iloc[:, :12].round(4)

3. Inspect the DeTime rotation score

In [5]
score = detime_cross_sectional_score(prices, features)
score.tail(5).round(3)
In [6]
fig, ax = plt.subplots(figsize=(10, 4))
score.tail(120).plot(ax=ax, linewidth=1.1)
ax.axhline(0, color="black", linewidth=0.8)
ax.set_title("DeTime cross-sectional score on real FX samples")
ax.set_ylabel("Score")
ax.grid(True, alpha=0.25)
plt.tight_layout()
plt.show()
In [7]
rotation_diagnostic_table(prices, features, tail=2).round(4)

4. Backtest a compact rotation suite

In [8]
strategies = {
    "buy_hold_equal_weight": buy_and_hold_weights(prices),
    "classic_momentum_63_top": classic_momentum_rotation_weights(prices, lookback=63, top_n=2, rebalance_freq="W-FRI", vol_target=None),
    "detime_rotation_top_trend_cycle_residual_volume": detime_rotation_weights(prices, features, top_n=2, rebalance_freq="W-FRI", vol_target=None),
    "detime_rotation_long_short": detime_long_short_rotation_weights(prices, features, top_n=2, bottom_n=2),
}
comparison, results = compare_weight_strategies(prices, strategies, fee_bps=1.0, slippage_bps=2.0)
comparison[["total_return", "sharpe", "max_drawdown", "average_turnover"]].round(4)
In [9]
fig, ax = plt.subplots(figsize=(10, 4))
for name, result in results.items():
    result.equity.plot(ax=ax, linewidth=1.2, label=name)
ax.set_title("Rotation equity curves on real FX samples")
ax.set_ylabel("Equity")
ax.legend(loc="best", fontsize=8)
ax.grid(True, alpha=0.25)
plt.tight_layout()
plt.show()
In [10]
turnover_report(strategies).round(4)

5. Live-data extension

Run run_column_06_cross_sectional_rotation.py without --use-bundled-sample to use sector ETFs and real equity/ETF volume. In the bundled FX sample, raw volume is unavailable, so volume is neutral rather than invented.