Skip to content
All strategies

Strategies

Skipped-Month Formation Momentum: Long-Short US Sector ETFs

Outcome: Abandoned

SkippedMonthSectorMomentum

Outcome Summary

SkippedMonthSectorMomentum tested whether skipped-month formation momentum across eight US sector ETFs predicts the next 21 sessions, using a top-2 long / bottom-2 short book. Across 3 iterations the code never cleared Layer 4 QA review. The rotation logic sized new legs while the old cohort's closes were still in flight and depended on exact-timestamp re-entry, so cohorts were partial and unbalanced. The one sandbox measurement (Sharpe -0.02, PF 0.98, -0.125% per position over 178 positions) was judged not representative of the hypothesis. The run was abandoned before backtest review, optimization, analyst or risk stages.

Hypothesis

Test whether sector winners over a multi-month formation period outperform sector losers over the next 21 trading sessions. Trade eight liquid US sector ETFs using synchronized daily bars, buying the two highest-ranked sectors and shorting the two lowest-ranked sectors. Exclude the most recent 21 sessions from formation returns to separate medium-term persistence from immediate reversal. This is an adaptation, not a replication of the original stock-decile study. Evaluate subsequent executable returns, never the formation-period averages shown in the source.

The source contains a concrete cross-sectional momentum mechanism. Its original code accidentally exponentiates individual log returns rather than their rolling sum, while ranking must occur separately at each date. Preserve these corrected mechanics and test a skipped-month, sector-level version. Long-only exposure is the largest portfolio imbalance at 83.2%; this proposal adds long-short and multi-instrument exposure. Hyperliquid is underweight, but a relative-strength rotation already exists there. Options proposals face repeated structural failures. Sector formation-return ranking differs from the listed 52-week-high anchoring and calendar-entry failures. Rebalancing is an operational schedule, not a claimed calendar anomaly. Corpus concentration is unavailable, so low prior use of these particular instruments cannot be asserted.

verification_loop: Verification failed (Layer 4 — QA review) [class=code_defect]: - [edge_concern] Smoke test (unoptimized, formation=126): 178 closed positions, PF 0.98, Sharpe -0.02, -0.125% per position; after borrow costs that is near zero edge. The defect below means this did not measure the hypothesis as specified, because cohorts were partial and undersized. Re-measure after the fix before reading anything into it. Runtime was also high: 249 s for 1,653 daily bars on 8 names, with ~949 s projected for the……Show moreShow less

verification_loop: Verification failed (Layer 4 — QA review) [class=code_defect]: - [edge_concern] Smoke test (unoptimized, formation=126): 178 closed positions, PF 0.98, Sharpe -0.02, -0.125% per position; after borrow costs that is near zero edge. The defect below means this did not measure the hypothesis as specified, because cohorts were partial and undersized. Re-measure after the fix before reading anything into it. Runtime was also high: 249 s for 1,653 daily bars on 8 names, with ~949 s projected for the full history. For a per-bar O(8) loop that is unexpectedly slow and should be profiled. - [critical] Rotation does not rebuild the 2-long/2-short cohort. In calculate_signal, close_position is submitted for the old legs, then _open_pending() is called right away. The backtest SimulatedExchange queues commands (use_message_queue), and USEQ has latency_ms=100, so none of those closes have filled yet. (a) Newly selected names: `gross` still includes the full old cohort (~0.6 x equity), so `budget - gross` is ~0. That makes notional 0 (bit CLEARED, so the leg is dropped for the whole 21-session cycle) or leaves only a residual (an undersized leg). (b) Names that were held and are selected again: their bit is kept (`continue`) and they are meant to re-enter from on_position_closed. But the close fills at rotation_ts + 100 ms, so `self.clock.timestamp_ns() != self._rotation_ts` returns early, and nothing calls _open_pending before the next rotation. Those names stay flat for the cycle. Net effect: after the first rotation the book alternates between full and partial or empty cohorts and is not dollar-balanced long vs short. The sandbox shows this. About 65 rotations x 4 legs ≈ 260 expected positions, but 178 were recorded, and avg_position_pct was 13.7% vs the 15% target. The hypothesis (top-2 long / bottom-2 short every 21 sessions) is not what gets traded. (line 168) — fix: Use a persistent rotation state instead of exact-timestamp matching. Keep the pending bits until the next rotation. When computing `gross`, exclude positions that have a reduce-only close pending (they are being replaced). Size each leg off _rotation_equity * leg_equity_fraction. In on_position_closed, and as a fallback at the start of every primary bar, call _open_pending whenever bits remain and no prior-cohort close is still in flight. Drop the `clock == _rotation_ts` guard, or replace it with 'same rotation bucket'. Only clear a bit after an order is actually submitted, or when the size is - [warning] The portfolio drawdown halt (15%) is permanent for the rest of the run, with no re-arm. In a 25-year backtest one drawdown can end trading for good, which shrinks the sample. This is consistent with the stated description, so it is only a warning, but the analyst should know whether a halt fired. (line 133) — fix: Log the halt date. Consider re-arming at the next rotation after equity recovers, or a cool-off measured in sessions, if the hypothesis allows it. - [warning] Dates outside the supplied XNYS closure table fall back to a weekday count. That fallback is a different session numbering, so the rotation phase jumps at the table boundary (before 1998 / after 2030). It does not affect the sandbox or backtest window. (line 117) — fix: Acceptable for now. Extend the table, or use src/data/market_calendar if it is importable, so the phase stays continuous.

Implementation

Sector momentum strategy on 8 SPDR sector ETFs using daily USEQ bars. Each ETF is ranked by its formation-period return: the endpoint price ratio over formation_sessions, leaving out the most recent 21 sessions. Every 21 XNYS sessions, on a grid tied to the calendar, the strategy closes all legs, buys the top 2 ETFs and shorts the bottom 2. Each leg is 15% of equity (60% gross). Each leg has a 12% stop resting at the venue, and a 15% portfolio drawdown halts trading.

Iteration 3 fixes the Layer 2 failure. The synthetic test bars have timestamps that are not in the supplied XNYS closure table, and the strategy raised a ValueError on them. Dates outside the table now map to a session number counted from weekdays. That number depends only on the bar timestamp, so restarts, backtest and paper still agree. Dates inside the table keep the exact XNYS session grid. Nothing else changed.

Verification Results

Verification failed (Layer 4 — QA review) [class=code_defect]: - [edge_concern] Smoke test (unoptimized, formation=126): 178 closed positions, PF 0.98, Sharpe -0.02, -0.125% per position; after borrow costs that is near zero edge. The defect below means this did not measure the hypothesis as specified, because cohorts were partial and undersized. Re-measure after the fix before reading anything into it. Runtime was also high: 249 s for 1,653 daily bars on 8 names, with ~949 s projected for the full history. For a……Show moreShow less

Verification failed (Layer 4 — QA review) [class=code_defect]: - [edge_concern] Smoke test (unoptimized, formation=126): 178 closed positions, PF 0.98, Sharpe -0.02, -0.125% per position; after borrow costs that is near zero edge. The defect below means this did not measure the hypothesis as specified, because cohorts were partial and undersized. Re-measure after the fix before reading anything into it. Runtime was also high: 249 s for 1,653 daily bars on 8 names, with ~949 s projected for the full history. For a per-bar O(8) loop that is unexpectedly slow and should be profiled. - [critical] Rotation does not rebuild the 2-long/2-short cohort. In calculate_signal, close_position is submitted for the old legs, then _open_pending() is called right away. The backtest SimulatedExchange queues commands (use_message_queue), and USEQ has latency_ms=100, so none of those closes have filled yet. (a) Newly selected names: `gross` still includes the full old cohort (~0.6 x equity), so `budget - gross` is ~0. That makes notional 0 (bit CLEARED, so the leg is dropped for the whole 21-session cycle) or leaves only a residual (an undersized leg). (b) Names that were held and are selected again: their bit is kept (`continue`) and they are meant to re-enter from on_position_closed. But the close fills at rotation_ts + 100 ms, so `self.clock.timestamp_ns() != self._rotation_ts` returns early, and nothing calls _open_pending before the next rotation. Those names stay flat for the cycle. Net effect: after the first rotation the book alternates between full and partial or empty cohorts and is not dollar-balanced long vs short. The sandbox shows this. About 65 rotations x 4 legs ≈ 260 expected positions, but 178 were recorded, and avg_position_pct was 13.7% vs the 15% target. The hypothesis (top-2 long / bottom-2 short every 21 sessions) is not what gets traded. (line 168) — fix: Use a persistent rotation state instead of exact-timestamp matching. Keep the pending bits until the next rotation. When computing `gross`, exclude positions that have a reduce-only close pending (they are being replaced). Size each leg off _rotation_equity * leg_equity_fraction. In on_position_closed, and as a fallback at the start of every primary bar, call _open_pending whenever bits remain and no prior-cohort close is still in flight. Drop the `clock == _rotation_ts` guard, or replace it with 'same rotation bucket'. Only clear a bit after an order is actually submitted, or when the size is - [warning] The portfolio drawdown halt (15%) is permanent for the rest of the run, with no re-arm. In a 25-year backtest one drawdown can end trading for good, which shrinks the sample. This is consistent with the stated description, so it is only a warning, but the analyst should know whether a halt fired. (line 133) — fix: Log the halt date. Consider re-arming at the next rotation after equity recovers, or a cool-off measured in sessions, if the hypothesis allows it. - [warning] Dates outside the supplied XNYS closure table fall back to a weekday count. That fallback is a different session numbering, so the rotation phase jumps at the table boundary (before 1998 / after 2030). It does not affect the sandbox or backtest window. (line 117) — fix: Acceptable for now. Extend the table, or use src/data/market_calendar if it is importable, so the phase stays continuous.

Iteration History

Verification failed (Layer 1.5 — hypothesis/config consistency) [class=hypothesis_mismatch]: - optimization_plan fixes leverage at 1.0

Verification failed (Layer 2 — synthetic scenarios): Parameters used: ['leverage', 'min_notional', 'skip_sessions', 'stop_loss_pct', '_optimization_plan', 'formation_sessions', 'max_gross_exposure', 'rebalance_sessions', 'leg_equity_fraction', 'xnys_closure_offsets', 'selection_count_per_side', 'portfolio_drawdown_stop_pct'] Check that __init__ sets all attributes from self.parameters.get(). - steady_uptrend: ValueError: Session outside the supplied XNYS calendar; refresh calendar metadata (bar timestamp:……Show moreShow less

Verification failed (Layer 2 — synthetic scenarios): Parameters used: ['leverage', 'min_notional', 'skip_sessions', 'stop_loss_pct', '_optimization_plan', 'formation_sessions', 'max_gross_exposure', 'rebalance_sessions', 'leg_equity_fraction', 'xnys_closure_offsets', 'selection_count_per_side', 'portfolio_drawdown_stop_pct'] Check that __init__ sets all attributes from self.parameters.get(). - steady_uptrend: ValueError: Session outside the supplied XNYS calendar; refresh calendar metadata (bar timestamp: 1735705980000) - steady_downtrend: ValueError: Session outside the supplied XNYS calendar; refresh calendar metadata (bar timestamp: 1735705980000) - flat_ranging: ValueError: Session outside the supplied XNYS calendar; refresh calendar metadata (bar timestamp: 1735705980000) - volatility_spike: ValueError: Session outside the supplied XNYS calendar; refresh calendar metadata (bar timestamp: 1735705980000) - zero_volume: ValueError: Session outside the supplied XNYS calendar; refresh calendar metadata (bar timestamp: 1735705980000) - price_gap: ValueError: Session outside the supplied XNYS calendar; refresh calendar metadata (bar timestamp: 1735705980000)

Outcome Summary

In event-driven multi-leg rotations, keep rotation state across bars and size new legs off rotation equity, excluding positions with closes in flight; do not assume closes fill at the same timestamp they were submitted.

After 3 iterations the run was abandoned in the verification loop because Layer 4 QA review failed with a critical code defect. Rotation did not rebuild the 2-long/2-short cohort: closes were still pending under the 100 ms USEQ latency when new legs were sized and the exact-timestamp re-entry guard was checked. As a result, legs were dropped or undersized (178 positions against about 260 expected, 13.7% average position against the 15% target) and the book was not dollar-balanced.

Long the two and short the two US sector ETFs (out of eight) with the strongest and weakest multi-month formation returns, skipping the most recent 21 sessions, and rebalance every 21 trading sessions to capture medium-term sector momentum separately from short-term reversal.

No full backtest or optimization was recorded. The only performance evidence is the QA unoptimized sandbox smoke test (formation=126): 178 closed positions, PF 0.98, Sharpe -0.02, and -0.125% per position. QA said this did not measure the hypothesis because the cohorts were partial and undersized.

Abandon Reason

verification_loop: Verification failed (Layer 4 — QA review) [class=code_defect]: - [edge_concern] Smoke test (unoptimized, formation=126): 178 closed positions, PF 0.98, Sharpe -0.02, -0.125% per position; after borrow costs that is near zero edge. The defect below means this did not measure the hypothesis as specified, because cohorts were partial and undersized. Re-measure after the fix before reading anything into it. Runtime was also high: 249 s for 1,653 daily bars on 8 names, with ~949 s projected for the……Show moreShow less

verification_loop: Verification failed (Layer 4 — QA review) [class=code_defect]: - [edge_concern] Smoke test (unoptimized, formation=126): 178 closed positions, PF 0.98, Sharpe -0.02, -0.125% per position; after borrow costs that is near zero edge. The defect below means this did not measure the hypothesis as specified, because cohorts were partial and undersized. Re-measure after the fix before reading anything into it. Runtime was also high: 249 s for 1,653 daily bars on 8 names, with ~949 s projected for the full history. For a per-bar O(8) loop that is unexpectedly slow and should be profiled. - [critical] Rotation does not rebuild the 2-long/2-short cohort. In calculate_signal, close_position is submitted for the old legs, then _open_pending() is called right away. The backtest SimulatedExchange queues commands (use_message_queue), and USEQ has latency_ms=100, so none of those closes have filled yet. (a) Newly selected names: `gross` still includes the full old cohort (~0.6 x equity), so `budget - gross` is ~0. That makes notional 0 (bit CLEARED, so the leg is dropped for the whole 21-session cycle) or leaves only a residual (an undersized leg). (b) Names that were held and are selected again: their bit is kept (`continue`) and they are meant to re-enter from on_position_closed. But the close fills at rotation_ts + 100 ms, so `self.clock.timestamp_ns() != self._rotation_ts` returns early, and nothing calls _open_pending before the next rotation. Those names stay flat for the cycle. Net effect: after the first rotation the book alternates between full and partial or empty cohorts and is not dollar-balanced long vs short. The sandbox shows this. About 65 rotations x 4 legs ≈ 260 expected positions, but 178 were recorded, and avg_position_pct was 13.7% vs the 15% target. The hypothesis (top-2 long / bottom-2 short every 21 sessions) is not what gets traded. (line 168) — fix: Use a persistent rotation state instead of exact-timestamp matching. Keep the pending bits until the next rotation. When computing `gross`, exclude positions that have a reduce-only close pending (they are being replaced). Size each leg off _rotation_equity * leg_equity_fraction. In on_position_closed, and as a fallback at the start of every primary bar, call _open_pending whenever bits remain and no prior-cohort close is still in flight. Drop the `clock == _rotation_ts` guard, or replace it with 'same rotation bucket'. Only clear a bit after an order is actually submitted, or when the size is - [warning] The portfolio drawdown halt (15%) is permanent for the rest of the run, with no re-arm. In a 25-year backtest one drawdown can end trading for good, which shrinks the sample. This is consistent with the stated description, so it is only a warning, but the analyst should know whether a halt fired. (line 133) — fix: Log the halt date. Consider re-arming at the next rotation after equity recovers, or a cool-off measured in sessions, if the hypothesis allows it. - [warning] Dates outside the supplied XNYS closure table fall back to a weekday count. That fallback is a different session numbering, so the rotation phase jumps at the table boundary (before 1998 / after 2030). It does not affect the sandbox or backtest window. (line 117) — fix: Acceptable for now. Extend the table, or use src/data/market_calendar if it is importable, so the phase stays continuous.

Analysis

replicating momentum strategy - Formation Periods

Backtest and paper results are hypothetical. Trading involves risk of loss.