# 2026-09-30 method

A specification: every rule this case was computed by, with its formula. Every number
is read from the code that applies it (`runner/method_spec.py`), and
`tests/test_method_spec.py` holds each one equal to that code. The case's inputs are
`inputs.json` (the question); its answer is `case.json`, and nothing else.

| item | value |
|---|---|
| commit | d954fab |
| market | brent |
| quarter | 2026-Q3 |
| issue date | 2026-10-08 |
| frame end month | 2026-09-30 |
| fit start | 2018-10-31 |
| fit end | 2026-09-30 |
| scoring rule | archive/SCORING_RULE_v2.md (version 2) |
| libraries | python 3.13.15, numpy 2.5.3, scipy 1.18.1, pandas 3.0.5 |

The library versions are those of the environment the draft was computed in, read
from it when the draft was built and frozen in inputs.json (`draft.libraries`).
Where this spec says "scipy's default", it is that scipy version's default.

## Parameters

| key | parameter | value | code |
|---|---|---|---|
| window_rows | training window (frame rows) | 96 | `config.settings.rolling_window_months` |
| alpha | ridge alpha | 2.0 | `config.settings.baseline_regularization` |
| coverage_min | minimum share of window rows a driver must be observed on | 0.95 | `engine1.baseline._MIN_FIT_COVERAGE` |
| ridge_solver | bound-constrained least-squares method (scipy lsq_linear) | trf | `engine1.baseline.RIDGE_SOLVER` |
| ridge_tolerance | lsq_linear convergence tolerance (tol) | 1e-10 | `engine1.baseline.RIDGE_TOLERANCE` |
| pin_tolerance | absolute coefficient below which a bounded driver is reported pinned | 1e-08 | `engine1.baseline._PIN_TOLERANCE` |
| z_min_history | earlier values needed before a z-score exists | 12 | `data.processing.normaliser.Z_MIN_HISTORY` |
| z_ddof | z-score standard deviation ddof | 0 | `data.processing.normaliser.Z_DDOF` |
| price_space | price space | linear | `config.settings.price_space` |
| horizon | forecast horizon (months) | 12 | `config.settings.forward_horizon_months` |
| first_origin | first walk-forward origin | 2014-01-31 | `engine1.forward.walk_forward.FIRST_ORIGIN` |
| band_low | band lower percentile | 10 | `engine1.forward.walk_forward.BAND_LOW_PERCENTILE` |
| band_high | band upper percentile | 90 | `engine1.forward.walk_forward.BAND_HIGH_PERCENTILE` |
| percentile_method | percentile definition (numpy method) | linear | `engine1.forward.walk_forward.PERCENTILE_METHOD` |
| min_band_errors | minimum walk-forward errors for a band | 24 | `engine1.forward.price_path.MIN_BAND_ERRORS` |
| band_floor | band floor (USD/bbl) | 5.0 | `engine1.forward.price_path.BAND_FLOOR` |
| min_halflife_pairs | minimum walk-forward gap pairs for a half-life | 25 | `engine1.forward.convergence.MIN_HALFLIFE_PAIRS` |

## Active drivers

Publication lags are ALREADY APPLIED in inputs.json (item 1). The fill limit is the
lag plus one, counted in FRAME ROWS: a value is carried forward over at most that
many rows of the frame, whatever calendar span they cover. The frame is a complete
month-end series, so on it a row is a month.

| id | name | feature | publication lag (months) | fill limit (frame rows) | sign constraint |
|---|---|---|---|---|---|
| F1 | Global Economic Activity | global_activity | 1 | 2 | >= 0 |
| F3 | Geopolitics & Disruptions | geopolitics | 1 | 2 | >= 0 |
| F4 | US Crude Production | non_opec_supply | 2 | 3 | <= 0 |
| F5 | USD Exchange Rate | usd_index | 0 | 1 | <= 0 |
| F6 | Inventories & Balance | inventory_balance | 0 | 1 | <= 0 |
| F8 | Monetary Policy & Rates | rates | 0 | 1 | free |
| F13 | Product Spread (1:1 crack) | product_spread | 0 | 1 | >= 0 |

## The rules

Notation: month-end rows t of the frame; m_t is `target_price`, the arithmetic mean
of the month's daily FRED DCOILBRENTEU settlements (the market price); z_{j,t} is
driver j's z-score column.

### 1. Publication lags: already applied; do not apply them again

Each raw driver column in inputs.json was shifted by its publication lag BEFORE the
month-end alignment and before z-scoring (`data.processing.lag_handler`), so the value
on row t is the value published by the end of month t. The z columns are computed from
those lagged columns. A re-derivation uses the columns as they are; shifting them again
double-counts the lag. The lag enters the fit once more only as the forward-fill limit
(section 3).

### 2. z-scores

z_{j,t} = (x_{j,t} - mean(x_{j,s}, s < t)) / std(x_{j,s}, s < t), over every earlier
non-missing value, standard deviation with ddof 0, and missing until
12 earlier values exist. Strictly earlier values only. The frozen z columns
hold exactly this; at each walk-forward origin the z-scores are recomputed the same way
on the raw columns cut at the origin, which gives the same values.

### 3. The fit at a date t (window, coverage screen, fill, ridge)

1. Window: the last 96 ROWS of the frame up to and including t (rows, not
   complete rows).
2. Coverage screen: an active driver whose z is non-missing on fewer than
   95% of those rows is dropped from the fit at t. (On the 2026-08-31
   frame this is what the audit's 11 differing fair-value band edges came from: at the
   origins 2014-01 to 2014-07 the USD index was observed on under 95% of its
   window rows, and the fit there ran on six drivers.)
3. Fill: each driver's z is carried forward at most its fill limit (lag + 1 frame rows).
   A row still missing a driver is dropped (listwise). Nothing is ever filled with 0.
   Scope: the screen (step 2) counts the window rows BEFORE any fill. For the fit, the
   carry-forward runs inside the window rows only, so a gap in the window's first rows
   is not filled from a row before the window. For the fair value at t (step 5), the
   carry-forward runs over the whole history up to t.
4. Ridge, intercept unpenalised. Centring: mean(x) and mean(y) are taken over the
   FIT ROWS only -- the window rows left after the fill and the listwise drop that
   also carry a target price -- never over all window rows and never column by
   column over each driver's own non-missing rows. A row dropped in step 3 enters
   neither mean. Then centre X and y on those means and minimise
   sum_i (y_i - sum_j b_j x_ij)^2 + alpha * sum_j b_j^2 with alpha = 2.0 -- the
   plain SUM of squared errors, not a mean -- and intercept c = mean(y) - mean(x) . b.
5. Fair value at t: c + sum_j b_j z_{j,t} on the filled row t. A month missing an
   active driver after the fill has no fair value; the case refuses to issue if row t
   is such a month.

### 4. Sign constraints: pinning

Solved as one bound-constrained least-squares problem, not by iterative removal:
minimise ||[X_c; sqrt(alpha) I] b - [y_c; 0]||^2 subject to b_j >= 0 for '>= 0',
b_j <= 0 for '<= 0', free otherwise (`scipy.optimize.lsq_linear`, method
'trf', tol = 1e-10, scipy's default otherwise). A constrained
coefficient with |b_j| < 1e-08 is reported as
pinned; it contributes nothing.

### 5. Half-life

From WALK-FORWARD gaps, never from the fit's residuals. At every origin t from
2014-01-31 (section 8), with FV_t the fair value fitted on rows up to t:
g_t = m_t - FV_t and, one month later against the SAME fair value,
r_{t+1} = m_{t+1} - FV_t. At the case date d, over every pair with t+1 <= d:
phi = sum(g_t r_{t+1}) / sum(g_t^2) (AR(1) through the origin, no intercept) and
half-life = ln(0.5) / ln(phi). With fewer than
25 pairs, or phi outside (0, 1), there is NO CONVERGENCE: no half-life, no
fallback value, and the forecast is the market price held flat. case.json `half_life`
states which, with phi and the pair count.

The half-life known at an origin o (section 8) is this rule with d replaced by o:
the pairs with t+1 <= o, the same minimum and the same (0, 1) test.

### 6. Path shapes

- Fair-value path (the diagnostic): FV_d at every horizon (drivers held at their last
  value).
- Forecast path (the headline): P50_h = FV_d + (m_d - FV_d) * 0.5^(h / half-life),
  h = 1..12; with no convergence P50_h = m_d.

### 7. Sigma

gap = m_d - FV_d (market minus fair value). sigma = gap / s, s the standard deviation
with ddof 0 of the fit residuals y_i - fitted_i on the window rows that have one (the
in-sample RMSE, the residual mean being zero).

### 8. Bands and the percentile definition

Walk-forward origins: every month end from 2014-01-31 to d, each fitted on its own
rows up to the origin (section 3). Error at horizon h from origin o, counted when
o + h <= d: fair value, e = m_{o+h} - FV_o; forecast, e = m_{o+h} - P50_h(o), the path
drawn at o at the half-life known at o (section 5). Per horizon, with at least
24 errors: P10 = centre + q10, P90 = centre + q90, the 10th and 90th
percentiles by numpy's 'linear' method (Hyndman-Fan type 7). The lower edge is
floored at $5. The band is published only if P10 <= P50 <= P90 after the
floor; otherwise, or with too few errors, P10 and P90 are null and the reason is
stated. Fair value's band today (h = 0) is built the same way from the same-month
errors m_o - FV_o of the origins before d.

An origin's fit FAILS when the fit at o (section 3) raises an error; the case it
anticipates is an active driver that clears the coverage screen but has no value on
row o after its fill, so FV_o does not exist (section 3, step 5). A failed origin
has no fair value and no forecast path: it contributes no error at any horizon, no
half-life pair, and no same-month error to today's band. It is not retried with
fewer drivers.

### 9. Break dummies are not used

The frame carries `break_*` columns (known structural-break windows). No fit, path or
band reads them; the fit uses the z columns in `fit.feature_columns` and nothing else.

### 10. The rest

- The market price is `target_price` at d, the month's average settlement, not the
  last one. The frame carries complete months only.
- The gap's sign is market minus fair value.
- The window counts frame rows (section 3); nothing is fitted without the coverage
  screen and the fill. The minimum training months setting belongs to the one-step
  WF-RMSE diagnostic and shapes no case output.

### 11. Which band the case scores

Both, and never merged. `forecast` -- the market-anchored path and its band -- is the
headline; `fair_value_path` is the diagnostic. The scorer reads both from case.json
(`data.storage.scores.band_of`) and counts them apart. inputs.json holds no answer.

### 12. When a case is issued, and which scoring rule applies

A case is ISSUED when it is signed, and on the day it is signed: `signed_date` in
case.json. Only a signed case is issued; a draft is not, whatever its `issue_date`.
`issue_date` is the date the draft was prepared for -- the ingestion ceiling of the
run -- and is not the day of issue. The case id is the FRAME END (the last complete
month the data reaches), not the issue date and not the signing date.

The scoring rule that APPLIES to a case is fixed by `signed_date`: the latest rule
version dated on or before that day, as each rule document's section 9
(Applicability) states ("issued on or after" its date). Where a document's header
says "issued after" instead, section 9 governs. case.json carries it as
`scoring_rule_applicable`, which is null in a draft and is filled when the case is
signed. `scoring_rule` in a draft names the rule the draft was prepared under; a
signed case is scored under `scoring_rule_applicable`.

## Band construction

| item | value |
|---|---|
| method | walk_forward_horizon_quantile |

## Rules

| document | version |
|---|---|
| archive/SCORING_RULE_v2.md | 2 |
| archive/ARCHIVE_POLICY.md | sha256 ef344a2ab6ee837f |
| archive/CALIBRATION_RULE.md | sha256 0dfd08948b085500 |
