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:
- Universe filter —
AverageDollarVolume(window_length=20).top(universe_size)keeps the top 500 names by 20-day ADV (momentum_backtest.py:203-204). - Signal factor — either
MomentumFactor(precomputed 12-1 vol-scaled momentum atmomentum_backtest.py:176-183) orResidualMomentumFactor(252-day residual momentum atmomentum_backtest.py:186-193). Both are 1-barCustomFactors that read columns from theGoldEodFeaturesDataSet — features are precomputed in Gold, not recomputed in-engine. - Bucket selection —
signal.top(long_count, mask=universe)andsignal.bottom(short_count, mask=universe)produce boolean columnslongs/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 ∪ shortsviaorder_target_percent(asset, 0.0). - Long sizing — equal-weight:
long_weight = long_budget / len(longs)wherelong_budget = 1.0iflong_only, else0.5. - Short sizing — equal-weight:
short_weight = -0.5 / len(shorts)(long-short only). - Calls
order_target_percent(asset, weight)for each leg, gated bydata.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.yamldeclaresmin_market_cap_billions: 1.0,min_price: 5.0,exchanges: [NYSE, NASDAQ]. None of these are applied inmake_pipeline()— onlyAverageDollarVolume.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 (fromgold.dim_instrument), and market cap (from aMarketCapprecomputed feature).ANDthem with the ADV filter into theuniversemask. - Touch:
momentum_backtest.py::make_pipeline, possibly a newMarketCapcolumn inGoldEodFeatures.
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 addsUSEquityPricing.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_countare unused. A bug inrebalance()could silently lever the book or over-concentrate. - Fix: In
initialize(), derive limits fromStrategyConfigand 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()whencfg.long_onlyis True
- Touch:
momentum_backtest.py::initialize; expose limits inBacktestConfigand the strategy YAML.
Status (post-F-089): Implemented in F-089 / TASK-427. All four
set_*guardrails installed insidemake_initializevia_derive_guardrail_defaults. Three new optional YAML knobs underbacktest:(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], callbatch_market_orderonce. Keeporder_target_percentfor 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_allowlistexcludes 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_pipelinetime, intersect the asset finder’s universe withsilver.fmp_promotion_allowlist WHERE is_allowed = TRUEresolved at the as-of date. Cleaner option: filter at bundle export. - Touch:
src/sbresearch/zipline/zipline_bundle_export_service.py, optionallymake_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 attests/integration/sbresearch/test_bundle_allowlist_invariant.pyto 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
PortfolioConstructionServicealready exists atsrc/sbportfolio/construction_service.pyand is wired into_promote_signalsfor 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 atsrc/sbportfolio/algorithms/. The Zipline backtest did not consume them because the rebalance callback atmomentum_backtest.py:261contained inline equal-weight arithmetic. F-088 fixes that gap by introducing a thin pure-function adapter atsrc/sbportfolio/backtest_adapter.py(compute_backtest_weights) that routes the Zipline rebalance through the existingsbportfolio.algorithmsregistry, and by replacing the_active_configmodule 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_configglobal (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 ofPortfolioConstructionService.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_orderto targets. Service is unit-testable with no Zipline dependency.Touch: New
src/sbfoundation/portfolio/portfolio_construction_service.py; refactormomentum_backtest.py::rebalance; update CLAUDE.md §1 reference. Persist outputs togold.fact_portfolio_targetper 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 aliasrank_weight→exponential_weight(decay=0.95). Wiring lives in src/sbportfolio/backtest_adapter.py generic dispatch (F-090/TASK-B), routing through thesbportfolio.algorithms.exponential_weightregistry 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. Addweighting: {equal | rank | zscore | inverse_vol}to the YAML’sbacktestblock. - Touch:
PortfolioConstructionService(post-R5) ormomentum_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_weightandsector_neutral_exponential_weightapplysignal.demean(groupby=Sector(), mask=universe)in src/sbbacktest/momentum_backtest.py::make_pipeline before.top()/.bottom(). TheSectorclassifier is backed by asectors.csvsidecar written byZiplineBundleExportService(F-090/TASK-C) fromgold.dim_instrument.sector. Post-bucket cap is not implemented; deferred to F-091 if needed.
- Gap:
_factor_risk_fallback(momentum_backtest.py:569) reportsnet_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%insidePortfolioConstructionService; redistribute overflow within the bucket.
- Pipeline-side:
- Touch: Add a
Sectorclassifier readinggold.dim_instrument.sector; eithermake_pipelineor 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 withinround(core_count * (1 + buffer)). State source (backtest) iscontext.portfolio.positions(closure-local; no sidecar).buffer=0.0reduces 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
sbbacktestinto src/sbportfolio/hysteresis.py (publicapply_hysteresis_band, was private_apply_rebalance_buffer) — the correct dependency direction, sincesbbacktestalready imports FROMsbportfolio.sbbacktest/closures.pynow 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(default0.0, ships OFF) feedsPortfolioConstructionService.build()via the"buffer"hyperparam key on everysplit_long_short-based algorithm;PortfolioConstructionService._resolve_current_holdingsresolves the prior rebalance’s holdings byalgorithm+ trailing hyperparam-hash8 (mirroringsbexecution.target_portfolio_reader.resolve_latest_source_id, not exactportfolio_id, since the middleCONSTRUCTION_CODE_VERSIONsegment 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). Addrebalance_buffer: 0.2to the YAML’sbacktestblock for the backtest path; flipsbportfolio.settings.HYSTERESIS_BUFFERabove0.0for the live path (after a report-only calibration window). - Touch:
PortfolioConstructionService(live, F-275) andmomentum_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_scaleYAML knobs underbacktest.construction:; newcompute_backtest_weights_with_scaling_meta(...) -> tuple[dict, ScalingResult]helper at src/sbportfolio/backtest_adapter.py. After the algorithm returns raw weights, the adapter computesex_ante_vol = sqrt(wᵀΣw) * sqrt(252)from the F-128/F-129 risk-model Σ and scales every weight bymin(target/ex_ante_vol, max_gross_scale). Works uniformly across all 7 algorithm modes — operator can vol-target an equal-weight book.ScalingResult.applied_scaleandex_ante_volreturned for the sidecar’sconstruction.vol_targetblock (TASK-I, pending). Default-OFF:target_portfolio_vol=Nonepreserves 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). Addtarget_portfolio_vol: 0.12andmax_gross_exposure: 2.0to the YAML. - Touch:
PortfolioConstructionService.
R10. Inverse-volatility weighting
Note (post-F-090 reconciliation): Implemented as YAML mode
inverse_volatilitywith hyperparamvol_lookback_days(default 63). Wiring lives in src/sbportfolio/backtest_adapter.py dispatch routing tosbportfolio.algorithms.inverse_volatility. The closure fetcheshistory_dfvia Zipline’sdata.history(assets, "close", bar_count=lookback+1, "1d")insidemake_rebalance(F-090/TASK-F) only when the base algorithm isinverse_volatility. The algorithm computes σ internally from realised returns (not fromvolatility_30d_fGold column, so YAML’svol_lookback_daysknob 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(usingvolatility_30d_ffrom 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
RiskModelthat the construction service consumes. Reuse the existingfetch_gold_features/compute_factor_riskhelpers; 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), newPortfolioConstructionService, the rebalance callback.
R12. Mean-variance / minimum-variance optimizer
Status (post-F-091): Implemented in F-091 / TASK-1046 + TASK-1047.
mean_variancepromoted from rejected to allowed inALLOWED_ALGORITHMS; newmin_variancealgorithm added. Both ship with amv_solver: Literal["closed_form", "cvxpy"]knob — cvxpy branch solvesargmin λ·w'(Σ+ridge·I)w − μᵀw(mean-var) orargmin w'(Σ+ridge·I)w(min-var) subject to the baseline trio (gross-exposure equality, per-position bounds, long-only whencfg.long_only=True). Covariance source preference:risk_model_covariancefrom F-128/F-129 wins; falls back toshrink_covariance(prepare_returns_matrix(history_df)). Non-OPTIMAL solver status →qp_solver_status_fallbackchain (defaultinverse_volatility).cvxpy ^1.5added topyproject.toml— default chain ships Clarabel + SCS + OSQP + HiGHS (BSD/MIT). Algorithm-level default solver isclosed_formfor back-compat;ConstructionConfig.mv_solverYAML default iscvxpy(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'·Σ·wsubject to budget, leverage, position, and sector caps. - Fix: Add
cvxpy(preferred — clean dependency, used widely in donorqsresearch.strategies.factor.portfolio_construction) insidePortfolioConstructionService. Make the choice configurable:construction: {equal | rank | inverse_vol | min_var | mean_var}in the YAML. - Touch: Add
cvxpytopyproject.toml, implement inPortfolioConstructionService. ConsultC:\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 bothlongsandshortscolumns even whenlong_only=True(momentum_backtest.py:213-214). The short list is then discarded inbefore_trading_start(momentum_backtest.py:259). Wasted Pipeline work. - Fix: Branch in
make_pipeline()onconfig.long_only; only emit theshortscolumn when long-short. - Touch:
momentum_backtest.py::make_pipeline.
Status (post-F-089): Implemented in F-089 / TASK-428.
make_pipelineemitsshortsonly whennot cfg.long_only;make_before_trading_startreads 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_rulehard-codesmonth_start(days_offset=0)andweek_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: 0to 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 explicitrebalance_day_offset: 0for 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.pypattern. Exposes_apply_circuit_breakers(...) -> CircuitBreakerResultwith two independent gates: (a) per-position σ-stop fires on any LONG position whose trailing N-day return (N fromposition_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 bydrawdown_cut_ratiofor the next rebalance when(current_nav − peak_nav) / peak_nav < max_drawdown_max(the existingpromotion_gates.max_drawdown_maxYAML knob). Both gates default-OFF whenposition_stop_sigma=Noneanddrawdown_cut_ratio=None. Result-stash oncontextfor the sidecarconstruction.circuit_breakerblock (TASK-I, pending). Wired intomake_before_trading_startBEFORE 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 exceedsmax_drawdown_maxfrom the strategy YAML’spromotion_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
constructionblock to the JSON written atmomentum_backtest.py:656-673listing 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_versionbumped 4 → 5; newconstructionblock 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.htmlrenders 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.mdper 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):
- 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.
- 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.
- 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.
- O-NNN — Pre-trade risk model (R11) — refactor
factor_risk.pyto expose a pre-tradeRiskModelinstead of only a post-trade report block. - F-NNN — Optimizer-based construction (R9, R12, R15) — adds
cvxpy; introducesmin_var/mean_varmodes with vol targeting and drawdown gating. - 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.