# How to Reweight a Multi-Factor Model Safely: Regimes, Shadow Models, and Weight Drift

> Canonical: https://vxi.me/en/posts/adaptive-factor-weighting-regime-shadow-drift/
> Locale: en-us
> Published: 2026-10-08
> Updated: 2026-10-08
> Markdown: https://vxi.me/en/posts/adaptive-factor-weighting-regime-shadow-drift.md

Once a factor shows a positive IC, the most dangerous next step may be to raise its weight immediately.

In the previous experiment, I focused on factor validation: freeze the signal-time snapshot first, then inspect Bucket Spread, Rank IC, and outcomes across multiple horizons. That answers a basic question: **does this factor actually contain ranking information?**

Automatic reweighting introduces a different question: **why should a factor that looked stronger over the most recent 20 periods be allowed to rewrite the active model right away?**

Those two questions are easy to mix together.

A candidate factor can genuinely contain information while its estimate over the latest window is still too high. A regime can also have genuinely changed while an overprotected model reacts so slowly that it misses the transition. Adaptive weighting is not finished when research IC is mapped into a new vector of weights. The harder problem is the model change itself.

I did not use old production weights or thresholds from my Notion notes as proof. Instead, I built a controlled synthetic regime-switching experiment. Because the ground truth is known, I can tell exactly when the factor relationship really changes and compare three activation policies:

- activate every new weight estimate immediately;
- activate every estimate, but cap the maximum move of each factor;
- run the candidate in shadow first, promote it only after consecutive wins over the active profile, and still enforce a delta cap at promotion.

The experiment uses 12 fixed random seeds. Each seed contains 240 sequential periods, 200 synthetic assets per period, and 4 factors. Regimes switch explicitly at periods 80 and 160. A candidate is estimated every 5 periods from only the previous 20 periods, then evaluated strictly on the next 5 periods.

That time boundary is intentional. scikit-learn's [TimeSeriesSplit](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html) documentation points out why ordinary cross-validation is inappropriate for time-ordered data when it would train on future observations and evaluate on the past.

If candidate weights already see the future, discussing shadow models, regimes, or drift budgets afterward is meaningless.

## How much do "faster" and "safer" reweighting actually differ?

I kept the candidate estimator deliberately simple. Inside the trailing window, the experiment computes each factor's cross-sectional Rank IC, clips negative evidence to zero, and normalizes the remaining positive evidence into candidate weights.

What changes is the activation policy.

I recorded:

- combined-score Rank IC on the future block;
- cumulative L1 weight turnover;
- the largest single weight jump;
- promotion count;
- the share of promotions where the new profile loses to the previous active profile in the next evaluation block;
- how long, after a true regime switch, it takes until the active profile's largest weight moves to the new regime's strongest factor.

I call the last metric adaptation lag. It is not an industry-standard finance metric; it is an experiment-specific way to measure how slowly the model acknowledges a known regime change.

Averaged across the 12 seeds:

| Policy | Cap | Shadow confirmations | Mean OOS Rank IC | L1 turnover | Max weight jump | False promotion rate | Adaptation lag |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Immediate | — | — | **0.2199** | **7.305** | 0.333 | **39.0%** | **11.67** |
| Delta capped | 5% | — | 0.2092 | 5.591 | 0.050 | 27.8% | 22.08 |
| Shadow first | 5% | 1 | 0.2001 | 4.197 | 0.050 | 20.6% | 29.79 |
| Shadow first | 5% | 2 | 0.1882 | 2.409 | 0.050 | 15.0% | 52.29 |
| Shadow first | 5% | 3 | 0.1850 | **1.659** | 0.050 | **10.2%** | **75.83** |
| Delta capped | 10% | — | 0.2172 | 6.884 | 0.100 | 36.2% | 14.58 |
| Shadow first | 10% | 1 | 0.2077 | 5.015 | 0.100 | 27.6% | 22.71 |
| Shadow first | 10% | 2 | 0.1937 | 3.472 | 0.100 | 13.7% | 37.08 |
| Shadow first | 10% | 3 | 0.1869 | 2.411 | 0.100 | 11.2% | 47.71 |

This table does not produce a single "safest parameter."

Immediate activation has the highest mean OOS Rank IC and adapts to regime changes fastest, with an average lag of only 11.67 periods. The cost is obvious: it has the highest cumulative turnover, changes at essentially every one of the 44 checkpoints, and has a false-promotion rate close to 39%.

Adding a real 5% delta cap lowers mean OOS Rank IC from 0.2199 to 0.2092 and slows adaptation, but L1 turnover drops from 7.305 to 5.591 and false promotions fall from 39.0% to 27.8%.

Shadow adds another layer. With a 5% cap and three required shadow wins, the false-promotion rate falls to about 10.2% and turnover falls to 1.659. That looks very stable—until you look at adaptation lag: 75.83 periods.

If you optimize only for "fewer bad promotions," the last row is tempting. But after a real regime shift, you may have protected the model so aggressively that it barely moves.

The experiment made one thing much clearer to me: **a guardrail is not a free safety layer. It becomes part of the model.**

AWS SageMaker's [shadow tests](https://docs.aws.amazon.com/sagemaker/latest/dg/shadow-tests.html) use a related production pattern: a new variant can receive mirrored production requests without returning its response to callers, so it can be compared with the production variant before promotion. That engineering pattern transfers well to a quant system, but the SageMaker documentation does not tell you how many consecutive wins justify promotion, and it certainly cannot choose a trading model's weight cap for you.

Those thresholds have to come from your own outcomes, turnover cost, and tolerance for slow regime adaptation.

## A regime is not just a label; it changes which history you should trust

One of the easiest mistakes in dynamic weighting is to assume that more historical samples always make the estimate safer.

If the underlying relationship is stable, more observations can help. But factor behavior can be regime-dependent. Pooling a long stretch of history from a very different environment into one trailing estimate can increase sample size while diluting the relationship that matters now.

Shu and Mulvey's 2025 *Journal of Portfolio Management* paper, [Dynamic Factor Allocation Leveraging Regime-Switching Signals](https://collaborate.princeton.edu/en/publications/dynamic-factor-allocation-leveraging-regime-switching-signals/), explicitly studies dynamic allocation using factor-specific regime inference. My experiment does not reproduce their model and their results do not validate my system, but the paper is useful evidence that regime-conditioned factor allocation is a legitimate research direction, not a project-specific invention.

For an adaptive weighting system, I would rather treat market and regime as evaluation context than as a label added at the end.

For the same momentum factor, the evidence context might look like:

```text
momentum evidence
    + market
    + regime
    + session / universe
    + data quality
        ↓
candidate evidence
```

You do not necessarily need to split everything into tiny independent buckets. Hierarchical shrinkage or other pooled approaches may be better. The important part is not translating "worked historically" directly into "increase the weight now."

The U.S. Federal Reserve's current [model risk management guidance](https://www.federalreserve.gov/frrs/guidance/supervisory-guidance-on-model-risk-management.htm) is about banking model governance, not trading strategy design. But one principle transfers well: ongoing monitoring should consider whether changes in data relevance, exposures, activities, or market conditions mean a model no longer behaves as expected, and outcomes analysis can justify adjustment or redevelopment.

That framing is useful in quant systems too. **A weight vector is neither a permanent constant after one training run nor a temporary variable that should be recomputed casually every window. It needs a version, a context, and explicit change conditions.**

That is also why I put the earlier [factor validation article](/en/posts/quant-factor-validation-bucket-spread-rank-ic/) before this one. First decide whether factor evidence is worth trusting. Then decide how candidate weights are allowed to change. Reverse the order and the model can end up reacting very precisely to noise.

## A 5% cap almost became a 9.1% cap after normalization

The most useful part of this experiment was not the results table. It was an implementation mistake.

My first delta-cap implementation was natural:

```text
target weight
    ↓
clip each factor to old ± 5%
    ↓
normalize so weights sum to 1
```

At first glance, it seems safe. The weights were already clipped to ±5%, and normalization only fixes the total back to one.

In the actual run, the nominal 5% cap produced an observed maximum weight jump of roughly **9.1%** on average.

The reason is simple: clipping constrains the coordinates **before** normalization. Scaling all the weights afterward can push some coordinates farther away again.

In other words, this seemingly reasonable code:

```python
clipped = np.clip(target, old - cap, old + cap)
weights = clipped / clipped.sum()
```

does not guarantee:

```text
abs(weights[i] - old[i]) <= cap
```

That kind of bug is more dangerous than getting one IC value slightly wrong. A dashboard can still say `cap=0.05` while the model is actually moving weights by much more.

I replaced the naive logic with a bounded simplex projection. The new weight vector must satisfy all three constraints at once: nonnegative weights, sum equal to one, and every coordinate still inside `old ± cap`.

The core constraints are:

```text
sum(w) = 1
0 <= w_i <= 1
old_i - cap <= w_i <= old_i + cap
```

The implementation uses a small binary search for the projection offset:

```python
lower = np.maximum(0.0, old - cap)
upper = np.minimum(1.0, old + cap)

lo = np.min(target - upper) - 1.0
hi = np.max(target - lower) + 1.0

for _ in range(80):
    lam = (lo + hi) / 2.0
    w = np.clip(target - lam, lower, upper)

    if w.sum() > 1.0:
        lo = lam
    else:
        hi = lam
```

After that fix, the final experiment's observed maximum jump is 0.05 under the 5% cap and 0.10 under the 10% cap, aside from floating-point epsilon.

That changed how I think about a drift budget. It should not exist only as configuration.

If the system has:

```yaml
max_weight_delta: 0.05
```

then runtime code should also assert something like:

```text
max(abs(new_weight - old_weight)) <= max_weight_delta + epsilon
```

Otherwise you have a parameter, not a constraint.

## Shadow is not about predicting better; it separates research conclusions from production state

I now prefer to split an adaptive factor lifecycle into four layers:

```text
observe
   ↓
candidate
   ↓
shadow
   ↓
active
```

Observe collects evidence but does not change live scoring.

Candidate means research has produced a weight profile that is worth comparing, but it is still not production state.

Shadow evaluates that profile against active on the same future outcomes. A shadow candidate should not earn deployment merely because it was estimated from newer data.

Promotion is the actual state change.

Once those layers are explicit, several distinctions become easier to enforce:

```text
factor IC improves
≠ candidate is definitely better

candidate wins one recent window
≠ promotion is justified

promotion is allowed
≠ weights may move without bounds
```

My false-promotion definition in this experiment is deliberately narrow: when a new profile is promoted and then loses to the previous active profile in the next future evaluation block, I count that as one false promotion.

It is not the only valid definition. It is enough to expose the problem: a candidate that looks stronger over the recent estimation window can still lose immediately after activation.

Shadow confirmations reduce those events, but more confirmations also delay the response to a genuine regime change.

So if I were designing a production pipeline now, I would not hard-code:

```text
3 consecutive wins -> deploy
```

I would first persist:

```text
candidate_version
active_version
market / regime
evidence_window
future_eval_window
weight_delta
turnover
shadow_outcome
promotion_decision
```

Then I would use real settled outcomes to see what different confirmation counts and drift budgets are actually trading off.

My old Notion notes contained ideas such as two or three confirmations, 5%, 8%, or 12–15% drift budgets. This experiment does not prove any of those numbers are correct. It only proves that these controls materially change model behavior, and the direction of that tradeoff is visible.

## I would now treat automatic reweighting as a constrained model-change workflow

Compressed back into a system design, the minimum loop is not complicated:

```text
1. Freeze current active weights
2. Estimate factor evidence from past data only
3. Attach market / regime context
4. Generate candidate weights
5. Enforce hard constraints / drift checks
6. Run the candidate in shadow
7. Wait for future outcomes to settle
8. Compare shadow with active
9. Promote only when the promotion rule is satisfied
10. Persist the complete decision trail
```

The important part is that step 9 should not be a single `if ic > threshold`.

At minimum, I want to know:

- how long the candidate has been winning;
- whether those wins all came from the same regime;
- how large the weight jump would be;
- how much turnover has already accumulated;
- whether the difference between candidate and active comes from one factor or the whole profile;
- how much adaptation lag I accept if I do not promote yet.

None of those questions can be answered by "recent Rank IC = 0.08."

I would go one step further and treat each promotion decision as an outcome-tracking object. Every time the system says "switch weights now," revisit that decision later: what would have happened if it had stayed on the old weights? What if promotion had waited one more window?

After enough of those records accumulate, you can finally make an evidence-based decision about whether shadow confirmation should be one, two, or three windows, or whether the delta cap should be 5% or 10%.

At that point it stops feeling like ordinary parameter tuning.

An adaptive weighting system has to learn not only **which factors are working now**, but also **whether its own model-change rules have historically helped**.

That is where this synthetic experiment leaves me. Immediate is responsive but churns aggressively. Shadow is cautious but can become too slow. A delta cap controls the size of each move, but it cannot replace future-outcome validation.

I am not going to take one number from this table and paste it into production configuration.

The more useful next step is to let these guardrails run in shadow against real settled outcomes: record every candidate the system wanted to promote, then check whether it kept winning. Once the real evidence is large enough, the system can start learning how many false promotions it can tolerate—and how much adaptation lag it is willing to pay for stability.

## Verification context

- Level: first-party
- Verified: 2026-10-08T00:00:00.000Z
- Environment: Python
- Environment: Synthetic regime-switching simulation
- Environment: 12 fixed seeds / 240 periods / 200 assets
- Evidence mode: generated
- Evidence receipt: https://vxi.me/evidence/adaptive-factor-weighting-regime-shadow-drift.json
