The pipeline rebuilds Argentina's monetary panel from eight primary sources and freezes it at the May 2026 CPI print. Everything downstream asks what anchored the disinflation, the peso regimes or the budget. Python ingests and cleans; R estimates. Every published number traces to a script in this repository and recomputes with one command.

What the code does

This is the analysis pipeline behind the working paper Promise versus Delivery: The Fiscal Anchoring of Argentina's Disinflation under Milei, 2023–2026 and the deep dive published on this site.

The project is around 3,700 lines of R and Python, eleven Python scripts on the ingest side and fourteen R scripts on the estimation side. Its empirical spine is three tests. Money demand is replicated after Obstfeld and its break dated with parameter-constancy statistics; the BCRA's own factors of explanation decompose base-money creation, summing to the monthly change in the base exactly, a correlation of 1.0000 over 53 months; and the September–October 2025 stress sequence becomes an event study whose inference rests on a placebo distribution rather than a t-test. Two further scripts, added after two adversarial review rounds, recompute the central claims under everything a referee could reasonably throw at them.

The pipeline

Stage Script Does Produces
1 python/ingest_*.py one source each: BCRA, INDEC, REM, EMBI, parallel FX, fiscal, ITCRM, comparators data-raw/ snapshots, tidy data/*.csv
2 python/build_panel.py merge to monthly + daily panels, phase and event flags data/panel_monthly.csv, data/panel_daily.csv
3 R/01–08, 10, 11 descriptives, money demand, inertia, pass-through, events, decomposition, comparators, durability, DSA output/tables/, output/data/
4 R/09_export_charts.R Chart.js widgets + static PNGs output/widgets/, output/figures/
5 R/12–13 (on demand) referee-round robustness on the locked panel referee_strengthening.md, tribunal2.md

run_all.R wraps each stage in a handler that halts on the first error and closes with a manifest of every expected table and figure, so a broken ingest never produces a half-built paper.

Walkthrough

One decision governs the whole build. The data vintage is locked: raw downloads were frozen on June 13, 2026, the day after the May CPI print, and no observation after May 31 enters the panels. A locked vintage is worthless as a policy statement if the ingest can quietly be re-run later, so the lock lives in code rather than in a README.

`python/common.py`: the vintage lock every ingest script calls first
def vintage_guard():
    p = params()
    if p.get("vintage_locked"):
        sys.exit(
            "REFUSING TO RUN: vintage_locked is true in config/params.yaml.\n"
            "The data vintage is frozen. Set vintage_locked: false only if you "
            "intend to re-open the vintage (and say so in the paper)."
        )

Every ingest script opens with this call, and config/params.yaml carries vintage_locked: true. Re-opening the vintage therefore requires an explicit config edit that the diff records, and the referee scripts written weeks after the freeze recompute on the locked panel only. Estimation stays reproducible against a fixed dataset while the Argentine statistics it describes keep moving.

Getting that dataset out of the BCRA is its own exercise. The central bank's statistics API identifies each series by a numeric id, and those ids are not stable documentation; hardcoding them is how a pipeline silently starts downloading the wrong aggregate. The ingest never hardcodes an id. It fetches the full variable catalog and matches descriptions against regexes kept in config, refusing to proceed on anything other than exactly one hit.

`python/ingest_bcra.py`: series ids resolved from the catalog, never hardcoded
def match_one(cat, spec, key):
    """spec: string (regex) or dict {regex, categoria?, unidad?}"""
    if isinstance(spec, dict):
        pattern  = spec["regex"]
        cat_filt = spec.get("categoria")
        uni_filt = spec.get("unidad")
    else:
        pattern = spec
        cat_filt = uni_filt = None

    rx = re.compile(pattern, re.IGNORECASE)
    hits = [v for v in cat if rx.search(v.get("descripcion", ""))]
    if cat_filt:
        hits = [v for v in hits if v.get("categoria", "") == cat_filt]
    if uni_filt:
        hits = [v for v in hits
                if uni_filt.lower() in v.get("unidadExpresion", "").lower()]
    if len(hits) == 1:
        return hits[0]
    print(f"\nREGEX PROBLEM for '{key}': pattern {pattern!r} matched "
          f"{len(hits)} catalog entries (after filters):")
    for v in (hits or cat)[:40]:
        print(f"  id={v.get('idVariable'):>5}  cat={v.get('categoria')}  "
              f"unit={v.get('unidadExpresion')}  {v.get('descripcion')}")
    sys.exit("Fix the regex/filters in config/series_ids.yaml and re-run.")

Zero or multiple matches dump the relevant catalog slice and stop, so the fix is always a config edit made against the dumped catalog. Each resolved id is written back into config/series_ids.yaml for the record, and the raw catalog is snapshotted under data-raw/bcra/ with the day's date. When the BCRA deprecated API v3.0 mid-project, the regexes survived unchanged; only the endpoint wrapper moved.

Nothing in the repository changed more between drafts than the event study's inference. A first draft measured the post-election repricing of Argentine sovereign risk with a plain t-test and got t = −47.70, a number no daily financial series can honestly produce; serial correlation in the levels had collapsed the standard error. Headline inference now asks a placebo question instead: how unusual is the observed post-event move against the same statistic computed on every ordinary day of the sample?

`R/05_events.R`: the placebo distribution and its empirical p-value
# placebo pool: the abnormal-mean statistic on non-event dates, excluding any
# pseudo-event within `guard` trading rows of a real flagged event
placebo_dist <- function(x, ds, event_rows, min_date = NULL) {
  lo <- bpre[1] + 1
  hi <- length(x) - wpost
  cand <- lo:hi
  if (length(event_rows))
    cand <- cand[vapply(cand, function(i)
      all(abs(i - event_rows) > guard), logical(1))]
  if (!is.null(min_date)) cand <- cand[ds[cand] >= min_date]
  vals <- vapply(cand, function(i) {
    r <- abnormal_at(x, i)
    if (is.null(r)) NA_real_ else mean(r$ab)
  }, numeric(1))
  vals[is.finite(vals)]
}

# two-sided empirical rank of the actual abnormal in that null
pval   <- (1 + sum(abs(placebo)   >= abs(est))) / (1 + length(placebo))
pval_n <- (1 + sum(abs(placebo_n) >= abs(est))) / (1 + length(placebo_n))

Under this design the statistic is the mean abnormal move over the eleven trading days from the event, measured against a baseline of days −30 to −11. placebo_dist recomputes that identical statistic at every candidate day outside a 21-day guard band around the real events, roughly 950 pseudo-events, and the p-value is the actual move's rank in that distribution, with the +1 in numerator and denominator keeping it valid at finite samples. Two pools discipline the reading. The full pool spans the 2022 hyperinflation, whose volatility widens the null and pushes p upward, so a result that survives it is conservative; a narrow pool restricted to the post-December-2023 regime supplies the comparable-volatility check. On the EMBI, the September election repricing of +454 basis points comes in at p = 0.065 against the full pool and 0.022 against the narrow one, and the post-midterm drop of −497 at 0.041 and 0.020. One event the design refuses to read is the October US swap, whose baseline opens inside the election's ongoing repricing. A contamination rule fixed before estimation flags it, and its p of 0.990 is recorded as uninformative; it is not evidence of no effect.

The money-demand test carries the same discipline into identification. Real balances broke upward in December 2023, but the 118% devaluation moved the price level that month, so a mean shift proves nothing about behavior. What has to move is the slope. The test re-estimates the inflation semi-elasticity with a dummy absorbing the devaluation window, so the interaction coefficient measures the change in the demand relationship net of the step.

`R/02_money_demand.R`: the slope shift net of the devaluation window
# pooled model lhs ~ lpi_e * post + deval: the lpi_e:post coefficient is the
# change in the elasticity, net of the Dec-2023..Mar-2024 devaluation step
dd <- data |> mutate(post  = as.integer(date >= thr),
                     deval = as.integer(date >= as.Date("2023-12-01") &
                                        date <= as.Date("2024-03-01")))
fit2 <- lm(stats::as.formula(paste0(lhs, " ~ ", nm, " * post + deval")), data = dd)
ct2  <- nw_test(fit2)   # Newey-West, lag = floor(0.75 n^(1/3))

Real transactional M2 is the headline regressand for this test because the narrow base absorbed an accounting migration in July 2024 that the demand series did not. That interaction survives, and the referee script then grinds it across Newey–West lags one through six and three definitions of the devaluation window: 18 cells, estimates between +0.098 and +0.201, all significant at 5%. A moving-block bootstrap in the second review round returns a two-sided p of 0.089, so the paper reports the break as suggestive rather than conclusive. The honest number ships.

Five months of post-regime data cannot support the persistence-break test the argument ultimately needs, and the repository says so in a way that binds. The formal test is written out in full and deliberately not run.

`R/03_inertia.R`: the pre-registered test, committed but not run
# PRE-REGISTERED FOLLOW-UP TEST — NOT RUN AT THIS VINTAGE.
# Pre-registered 2026-06. Runnable once >= 12 post-regime observations exist,
# i.e. after the December 2026 CPI print. Do not modify the specification
# below between now and then.
#
# (1) Chow-type break in persistence at 2026-01:
#     X <- stats::embed(full$infl_core, p_bic + 1)   # p_bic from full-sample BIC
#     strucchange::sctest(y ~ ., data = dat, type = "Chow",
#                         point = which(dat$date == as.Date("2026-01-01")))

At every run the script counts the post-regime observations and prints how many are still missing. Once the December 2026 print exists, the specification runs as committed; any change between now and then has to be documented as a change. A test whose form is fixed before its data exist cannot be tuned to its result. That is the entire value of writing it down in June.

Reproducing it

make deps, a one-time make bcra-cert, and make all rebuild everything from raw downloads to figures; with the vintage locked, make analysis recomputes estimation from the committed panels instead. The BCRA's API server presents an incomplete TLS certificate chain, and instead of disabling verification, make bcra-cert exports what the server actually serves via openssl s_client into a local CA bundle the ingest scripts verify against. Verification is never switched off. Every raw download lands timestamped under data-raw/{source}/{date}/ and is never overwritten; documentary figures the pipeline cannot derive sit in a config CSV with a citation per row. The code and generated figures are licensed CC BY 4.0, reuse with attribution.