Portfolio Construction

This chapter is generated from the canonical platform doc, which lives in docs/ (single source of truth).

Portfolio Construction — Momentum Backtest

How portfolio construction works in the momentum backtest (src/sbresearch/momentum_backtest.py), what Zipline gives you to work with, and a prioritized list of improvements.


1. Current State

1.1 Overview

The momentum backtest is a Zipline-driven, equal-weighted long/short decile portfolio wired to a declarative StrategyConfig loaded from config/strategies/<name>.yaml. There is no optimizer, no risk model in the construction step, no sector caps, no turnover control, and no signal-weighted sizing.

1.2 Pipeline & Ranking (signal → buckets)

make_pipeline() at momentum_backtest.py:201-223 builds a Zipline Pipeline:

  1. Universe filterAverageDollarVolume(window_length=20).top(universe_size) keeps the top 500 names by 20-day ADV (momentum_backtest.py:203-204).
  2. Signal factor — either MomentumFactor (precomputed 12-1 vol-scaled momentum at momentum_backtest.py:176-183) or ResidualMomentumFactor (252-day residual momentum at momentum_backtest.py:186-193). Both are 1-bar CustomFactors that read columns from the GoldEodFeatures DataSet — features are precomputed in Gold, not recomputed in-engine.
  3. Bucket selectionsignal.top(long_count, mask=universe) and signal.bottom(short_count, mask=universe) produce boolean columns longs/shorts (momentum_backtest.py:213-214). Defaults are 50/50.

1.3 Per-day refresh

before_trading_start() (momentum_backtest.py:255-259) pulls pipeline_output("momentum_pipe") daily and stores the longs/shorts symbol lists on context. Shorts list is forced empty when long_only=True.

1.4 Rebalance & Sizing

rebalance() (momentum_backtest.py:262-285), scheduled monthly or weekly at market_open + 30min:

  • Closes any current position not in desired = longs ∪ shorts via order_target_percent(asset, 0.0).
  • Long sizing — equal-weight: long_weight = long_budget / len(longs) where long_budget = 1.0 if long_only, else 0.5.
  • Short sizing — equal-weight: short_weight = -0.5 / len(shorts) (long-short only).
  • Calls order_target_percent(asset, weight) for each leg, gated by data.can_trade(asset).

A default long-short run targets ±1% of NAV per name (50% / 50 names); long-only targets +2% of NAV per long (100% / 50 names).

1.5 Frictions

initialize() (momentum_backtest.py:249-250) sets PerShare(cost=0.001, min_trade_cost=1.0) commission and VolumeShareSlippage(volume_limit=0.025, price_impact=0.1). No set_long_only, no set_max_leverage, no set_max_position_size, no set_max_order_count, no set_asset_restrictions.

1.6 Configuration

All parameters (long_count, short_count, long_only, rebalance, universe.size, capital, date range) come from the strategy YAML (classic-momentum.yaml:8-15, residual-momentum.yaml:8-15), with CLI overrides applied in _backtest_from_args() (momentum_backtest.py:728-750). Both YAMLs currently use long_count=50, short_count=50, long_only=false, rebalance=monthly.


2. What Zipline Provides

Zipline does not have a set_portfolio_constructor(fn) hook. Portfolio construction is your function scheduled via schedule_function(rebalance, date_rule, time_rule). Zipline supplies:

2.1 Order primitives (zipline.api)

All accept limit_price, stop_price, and style (MarketOrder | LimitOrder | StopOrder | StopLimitOrder):

Function Specifies Notes
order(asset, amount) share count (int) hand-rolled
order_value(asset, value) dollar value
order_percent(asset, pct) % of NAV, delta additive
order_target(asset, target) absolute share count
order_target_value(asset, target) absolute dollar value
order_target_percent(asset, target) absolute % of NAV what we use now
batch_market_order(share_counts) pd.Series[Asset→int] one call for many fills

2.2 Trading controls (called once in initialize)

set_long_only, set_max_leverage, set_min_leverage, set_max_position_size, set_max_order_size, set_max_order_count, set_asset_restrictions, set_do_not_order_list, set_slippage, set_commission, set_cancel_policy.

2.3 Pipeline-side

attach_pipeline + pipeline_output for ranking/screening. The Pipeline API supports group-based normalisations (.demean(groupby=...), .zscore(groupby=...)) which can deliver sector-neutral signals without a downstream optimizer.

2.4 No zipline.optimize

The convex optimizer (MaximizeAlpha, TargetWeights, MaxGrossExposure, PositionConcentration) shipped on Quantopian is not in zipline-reloaded 3.1.x. Optimizer-based construction has to be wired via cvxpy, scipy.optimize, or riskfolio-lib inside the rebalance callback.


3. Recommendations

Ordered by leverage (impact ÷ effort). Each item lists the gap, the fix, and the file(s) to touch.

3.1 — High leverage / Low effort

R1. Enforce the declared universe filter from strategy YAML

  • Gap: classic-momentum.yaml declares min_market_cap_billions: 1.0, min_price: 5.0, exchanges: [NYSE, NASDAQ]. None of these are applied in make_pipeline() — only AverageDollarVolume.top(500). The backtest universe silently diverges from the documented universe.
  • Fix: Add Pipeline Filters for price (USEquityPricing.close.latest >= cfg.universe.min_price), exchange (from gold.dim_instrument), and market cap (from a MarketCap precomputed feature). AND them with the ADV filter into the universe mask.
  • Touch: momentum_backtest.py::make_pipeline, possibly a new MarketCap column in GoldEodFeatures.

Status (post-F-089): Implemented in F-089 / TASK-426. Bundle-side floor reads min(min_market_cap_billions)*1e9 / min(min_last_price) across all strategy YAMLs via resolve_strategy_universe_floors; Pipeline mask adds USEquityPricing.close.latest >= cfg.min_last_price. Per-strategy MarketCap Pipeline filter and exchange filter are deferred — see ADR §8. Brief: feature-construction-guardrails-and-universe-enforcement-design-brief.md.

R2. Add Zipline trading-control guardrails

  • Gap: set_long_only, set_max_leverage, set_max_position_size, set_max_order_count are unused. A bug in rebalance() could silently lever the book or over-concentrate.
  • Fix: In initialize(), derive limits from StrategyConfig and call:
    • set_max_leverage(2.0) (1.0 long + 1.0 short for long-short, 1.0 for long-only)
    • set_max_position_size(max_notional=cfg.capital * 0.05) (≤5% of NAV per name)
    • set_max_order_count(2 * (long_count + short_count) + 50)
    • set_long_only() when cfg.long_only is True
  • Touch: momentum_backtest.py::initialize; expose limits in BacktestConfig and the strategy YAML.

Status (post-F-089): Implemented in F-089 / TASK-427. All four set_* guardrails installed inside make_initialize via _derive_guardrail_defaults. Three new optional YAML knobs under backtest: (max_leverage, max_position_size_pct, max_order_count) allow override; current book is well below the derived defaults so the guardrails are passive kill-switches. See ADR §7. Brief: feature-construction-guardrails-and-universe-enforcement-design-brief.md.

R3. Use batch_market_order instead of N individual order_target_percent calls

  • Gap: Each rebalance issues 100+ separate orders. Cheap but noisy in the transaction log and harder to atomically cancel.
  • Fix: Compute target shares from target weights, build pd.Series[Asset→int], call batch_market_order once. Keep order_target_percent for the close-out leg if simpler.
  • Touch: momentum_backtest.py::rebalance.

R4. Integrate the FMP promotion allowlist into the backtest universe

  • Gap: silver.fmp_promotion_allowlist excludes ETFs, ADRs, warrants, preferreds — but the Zipline csvdir bundle is built from Gold which may already exclude some, and the backtest does not re-apply the allowlist. Risk of holding non-equity instruments inadvertently.
  • Fix: At _emit_tearsheet / make_pipeline time, intersect the asset finder’s universe with silver.fmp_promotion_allowlist WHERE is_allowed = TRUE resolved at the as-of date. Cleaner option: filter at bundle export.
  • Touch: src/sbresearch/zipline/zipline_bundle_export_service.py, optionally make_pipeline.

Status (post-F-089): Already implemented at TASK-247 (Aug 2025) — all 5 bundle-export SQL strings are rooted in silver.fmp_promotion_allowlist a WHERE a.is_allowed = TRUE. F-089 / TASK-430 adds a structural lock-in test at tests/integration/sbresearch/test_bundle_allowlist_invariant.py to guard against future refactors that might drop the JOIN/filter. No new production code. See ADR §8. Brief: feature-construction-guardrails-and-universe-enforcement-design-brief.md.

3.2 — High leverage / Medium effort

R5. Promote portfolio construction to PortfolioConstructionService

Note (post-F-088 reconciliation): The PortfolioConstructionService already exists at src/sbportfolio/construction_service.py and is wired into _promote_signals for the Gold phase-3 promotion path (see CLAUDE.md §1). The 5 pure-function weighting algorithms (equal_weight, exponential_weight, inverse_volatility, mean_variance, risk_parity) live at src/sbportfolio/algorithms/. The Zipline backtest did not consume them because the rebalance callback at momentum_backtest.py:261 contained inline equal-weight arithmetic. F-088 fixes that gap by introducing a thin pure-function adapter at src/sbportfolio/backtest_adapter.py (compute_backtest_weights) that routes the Zipline rebalance through the existing sbportfolio.algorithms registry, and by replacing the _active_config module global with closure factories (make_initialize, make_before_trading_start, make_rebalance). No new service is created. See the F-088 design brief and the portfolio-construction ADR for the full record.

  • Gap: Construction logic is tangled inside a Zipline callback that can’t accept closures, forcing the module-level _active_config global (momentum_backtest.py:130). Logic is not unit-testable in isolation, not reusable for live trading, and not visible to the Gold phase-3 promotion (promote_signals) that CLAUDE.md Section 1 lists as the home of PortfolioConstructionService.

  • Fix: Introduce a pure-function service:

    class PortfolioConstructionService:
        def target_weights(
            self,
            signal: pd.Series,           # signal value per asset
            long_mask: pd.Series,        # bool per asset
            short_mask: pd.Series,
            features: pd.DataFrame,      # vol, sector, beta, etc.
            config: PortfolioConfig,
        ) -> pd.Series:                  # signed % of NAV per asset
            ...

    Zipline’s rebalance() shrinks to: fetch service output → close exits → batch_market_order to targets. Service is unit-testable with no Zipline dependency.

  • Touch: New src/sbfoundation/portfolio/portfolio_construction_service.py; refactor momentum_backtest.py::rebalance; update CLAUDE.md §1 reference. Persist outputs to gold.fact_portfolio_target per CLAUDE.md’s documented phase-3 contract.

R6. Signal-weighted (rank-weighted) sizing

Note (post-F-090 reconciliation): Implemented as YAML mode exponential_weight (geometric rank-decay) and the operator alias rank_weightexponential_weight(decay=0.95). Wiring lives in src/sbportfolio/backtest_adapter.py generic dispatch (F-090/TASK-B), routing through the sbportfolio.algorithms.exponential_weight registry entry. See the F-090 design brief.

  • Gap: Equal-weighting treats the #1 signal name and the #50 signal name identically. Throws away signal dispersion.
  • Fix: Weight by signal rank or z-score within the long/short bucket: w_i = z_i / sum(|z|) scaled to the side’s gross budget. Add weighting: {equal | rank | zscore | inverse_vol} to the YAML’s backtest block.
  • Touch: PortfolioConstructionService (post-R5) or momentum_backtest.py::rebalance.

R7. Sector caps / sector-neutral construction

Note (post-F-090 reconciliation): Implemented via the pipeline-side demean option. YAML modes sector_neutral_equal_weight and sector_neutral_exponential_weight apply signal.demean(groupby=Sector(), mask=universe) in src/sbbacktest/momentum_backtest.py::make_pipeline before .top()/.bottom(). The Sector classifier is backed by a sectors.csv sidecar written by ZiplineBundleExportService (F-090/TASK-C) from gold.dim_instrument.sector. Post-bucket cap is not implemented; deferred to F-091 if needed.

  • Gap: _factor_risk_fallback (momentum_backtest.py:569) reports net_sector_exposures — the data exists, but construction ignores it. A momentum book can quietly concentrate 60% in tech.
  • Fix: Two options, pick one:
    • Pipeline-side: signal = signal.demean(groupby=Sector, mask=universe) makes the signal sector-neutral before ranking. Cheapest path, gives near sector-neutral books without an optimizer.
    • Construction-side: Hard cap |sector_weight| ≤ 25% inside PortfolioConstructionService; redistribute overflow within the bucket.
  • Touch: Add a Sector classifier reading gold.dim_instrument.sector; either make_pipeline or the construction service.

R8. Turnover throttling

Note (post-F-090 reconciliation): Implemented as YAML field backtest.construction.rebalance_buffer (range [0.0, 1.0)) for the backtest path. Rank-band semantics: held names stay while their signal rank remains within round(core_count * (1 + buffer)). State source (backtest) is context.portfolio.positions (closure-local; no sidecar). buffer=0.0 reduces to F-088/F-089 byte-identical (back-compat invariant verified by 21 unit tests, now living at tests/unit/sbportfolio/test_hysteresis.py).

Live-path wiring (2026-07-10, F-275 / TC-6): the mechanic was relocated from sbbacktest into src/sbportfolio/hysteresis.py (public apply_hysteresis_band, was private _apply_rebalance_buffer) — the correct dependency direction, since sbbacktest already imports FROM sbportfolio. sbbacktest/closures.py now imports the relocated function (behavior- identical relocation). It is also wired into the live/paper construction path for the first time: sbportfolio.settings.HYSTERESIS_BUFFER (default 0.0, ships OFF) feeds PortfolioConstructionService.build() via the "buffer" hyperparam key on every split_long_short-based algorithm; PortfolioConstructionService._resolve_current_holdings resolves the prior rebalance’s holdings by algorithm + trailing hyperparam-hash8 (mirroring sbexecution.target_portfolio_reader.resolve_latest_source_id, not exact portfolio_id, since the middle CONSTRUCTION_CODE_VERSION segment can legitimately change between rebalances). Report-then-enforce: this milestone ships the mechanism only — no live buffer value is chosen yet.

  • Gap: Monthly rebalance refreshes the entire decile every cycle. If signal moves a name from rank 50 → rank 52, we close it and pay round-trip cost for ~zero alpha. Previously true for the backtest only; the live/paper book (the only book that actually trades) had zero throttling until F-275.
  • Fix: Implement buffered decile membership: keep an existing long position while its signal stays in the top long_count * (1 + buffer) (e.g. top 60 instead of top 50). Add rebalance_buffer: 0.2 to the YAML’s backtest block for the backtest path; flip sbportfolio.settings.HYSTERESIS_BUFFER above 0.0 for the live path (after a report-only calibration window).
  • Touch: PortfolioConstructionService (live, F-275) and momentum_backtest.py::before_trading_start (backtest, F-090).

3.3 — Medium leverage / Medium effort

R9. Volatility-targeted gross exposure

Status (post-F-091): Adapter-layer scaling implemented in F-091 / TASK-1048. New target_portfolio_vol + max_gross_scale YAML knobs under backtest.construction:; new compute_backtest_weights_with_scaling_meta(...) -> tuple[dict, ScalingResult] helper at src/sbportfolio/backtest_adapter.py. After the algorithm returns raw weights, the adapter computes ex_ante_vol = sqrt(wᵀΣw) * sqrt(252) from the F-128/F-129 risk-model Σ and scales every weight by min(target/ex_ante_vol, max_gross_scale). Works uniformly across all 7 algorithm modes — operator can vol-target an equal-weight book. ScalingResult.applied_scale and ex_ante_vol returned for the sidecar’s construction.vol_target block (TASK-I, pending). Default-OFF: target_portfolio_vol=None preserves F-088 / F-089 / F-090 byte-equivalence.

  • Gap: Gross exposure is fixed at 200% long-short / 100% long-only regardless of regime. A high-vol regime exposes 2× the target portfolio vol.
  • Fix: Compute ex-ante portfolio vol from volatility_30d_f (and ideally a covariance estimate) and scale gross exposure to a target portfolio vol (e.g. 12% annualised). Add target_portfolio_vol: 0.12 and max_gross_exposure: 2.0 to the YAML.
  • Touch: PortfolioConstructionService.

R10. Inverse-volatility weighting

Note (post-F-090 reconciliation): Implemented as YAML mode inverse_volatility with hyperparam vol_lookback_days (default 63). Wiring lives in src/sbportfolio/backtest_adapter.py dispatch routing to sbportfolio.algorithms.inverse_volatility. The closure fetches history_df via Zipline’s data.history(assets, "close", bar_count=lookback+1, "1d") inside make_rebalance (F-090/TASK-F) only when the base algorithm is inverse_volatility. The algorithm computes σ internally from realised returns (not from volatility_30d_f Gold column, so YAML’s vol_lookback_days knob is honoured exactly).

  • Gap: Equal-weight gives the same risk budget to a 20%-vol name and a 60%-vol name.
  • Fix: Within each bucket, w_i ∝ 1 / vol_i (using volatility_30d_f from Gold). Combines naturally with R6 (rank-weighting): w_i ∝ rank_i / vol_i. This is the cheapest form of risk-aware construction and a precursor to R11.
  • Touch: PortfolioConstructionService.

R11. Wire factor_risk covariance into construction (pre-trade, not post-trade)

  • Gap: _compute_factor_risk_block (momentum_backtest.py:473) already computes factor covariance, idiosyncratic variance, marginal risk, and exposure decomposition — but only after the trade for reporting. The expensive math is on the floor.
  • Fix: Hoist the factor-risk computation into a pre-trade RiskModel that the construction service consumes. Reuse the existing fetch_gold_features / compute_factor_risk helpers; call them at rebalance time over the past 252 days for the long+short candidate set. Two consumption modes:
    • Conservative: use marginal-risk numbers to scale down concentrated positions.
    • Aggressive: feed the covariance matrix to an MV optimizer (R12).
  • Touch: src/sbresearch/factor_risk.py (split fetch from compute), new PortfolioConstructionService, the rebalance callback.

R12. Mean-variance / minimum-variance optimizer

Status (post-F-091): Implemented in F-091 / TASK-1046 + TASK-1047. mean_variance promoted from rejected to allowed in ALLOWED_ALGORITHMS; new min_variance algorithm added. Both ship with a mv_solver: Literal["closed_form", "cvxpy"] knob — cvxpy branch solves argmin λ·w'(Σ+ridge·I)w − μᵀw (mean-var) or argmin w'(Σ+ridge·I)w (min-var) subject to the baseline trio (gross-exposure equality, per-position bounds, long-only when cfg.long_only=True). Covariance source preference: risk_model_covariance from F-128/F-129 wins; falls back to shrink_covariance(prepare_returns_matrix(history_df)). Non-OPTIMAL solver status → qp_solver_status_fallback chain (default inverse_volatility). cvxpy ^1.5 added to pyproject.toml — default chain ships Clarabel + SCS + OSQP + HiGHS (BSD/MIT). Algorithm-level default solver is closed_form for back-compat; ConstructionConfig.mv_solver YAML default is cvxpy (adapter threads it through). Sector caps + turnover constraints deferred to follow-on.

  • Gap: Once R11 lands, the natural construction step is argmax w'·alpha - λ·w'·Σ·w subject to budget, leverage, position, and sector caps.
  • Fix: Add cvxpy (preferred — clean dependency, used widely in donor qsresearch.strategies.factor.portfolio_construction) inside PortfolioConstructionService. Make the choice configurable: construction: {equal | rank | inverse_vol | min_var | mean_var} in the YAML.
  • Touch: Add cvxpy to pyproject.toml, implement in PortfolioConstructionService. Consult C:\qs\QSResearch\packages\qsresearch\qsresearch\strategies\factor\portfolio_construction.py (CLAUDE.md §7 Code Donor) as the reference implementation.

3.4 — Lower leverage / Low effort

R13. Stop computing signal.bottom(short_count) when long_only=True

  • Gap: make_pipeline() always computes both longs and shorts columns even when long_only=True (momentum_backtest.py:213-214). The short list is then discarded in before_trading_start (momentum_backtest.py:259). Wasted Pipeline work.
  • Fix: Branch in make_pipeline() on config.long_only; only emit the shorts column when long-short.
  • Touch: momentum_backtest.py::make_pipeline.

Status (post-F-089): Implemented in F-089 / TASK-428. make_pipeline emits shorts only when not cfg.long_only; make_before_trading_start reads defensively via "shorts" in pipe.columns. Pure dead-code removal — F-089 baseline unchanged. Brief: feature-construction-guardrails-and-universe-enforcement-design-brief.md.

R14. Make rebalance day-of-month configurable

  • Gap: _rebalance_rule hard-codes month_start(days_offset=0) and week_start(days_offset=0). Some momentum studies prefer mid-month or skip-the-first-day to avoid the “month-end rebalance” crowding effect.
  • Fix: Add rebalance_day_offset: 0 to the YAML and thread it through.
  • Touch: momentum_backtest.py::_rebalance_rule, BacktestConfig, strategy YAML schema.

Status (post-F-089): Implemented in F-089 / TASK-429. _rebalance_rule(cadence, day_offset) accepts a configurable offset with range validation (0..27 monthly, 0..6 weekly). Both YAMLs carry explicit rebalance_day_offset: 0 for the legacy “first day of period” cadence. Brief: feature-construction-guardrails-and-universe-enforcement-design-brief.md.

R15. Per-position circuit-breaker / drawdown gate

Status (post-F-091): Implemented in F-091 / TASK-1050. New pure module src/sbbacktest/circuit_breaker.py mirroring F-090’s rebalance_buffer.py pattern. Exposes _apply_circuit_breakers(...) -> CircuitBreakerResult with two independent gates: (a) per-position σ-stop fires on any LONG position whose trailing N-day return (N from position_stop_lookback, default 5) is below -position_stop_sigma · σ — short positions excluded because a price drop helps a short leg; (b) portfolio drawdown gate scales gross exposure by drawdown_cut_ratio for the next rebalance when (current_nav − peak_nav) / peak_nav < max_drawdown_max (the existing promotion_gates.max_drawdown_max YAML knob). Both gates default-OFF when position_stop_sigma=None and drawdown_cut_ratio=None. Result-stash on context for the sidecar construction.circuit_breaker block (TASK-I, pending). Wired into make_before_trading_start BEFORE the F-090 rebalance_buffer call (TASK-H, pending).

  • Gap: No per-name stop-loss or portfolio-level drawdown gate. A momentum book can ride a single name to -80%.
  • Fix: In the construction service, zero-out any position whose trailing 5-day return falls below -3·σ (configurable). At portfolio level, cut gross exposure in half if 30-day drawdown exceeds max_drawdown_max from the strategy YAML’s promotion_gates.
  • Touch: PortfolioConstructionService.

R16. Surface construction config in the JSON sidecar

  • Gap: The tearsheet JSON sidecar (_emit_tearsheet) records metrics but not the construction choices (weighting scheme, sector caps, vol target). Two backtests with different weighting are visually indistinguishable in the run report.
  • Fix: Add a construction block to the JSON written at momentum_backtest.py:656-673 listing the resolved construction config. Render in the HTML.
  • Touch: momentum_backtest.py::_emit_tearsheet, src/sbreporting.

Status (post-F-089): Implemented in F-089 / TASK-431. Sidecar schema_version bumped 4 → 5; new construction block with 9 fields (algorithm, long_only, long_count, short_count, long_gross, short_gross, guardrails.{max_leverage, max_position_size_pct, max_order_count}, rebalance.{cadence, day_offset}) surfaces the resolved post-R2 / post-R14 config alongside the F-092 raw-YAML mirror. momentum.html renders a “Construction (resolved)” card immediately after the Strategy Configuration section. Brief: feature-construction-guardrails-and-universe-enforcement-design-brief.md.

3.5 — Documentation / Governance

R17. Write an ADR for the construction choices

  • Gap: There is no decision record under backlog/docs/decisions/ describing why we chose equal-weight 50/50 decile vs. rank-weighted, sector-neutral, or MV-optimized. Future contributors won’t know whether the current state is a deliberate baseline or technical debt.
  • Fix: Add backlog/docs/decisions/portfolio-construction.md per CLAUDE.md §7. Capture the construction taxonomy (R5–R12 above), the current baseline rationale, and the migration path.
  • Touch: backlog/docs/decisions/portfolio-construction.md (new).

4. Proposed Milestone Ordering

A reasonable sequencing into Backlog.md milestones (Backlog.md MCP per CLAUDE.md §7):

  1. F-NNN — PortfolioConstructionService (R5) — extract the construction step into a pure-function service, no behaviour change. Tests pin the current equal-weight outputs as the regression baseline.
  2. F-NNN — Construction guardrails & universe enforcement (R1, R2, R4, R13, R14, R16) — small declarative fixes on top of the new service. Tightens the existing equal-weight construction without changing the weighting scheme.
  3. F-NNN — Risk-aware weighting (R6, R7, R8, R10) — rank, sector-neutral, turnover-buffered, inverse-vol. Each switchable from the YAML so we can A/B vs. baseline.
  4. O-NNN — Pre-trade risk model (R11) — refactor factor_risk.py to expose a pre-trade RiskModel instead of only a post-trade report block.
  5. F-NNN — Optimizer-based construction (R9, R12, R15) — adds cvxpy; introduces min_var / mean_var modes with vol targeting and drawdown gating.
  6. R17 — ADR authored alongside F1; updated as F3–F5 land.

Each milestone keeps the equal-weight baseline runnable so we can compare risk-aware variants against it under identical universe and frictions.