Repository navigation
Make a retention-rate elasticity of 1 the default CGT realisation response - #2419
Conversation
relative_wage_change, relative_income_change and
employment_income_behavioral_response took the baseline from
simulation.get_branch("baseline"). policyengine_uk.Simulation keeps its
baseline as a separate simulation in simulation.baseline, so that call
made a branch of the reform simulation, with the reform's parameters,
and every response was zero (#2268).
The formulas now measure on one pair of branches, of the reform and of
simulation.baseline, opened by the response for each period. Both hold
the reform's earnings and gains before responses with the response
neutralised. The shared measurement_branch helper, which the capital
gains response now uses too, drops what the branched simulation had
already calculated, so neither response depends on calculation order
or changes on recalculation.
Fixes #2268
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the labour supply response working, each response's measurement recomputed the other: the labour supply branches counted lower realised gains as lost income, and the branches that step gains up to measure the capital gains rate recomputed the labour supply response against a baseline held at gains before the step. On a 16-person dataset with both responses on, the capital gains response went from -150,569 to -554,679. Every measurement branch now neutralises all three response variables, so each response is measured from the position before any response and does not change when the other is switched on. Tests state that, and that every *_behavioural_response variable is in BEHAVIOURAL_RESPONSES. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI's unpinned ruff formats Python inside Markdown, and the substitution elasticity line in the elasticity-based responses example was over 88 characters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation that applies its baseline's rules (one built without a reform, every branch of one, and a reform's own baseline) now skips the marginal-rate measurement, whose answer would be zero, as does a population without gains. The check compares the two tax-benefit systems' parameter timelines and variable definitions, so rules edited after construction, as policyengine.py's modifiers edit them, are still measured. Marginal tax rates on earnings now hold the realisation response at the simulation's own values in their pay-rise branches. Measured again there, the response compared the reform's rate at the higher pay with the baseline's at the old pay, so at a band edge the pay rise counted as part of the reform. The response settings now resolve through capital_gains_response_assumption, which lets an MTR elasticity apply alone, with a warning, where the retention-rate settings keep their defaults, and reports the response a simulation applies. Part of #2396. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ponse gov.simulation.capital_gains_responses.elasticity now defaults to 1.0, the central value Advani, Lonsdale and Summers (2024, pp. 36-37) use, with 0.5 and 2.0 as sensitivities; mtr_elasticity stays 0 and separate_badr_elasticity false, so qualifying gains take 1.0 too. The parameter descriptions state the assumption's qualifications and the static recipe. Tests that checked the static default now set the elasticity they need, the MTR-convention tests set the retention-rate elasticity to 0, and new tests cover the default through every route, the explicit alternatives, legacy MTR settings, dated settings, the cases that never measure, the pay-rise branches, stability and the assumption helper. Fixes #2396. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new assumptions page describes how the response works, the default and its qualifications, when there is no response, the other assumptions and the marginal tax rate convention, the assumption helper, how to move from the static default, and the runtime. The simulations guide's revenue identity and the dynamics guide point to it. Part of #2396. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
marginal_tax_rate_on_capital_gains recalculated household net income in each branch, all of whose programmes it has to reach. Realised gains reach net income only as market income and through capital_gains_tax, so the rise in that tax per pound of marginal gains is the same rate, and its branches recalculate only what the tax depends on. On the enhanced FRS a measurement now costs about a sixth of what it did, which takes the response from about 13 to about 2 seconds per reformed simulation and year; the rate matches the old one within 0.001 for every person with gains. A test traces a net income calculation and fails if any variable other than capital_gains_tax and household_market_income reads capital_gains; another checks the rate against a direct net-income difference. Part of #2396. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vahid-ahmadi
left a comment
There was a problem hiding this comment.
@juaristi22 review of c6c77ef32 (this PR's own commits, b3831462b..c6c77ef32).
Verdict: the code is sound and well tested. Fine once the release type is settled (point 1). Before the app adopts it, the framing of 1.0 and the effect on household-level outputs need a user-facing note (points 2 to 4).
Findings
-
must: a default change that re-scores every CGT reform ships as a patch.
.github/bump_version.py:34-38mapsbreakingto major,added/removedto minor and everything else to patch. Withchangelog.d/2396.changed.mdand #2277's.fixed.md, this release is a patch. It changes the costing of every CGT reform, moves income tax reforms too (point 4), and makesmtr_elasticity-only calls warn. Any downstream~=or caret pin picks that up silently, which is the same risk the PR body's merge-order note about PolicyEngine/policyengine.py#620 is guarding against. I'd make it2396.breaking.md, or at least add an.addedfragment forcapital_gains_response_assumption(a genuine addition) so it's minor. Either way it should be a conscious choice, not the default bump. -
should: say plainly which way CenTax's own reasoning points for the reforms PolicyEngine usually scores. I read pp. 32-37 of the paper. Their elasticity is on the same base as this PR's: aggregate realisations against the retention rate. Their starting point is about 1.5 (the Agersnap and Zidar five-year estimate with controls). They then cut it to 1.0 for two features of their package: removing uplift at death and an exit charge, and counting gains that shift into income tax as revenue kept. The paper then says (p. 37): "A reform that lacked these base-broadening measures would surely result in a higher behavioural elasticity (implying lower revenues) than we have estimated." A rate-only reform in PolicyEngine has neither feature, and PolicyEngine adds no income tax back. So by their logic, 1.0 is at the low end for those reforms, and 1.5 is the unadjusted figure. The description at
elasticity.yaml:1anddocs/book/assumptions/capital-gains-responses.md:43-51say it "isn't an estimate for every reform". That's true but it doesn't say which way the error goes. I'd add one sentence saying that for rate changes without base reform, the source implies a higher elasticity, and therefore lower revenue. Whether to keep 1.0 or move to 1.5 is a methodology judgement for the maintainers. I'd keep 1.0 only with that sentence. -
should: 1.0 is a medium-term value, and the app scores single years. CenTax use 1.0 for the five-year effect. The study it rests on gives 1.6 at three to five years, and the Canadian study gives 1.8 in the first year for large gainers. Neither includes forestalling. So a year-one score will understate the response, and the gap from official costings will be largest in the first year. CenTax (p. 4) note that HMRC's ready reckoner has a 10pp rise in the top CGT rates losing £2bn against more than £5bn static. Under this functional form, the revenue-maximising rate is 1/(1+e): 50% at 1.0, 40% at 1.5, 33% at 2.0 and about 22% at 3.6. On a synthetic household (£100k earnings, £200k gains), main and additional CGT rates at 45% raise the person's CGT from £47,280 to £63,782 at the default. At 3.6 it falls to £26,744. So the default decides the sign of the headline for large rate rises against official figures. That divergence is legitimate, but it should be stated in the docs page and in the release note, with the horizon caveat.
-
should: household-level outputs now count forgone realisations as lost income. Same household, same reform:
household_net_incomefalls by £41,370 static and by £71,765 at the default. Meanwhile the government gains £16,502 instead of £41,370. The response lowerscapital_gains, which feedshousehold_market_income, so decile charts, winners and losers, and per-household impacts will show a CGT rise costing households far more with behaviour than without. Gains not realised are deferred, not lost. This was already true for anyone who set an elasticity, but the default makes it the published number.docs/book/usage/simulations.md:362redirects the revenue identity togov_balance, which covers revenue only. Either decide that distributional outputs use gains before the response, or document it prominently before the app step. Related: non-CGT reforms move too. With earnings of £30k and gains of £20k, cutting the basic rate band to £20,000 or the personal allowance to £5,000 lowers realised gains by £1,463 (7.3%), because more of the gains fall in the higher CGT band. That's correct, butchangelog.d/2396.changed.mdonly says CGT reforms are no longer static, so published income tax costings will shift without notice. -
nit: band-specific reforms give no response above the band. With the same household and only
gov.hmrc.cgt.higher_rateraised to 45%, CGT rises from £47,280 to £65,642 but the response is exactly zero, because the marginal pound of gains is taxed at the unchanged additional rate. That's the right result for a marginal retention-rate design, but one line in "When there is no response" (capital-gains-responses.md:84) would save someone a bug report. -
nit: the base level. The response scales gains proportionally, so it can't fix the base-year level. On the current enhanced FRS, gains and CGT sit at roughly 45% of HMRC's 2024-25 outturn (my comment on #2274). So any pound comparison with the OBR elasticity setting (
badr_elasticity.yaml:1) mixes the elasticity gap with the level gap. Worth one clause in the docs comparison. Also, carried interest moved to income tax from April 2026, so the carried interest schedule's share of the rate matters only for earlier years.
Points I checked that are fine
- The baseline-equality shortcut doesn't skip non-CGT rules that move the CGT rate.
same_rules(utils/rule_comparison.py:142) compares the whole parameter tree and every variable, leaving out only the response settings. So an income tax band or personal allowance reform is measured (the £1,463 above), and an elasticity-only reform is correctly skipped with a zero response. Anything it can't compare counts as different, so the only risk is cost. Input-only scenario changes get no response, and the docs (capital-gains-responses.md:95) say so. - The MTR pay-rise hold (
marginal_tax_rate.py:34,marginal_tax_rate_wrt_employer_cost.py:38). Holding the response at the simulation's own values is right: the earnings MTR then includes the higher CGT band on the same gains but not a fresh response. - Rate from CGT alone. Outside the CGT folder,
capital_gainsis read only byhousehold_market_income. That in turn feeds net income, gross income (unused) and the private school imputation, which you note. I found no means test or allowance taper that reads gains, so the narrower definition is equivalent apart from that imputation. - Legacy shim and reporter. An
mtr_elasticityof -0.7 alone resolves toconvention: "mtr"with oneCapitalGainsResponseWarning, sources markedauto_resolved, and reverts toretentionin years outside the override.to_dict()serialises. - Dependence on #2277. This is code-coupled, not just order-coupled:
utils/capital_gains.py:14importsmeasurement_branch, which exists only in #2277. Decoupling would mean moving that helper here, so keeping the stack is simpler. The merge order (PolicyEngine/policyengine.py#620, then #2277, then this) is right.
What I checked
- Fresh worktree at
c6c77ef32with its own uv venv. pyteston the seven touched test files (non-microsimulation): 171 passed. CGT YAML (tests/policy/baseline/gov/hmrc/capital_gains_tax): 48 passed. These build simulations without a baseline, so the default can't affect them.ruff format --checkandruff check: clean. CI green at this head.- Reverting source and re-running the new tests:
- with the elasticity set back to 0: 20 of 37 fail;
- with the pay-rise hold removed: 2 fail, both reproducers;
- with the same-rules shortcut disabled: 3 fail.
- Household probes for the numbers above, all synthetic. The docs example runs and prints £144,737.
- Read the CenTax paper's pp. 4 and 32-40.
- Not checked: the microsimulation tests and the full-dataset runtime and "within 0.001" claims, because I have no dataset credentials here. I also didn't check the OBR costing's own elasticity definition against the source.
🤖 Generated with Claude Code
From Vahid's review of #2419: - The elasticity's description and the docs say that CenTax reach 1.0 from about 1.5 by adjusting for their package's base changes and income shifting, and expect a higher elasticity without them (p. 37), so for rate changes alone the default overstates what a rate rise raises; and that 1.0 is a medium-term value, so first-year scores likely understate the response (pp. 34-35). - A "Large rate rises" section gives the revenue-maximising rate 1/(1+e) and shows the elasticity can decide the sign of a large rise, against the ready reckoner's figures (p. 33). - "What the response moves": household net income counts gains no longer realised as income lost, and income tax band and allowance reforms move realised gains too. The changelog says both. - "When there is no response" covers a reform to a band the last pound of gains doesn't fall in, and the OBR comparison notes that pounds mix the elasticity gap with any gap in the level of gains. - An added fragment for capital_gains_response_assumption and CapitalGainsResponseWarning, so the release is a conscious minor bump rather than the default patch. Every figure comes from synthetic households. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main added test_labour_supply_parameters_are_read.py, whose fixture checks that the formula path ran by looking for the lsr_measurement branch. This PR no longer keeps measurement branches, so the fixture now checks that the formula measured a nonzero response instead, which it also does only after its early returns. The docs example in dynamics.md is wrapped to pass ruff format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
marginal_tax_rate, marginal_tax_rate_wrt_employer_cost and marginal_tax_rate_on_capital_gains step an input in a branch that shares the simulation's tax-benefit system, and recalculate every formula there. With the labour supply response now measured against the baseline, the branch measured it again: at the stepped input, against a baseline without the step, and with the step undone inside the measurement's own marginal rate branches. On £40,000 of earnings and £22,000 of gains under a 30% basic rate with elasticities set, the response inside the gains branch hit the wage change bound (-£9,921 against -£1,406 on the reform) and the capital gains marginal rate came out at 502% instead of 24%. With employer NI price incidence on, the pay rise branches gave 94.2% and 94.9% instead of 95.5% and 96.1%. held_behavioural_responses reads the simulation's live responses once, and hold_behavioural_responses sets them on each branch, generalising the gains-only hold marginal_tax_rate_on_capital_gains already had. The same pay-rise problem affected the capital gains response on main (830% and 1,164% against 28% and 48%), which this also fixes. Neutralised responses, as in measurement branches, are not held. A test now checks the measurement branch's purge directly: with core 3.33, a branch's held inputs already drop dependent values, so the existing order tests pass without it, but a branch with no inputs would still read its simulation's post-response net income. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sponse A branch that shares its parent's tax-benefit system changes inputs, not policy. The Pension Credit branch of the UC non-dependant exemption fixes the UC housing element at zero, and measured the labour supply response again for the whole population with that element held on the reform's side only: for a renting UC family with a £22,000 earner, +£5,743 inside the branch against -£754 on the reform. Every reform with elasticities then opened two measurement pairs per period instead of one, which on 300 synthetic households doubled the reform's household_net_income time. response_source walks up to the nearest ancestor with other rules (the simulation itself, or a measurement branch), and the response formula returns that simulation's response. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
income_elasticity_lsr and substitution_elasticity_lsr multiply a measured relative change by their elasticity, so with the elasticity at zero they are zero. They still measured the change, which opens a measurement pair: policyengine.py's UK labour supply output asks for both terms on every simulation, so on current law each produced its zeros from a pair of branches. With one elasticity set, the other term no longer computes its relative change (the wage change needs marginal rates in both branches). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2277 (fix-lsr-baseline-2268) now holds every behavioural response in the branches of the three marginal rate variables, through held_behavioural_responses and hold_behavioural_responses. This branch used its own gains-only pair for the same purpose; the conflicts in the three variables resolve onto #2277's helpers, keeping this branch's tax-only rate on gains, and the gains-only pair is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @vahid-ahmadi. Every household figure in your review reproduces on the branch. Changes are in 0e6cfaa, followed by merges of main and of the updated #2277.
Also since your review:
🤖 Generated with Claude Code |
vahid-ahmadi
left a comment
There was a problem hiding this comment.
@juaristi22 round 2, at 8502e5c6c. I looked at this PR's own diff (276cfcdeb..8502e5c6c, the compare view against #2277) and at what changed since c6c77ef32.
Verdict: all six round-1 points are fixed. Fine to merge in the planned order once its blockers are done. One pre-existing bug in the CGT band split (point 7) is outside this diff but worth a separate fix, because the default now turns it into a response.
Round-1 points
- Release type: fixed. With
2396.added.md,2396.changed.mdand #2277's.fixed.mdinchangelog.d,.github/bump_version.py'sinfer_bumpreturnsminor, so 2.132.6 goes to 2.133.0. policyengine.py pinspolicyengine-uk==2.130.1exactly, so it won't pick this up silently either way. The "Release" section in the body is clear, so minor is a reasonable call. - Which way 1.0 errs: fixed.
elasticity.yaml:1, the docs (capital-gains-responses.md:44-53) and the changelog now say that for a rate-only reform the source points above 1.0, so the default overstates what a rate rise raises. I checked this against the paper: the 1.5 starting point and the p. 37 "surely ... higher" sentence are both there. The default stays at 1.0, with 1.5 left as a methodology call. - Horizon and sign: fixed. The 1.6 at three to five years and the 1.8 first-year figure match the paper. I re-ran the 45% example. CGT changes by +£16,502 at 1.0, +£6,777 at 1.5 and -£20,536 at 3.6, so the "Large rate rises" figures are right. The 1/(1+e) peaks are right too. You're right that p. 33 has both the £2bn and the "in excess of £5bn" figures, and p. 4 only the £2bn. My round-1 page reference was wrong.
- Household outputs: fixed (documented). I reproduced the £71,765 against £41,370 drop in net income, and the £1,463 (7.3%) for both the basic-rate-band and personal-allowance cuts. "What the response moves", the simulations note and the changelog all state them. The pre-response question is left open as a methodology decision.
- Band-only reforms: fixed. The higher-rate-only example reproduces: £47,280 to £65,642, with a response of exactly 0. But see point 7: that £65,642 depends on a pre-existing band bug.
- Base level: fixed.
badr_elasticity.yaml:1andcapital-gains-responses.md:191-194now say that a comparison in pounds mixes the gap between the elasticities with the gap in levels.
New findings
-
should (pre-existing, not in this diff): the CGT split between the higher and additional rates ignores income already in the higher band.
capital_gains_tax.py:71setshigher_rate_band = higher_rate_limit - basic_rate_limit, and line 172 fills it from gains above the basic rate band. Nothing subtracts the taxable income that already sits above the basic rate limit. So every gainer gets the full higher band (about £87k) for gains, wherever their income ends. Synthetic checks, static (elasticity 0):- £200,000 earnings, £50,000 gains,
gov.hmrc.cgt.additional_rateat 45%: CGT stays at £11,280. Withhigher_rateat 45% instead, it rises to £21,150. An additional-rate taxpayer's gains are all charged at the higher rate. - £120,000 earnings, £30,000 gains: all of the gains are charged at the higher rate, although about £19k of them sits above the £125,140 limit.
With equal higher and additional rates (current law), this changes nothing. The docstring (lines 35-37) says the split exists only so that reforms can set an additional rate, and for those reforms it charges the wrong rate. Under this PR's default it also drives the response. For the £120k case with only the higher rate at 45%, realised gains fall by £8,289 at 1.0, where the corrected split would give no response at the margin. An additional-rate-only reform gives no response to anyone whose gains above the basic rate band are under about £87k, however high their income. The fix is to fill from
max_(higher_rate_limit - max_(taxable_income, basic_rate_limit), 0), but I'd do that in its own PR, not here. Once it's fixed, the £65,642 incapital-gains-responses.md:149becomes about £55,200. I make that £47,280 plus £37,710 times 21%, but please check it rather than take my arithmetic. The "no response" statement still holds. - £200,000 earnings, £50,000 gains,
-
nit: the £5bn figure belongs to the paper, not the ready reckoner.
capital-gains-responses.md:105-108says the ready reckoner had a 10pp rise "raising over £5bn before behaviour". On p. 33 the ready reckoner gives the £2bn cost. The "in excess of £5bn" static figure is the authors' own remark ("Given the large static revenue..."). My round-1 wording ran the two together, so this one is partly mine. I'd write "HMRC's ready reckoner had a 10pp rise in the top rates losing £2bn a year by the third year, against a static gain the authors put at over £5bn".
What I checked
- Fresh worktree at
8502e5c6cwith its own uv venv. pyteston the touched test files (default response, CGT responses, CGT components, rule comparison, fiscal-year parameters, the marginal-rate hold, measurement branches, labour supply baseline, code-health cached arrays): 180 passed.ruff format --checkandruff check: clean. CI is green at this head.- Ran
infer_bumpon the branch'schangelog.d. - Re-ran every synthetic figure quoted in the docs, changelog and body: they all reproduce. Re-read pp. 4 and 33-37 of the paper.
- Probed the CGT band split as above. All households are synthetic.
- Not checked:
- the full-dataset runtime and the policyengine.py#574 gate results, as I have no dataset credentials here;
- #2277's own changes, which are reviewed separately.
🤖 Generated with Claude Code
… a fragment From Vahid's review of #2277: - dynamics.md says simulation.baseline is current law whatever policy a comparison treats as its baseline, so with elasticities on the reform side only, the reform's response also answers the baseline policy (a £30,000 earner in 2026: -£187 for a 22% basic rate, -£193 with a £13,000 personal allowance too, against the -£6 the allowance adds), and that setting the elasticities on both sides isolates the reform's own. - It also says only earnings and gains are held: other inputs a scenario changes keep each simulation's value and count as policy in the relative income change. - An added fragment for the new measurement_branches helpers, so the release is a minor one: anyone who sets the labour supply elasticities sees outputs move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ter the response From Vahid's review of #2277: since response_source gives a branch that shares its parent's rules the parent's labour supply response, the marginal rate tests' labour supply cases fail only with both that and the hold removed. A new test uses a branch with its own copy of the rules, emptied as the marginal rate variables empty theirs: it measures the response again unless the responses are held, and fails without the hold. The marginal rate tests' docstring says which mechanism each case covers. The zero-elasticity test now calculates the response before the terms, as the response formula does: policyengine-core 3.32, the floor, refuses a term requested before the response. It still fails without the shortcut. The measurement, hold, order and purge tests pass on core 3.32.27 as well as 3.33.2. On 3.32.27, disabling the purge fails the four order and recalculation tests and the direct purge test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #2396
Blocked by: #2277 (merged) and PolicyEngine/policyengine.py#620 (merged and in a policyengine.py release; see the merge order below). This PR stays a draft until both are done.
PR set: CGT retention-rate default (#2396, PolicyEngine/policyengine.py#602)
Merge order. Merging publishes a release straight away. policyengine-sim-api's deploy gate runs policyengine.py's Budget reform contract, which fails on this default without PolicyEngine/policyengine.py#620. So sim-api needs a policyengine.py release with #620 before it takes this release. Merging and releasing #620 first keeps that order. It also means policyengine.py's automatic qualification (PolicyEngine/policyengine.py#574, still a draft) starts from a contract that holds under this default.
Tracking checklist: checklist on PolicyEngine/policyengine.py#602
Stacked on #2277. The branch merges #2277 (
fix-lsr-baseline-2268, up to 03ec1e8), which the "stable across recalculation and order" criterion depends on, so the diff shows its commits until it merges. To review only this PR's commits, use the compare viewfix-lsr-baseline-2268...cgt-retention-default-2396. #2277 now holds every behavioural response in marginal rate branches (hold_behavioural_responses), and this PR uses that in place of its own gains-only hold.What
gov.simulation.capital_gains_responses.elasticitynow defaults to 1.0: the central value Advani, Lonsdale and Summers (2024, pp. 36–37) use for the medium-term effect of their reform package, with 0.5 and 2.0 as sensitivities.mtr_elasticitystays 0 andseparate_badr_elasticityfalse, so gains qualifying for Business Asset Disposal Relief take 1.0 too. A CGT reform with no elasticity set is no longer costed statically: for a marginal rate on gains rising from 24% to 45%, realised gains scale by 0.55 / 0.76 ≈ 0.7237.Flipping the YAML alone wasn't safe, so the PR also does the following.
measure_capital_gains_mtrsnow returns zero changes, measuring nothing, when the simulation applies its baseline's rules.same_rules(utils/rule_comparison.py) compares the two tax-benefit systems' parameter timelines and variable definitions, leaving out the behavioural-response settings, which only feed responses the measurement neutralises.apply_parameter_changes.marginal_tax_rateandmarginal_tax_rate_wrt_employer_costmeasured the response again, comparing the reform's rate on gains at the higher pay with the baseline's at the old pay. At a band edge the £1,000 step moved the gains into the higher rate band and counted that move as part of the reform.marginal_tax_ratecame out at 864%.hold_behavioural_responses, which holds the labour supply response too.mtr_elasticityset while the retention-rate settings keep their defaults now applies alone in its periods, with aCapitalGainsResponseWarning. The warning is given once per tax-benefit system and period.capital_gains_response_assumption(simulation, year)returns the convention, the elasticities for main-rate and relief gains, the MTR elasticity, the separate switch, and each one's source (default, override or auto-resolved). Itsto_dict()is JSON-serialisable. policyengine.py records it (Record the capital gains realisation response each UK run applies policyengine.py#621). The person-levelcapital_gains_elasticityandcapital_gains_badr_elasticityread the same resolution.docs/book/assumptions/capital-gains-responses.md. It covers:gov_balancefor reforms that move gains, and says household results count gains no longer realised as income lost.marginal_tax_rate_on_capital_gainsnow measures the rise incapital_gains_taxper pound of marginal gains, instead of the fall in household net income. Its branches recalculate only what the tax depends on, so a measurement costs about a sixth of what it did.capital_gains_tax, so the two definitions agree. A new test traces a net income calculation and fails if any other variable starts readingcapital_gains. Another checks the rate against a direct net-income difference for four household types.Release
This ships as a minor release:
2396.changed.mdfor the new default, and2396.added.mdforcapital_gains_response_assumptionandCapitalGainsResponseWarning. A downstream pin that allows minor releases picks it up, and with it new costings for every CGT reform, moves in income tax band and allowance reforms, and a warning formtr_elasticity-only calls. If such pins shouldn't pick it up silently, renaming the changed fragment to2396.breaking.mdmakes it a major release.What changes for users
These figures come from synthetic households, and the docs page and changelog state them.
Moving from the static default
elasticity,mtr_elasticity= 0 andseparate_badr_elasticity= false for the years you run ("2000-01-01.2100-12-31"covers all)elasticity= 0.5 or 2.0elasticity= 3.6,separate_badr_elasticity= true (badr_elasticitystays 1.4)mtr_elasticityandelasticity= 0 for the same periodsRuntime
Method. Calculation time is the median of three runs, each in a fresh process. The measurement count is the number of marginal-rate measurements on gains.
marginal_tax_rate, no reformmarginal_tax_rate, CGT reformReading the table:
The full dataset. On the enhanced FRS (2023-24, 1.40.3),
household_net_incomefor 2026. Base and this PR ran back to back, two runs each:The "default flip only" column and base's personal allowance case come from an earlier run on the same machine.
What the response costs.
capital_gains_taxrather than in household net income (point 6 above). That cut the measurement from about 13 seconds to about 2 seconds per reformed simulation and year on the full dataset.Isolation and stability
same_rulesreadsvalues_listdirectly and writes no cache.WeakKeyDictionarykeyed by tax-benefit system, andwarnings.filtersis never touched.Tests
test_capital_gains_response_default.py(37).reform=andscenario=,SimulationandMicrosimulation, and on a dataset.test_rule_comparison.py(8):test_capital_gains_responses.py, the residential MTR and equalisation tests intest_capital_gains_tax_components.py, andtest_fiscal_year_parameters.py's override. The code-health read-only-cache test gains a default-response case.uvx ruff format --check .anduvx ruff check .are clean. CI runs the full suite.pytest policyengine_uk/tests -m "not microsimulation" -n 4: 5,841 passed, 72 skipped, 1 xfailed (the strict xfail for Marriage Allowance is booked on the recipient as the transferor's unused allowance, not the fixed s.55B transfer #1947).policyengine-core test policyengine_uk/tests/policy: 3,412 passed.uvx ruff format --check .anduvx ruff check .: clean.pytest -m microsimulationwith dataset credentials: 30 passed, including the reform impact checks inreforms_config.yaml.Simulation,calculate_householdandmanaged_microsimulation;Reproducer: policyengine_uk/tests/test_capital_gains_response_default.py::test_pay_rise_branches_hold_the_realisation_response
axiom: n/a: behavioural-response modelling assumption under gov.simulation; no statutory provision changes
Follow-ups (not in this PR)
subsample()must subsample the baseline too, or reform measurement now fails with a clear population-size error.🤖 Generated with Claude Code