"""The Consumer Price Index the 2027 CGT rules index a cost base by (decisions.md #149). From 1 July 2027 money spent on a CGT asset is indexed by the index number for the quarter of the CGT event over the number for the quarter it was spent (ITAA 1997 s 960-275(1B)), worked to three decimal places, rounding up from 5 (s 960-275(5)), and not at all at a factor of 1 or less (s 960-270(2)). The index number is the All Groups CPI for the eight capitals, first published for the quarter (s 960-280(1)), so a quarter once stored is never overwritten by a revision; after the ABS changes its reference period only numbers on the new one count (s 960-280(3)), so the stored series is replaced. Nothing before the September 2027 quarter is ever needed: an asset held through 30 June 2027 is treated as bought again on 1 July 2027, and is indexed from there. So nothing is asked of the ABS until that quarter has finished. """ from __future__ import annotations import datetime as dt from decimal import ROUND_HALF_UP, Decimal from sqlalchemy import delete, select from sqlalchemy.orm import Session from . import clock, providers from .models import CpiQuarter # All Groups CPI (10001), original series (10), weighted average of the eight # capital cities (50), quarterly. The ABS data API needs no key. ABS_URL = "https://data.api.abs.gov.au/rest/data/ABS,CPI,2.0.0/1.10001.10.50.Q" # The first quarter any 2027 cost base is indexed from. FIRST = dt.date(2027, 7, 1) THOUSANDTHS = Decimal("0.001") def quarter(day: dt.date) -> dt.date: """The first day of the calendar quarter `day` falls in.""" return dt.date(day.year, (day.month - 1) // 3 * 3 + 1, 1) def name(start: dt.date) -> str: """"the June 2028 quarter", for a page.""" end = dt.date(start.year, start.month + 2, 1) return f"the {end.strftime('%B %Y')} quarter" def _label(start: dt.date) -> str: return f"{start.year}-Q{(start.month - 1) // 3 + 1}" def _start(label: str) -> dt.date: year, q = label.split("-Q") return dt.date(int(year), (int(q) - 1) * 3 + 1, 1) class CpiBook: """Every stored quarter, read once. `estimate` is for a sale today, in the simulator or the unrealised gains export: its quarter's number is months away, so the latest published stands in for any later quarter, and `estimated` says which it was. A figure for a tax return never estimates.""" def __init__(self, session: Session, estimate: bool = False): self._numbers = {row.quarter: row.index_number for row in session.scalars(select(CpiQuarter))} self._estimate = estimate self.estimated: dt.date | None = None def index(self, day: dt.date) -> Decimal | None: start = quarter(day) if start in self._numbers or not self._estimate: return self._numbers.get(start) latest = self.latest if latest is None or start < latest: return None self.estimated = latest return self._numbers[latest] @property def latest(self) -> dt.date | None: return max(self._numbers, default=None) def factor(self, spent: dt.date, event: dt.date) -> Decimal | None: """What money spent on `spent` is multiplied by for a CGT event on `event`: 1 when that would not raise it, None when a quarter's number is not published yet. In one quarter it is 1 whatever the number.""" if quarter(spent) == quarter(event): return Decimal(1) then, now = self.index(spent), self.index(event) if then is None or now is None: return None worked = (now / then).quantize(THOUSANDTHS, rounding=ROUND_HALF_UP) return worked if worked > 1 else Decimal(1) def _due(session: Session, today: dt.date) -> bool: """Whether a quarter could be published that is not stored yet: the newest finished quarter, once there is one the rules use.""" finished = quarter(quarter(today) - dt.timedelta(days=1)) if finished < FIRST: return False newest = session.scalar(select(CpiQuarter.quarter).order_by(CpiQuarter.quarter.desc())) return newest is None or newest < finished def refresh(session: Session, today: dt.date | None = None) -> int: """Ask the ABS for the quarters not stored yet, and store them; how many.""" if not _due(session, today or clock.today()): return 0 answer = providers._get_json(ABS_URL, {"format": "jsondata", "startPeriod": _label(FIRST)}) published, reference = _read(answer) stored = {row.quarter: row for row in session.scalars(select(CpiQuarter))} if any(row.reference_period != reference for row in stored.values()): session.execute(delete(CpiQuarter)) stored = {} added = 0 for start, number in sorted(published.items()): if start in stored or start < FIRST: continue session.add(CpiQuarter(quarter=start, index_number=number, reference_period=reference)) added += 1 session.flush() return added def _read(answer: dict) -> tuple[dict[dt.date, Decimal], str]: """The quarters and their numbers, and the reference period, out of the ABS's SDMX-JSON.""" data = answer["data"] structure = data["structures"][0] periods = [v["id"] for v in structure["dimensions"]["observation"][0]["values"]] dataset = data["dataSets"][0] base = next(a for a in structure["attributes"]["dataSet"] if a["id"] == "BASE_PERIOD") reference = base["values"][dataset["attributes"][0]]["name"] [series] = dataset["series"].values() numbers = {_start(periods[int(i)]): Decimal(str(values[0])) for i, values in series["observations"].items() if values[0] is not None} return numbers, reference