{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell-000",
   "metadata": {},
   "source": [
    "# Lesson 4 (Part B) — Lab: Bonds & Mortgages as Objects\n",
    "\n",
    "**Companion notebook** for the Lesson 4B lab. Build `CouponBond` and `MortgageLoan`\n",
    "step by step. When they pass, move them into `src/foundations/instruments.py`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-001",
   "metadata": {},
   "outputs": [],
   "source": [
    "def check(label, got, expected, tol=1e-6):\n",
    "    \"\"\"Compare your answer with the expected value; prints ✅ or ❌ (never raises).\"\"\"\n",
    "    import math\n",
    "    try:\n",
    "        if isinstance(expected, float) or isinstance(got, float):\n",
    "            ok = math.isclose(float(got), float(expected), rel_tol=tol, abs_tol=tol)\n",
    "        elif isinstance(expected, (list, tuple)) and expected and isinstance(expected[0], str):\n",
    "            ok = list(got) == list(expected)\n",
    "        elif hasattr(expected, \"__len__\") and not isinstance(expected, str):\n",
    "            import numpy as np\n",
    "            ok = np.allclose(np.asarray(got, dtype=float), np.asarray(expected, dtype=float), rtol=tol, atol=tol)\n",
    "        else:\n",
    "            ok = got == expected\n",
    "    except Exception:\n",
    "        ok = False\n",
    "    print((\"✅ \" if ok else \"❌ \") + f\"{label}: got {got!r}, expected {expected!r}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-002",
   "metadata": {},
   "source": [
    "## Part 1 — `CouponBond`\n",
    "\n",
    "Complete the four methods marked **Your turn**. The properties are done for you."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-003",
   "metadata": {},
   "outputs": [],
   "source": [
    "class CouponBond:\n",
    "    \"\"\"A level-coupon bond redeemed at face value.\"\"\"\n",
    "\n",
    "    def __init__(self, face: float, coupon_rate: float, years: float, freq: int = 2):\n",
    "        if face <= 0 or coupon_rate < 0 or years <= 0 or freq not in (1, 2, 4, 12):\n",
    "            raise ValueError(\"invalid bond terms\")\n",
    "        self.face, self.coupon_rate, self.years, self.freq = face, coupon_rate, years, freq\n",
    "\n",
    "    def __repr__(self) -> str:\n",
    "        return (f\"CouponBond(face={self.face}, coupon_rate={self.coupon_rate}, \"\n",
    "                f\"years={self.years}, freq={self.freq})\")\n",
    "\n",
    "    @property\n",
    "    def n_periods(self) -> int:\n",
    "        return round(self.years * self.freq)\n",
    "\n",
    "    @property\n",
    "    def coupon(self) -> float:\n",
    "        return self.face * self.coupon_rate / self.freq\n",
    "\n",
    "    def cash_flows(self) -> list[float]:\n",
    "        \"\"\"Coupons each period, plus the face value at maturity.\"\"\"\n",
    "        ...  # your code here\n",
    "\n",
    "    def times(self) -> list[float]:\n",
    "        \"\"\"Payment times in years.\"\"\"\n",
    "        return [k / self.freq for k in range(1, self.n_periods + 1)]\n",
    "\n",
    "    def price(self, ytm: float) -> float:\n",
    "        \"\"\"Price at annual yield ytm, compounded freq times a year.\"\"\"\n",
    "        ...  # your code here\n",
    "\n",
    "    def ytm(self, price: float, lo: float = -0.99, hi: float = 1.0, tol: float = 1e-10,\n",
    "            max_iter: int = 200) -> float:\n",
    "        \"\"\"Yield-to-maturity by bisection.\"\"\"\n",
    "        ...  # your code here\n",
    "\n",
    "    def macaulay_duration(self, ytm: float) -> float:\n",
    "        \"\"\"PV-weighted average time to the cash flows, in years.\"\"\"\n",
    "        ...  # your code here\n",
    "\n",
    "    def modified_duration(self, ytm: float) -> float:\n",
    "        return self.macaulay_duration(ytm) / (1 + ytm / self.freq)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-004",
   "metadata": {},
   "outputs": [],
   "source": [
    "b = CouponBond(1000, 0.06, 10)\n",
    "print(b, b.n_periods, b.coupon)\n",
    "\n",
    "\n",
    "def attempt(fn, *args):\n",
    "    \"\"\"Run fn, returning None instead of raising (so unfinished methods don't stop the notebook).\"\"\"\n",
    "    try:\n",
    "        return fn(*args)\n",
    "    except Exception:\n",
    "        return None\n",
    "\n",
    "\n",
    "check(\"last cash flow\", (attempt(b.cash_flows) or [None])[-1], 1030.0)\n",
    "check(\"price at 5%\", attempt(b.price, 0.05), 1077.9458, tol=1e-6)\n",
    "check(\"price at 6% (par)\", attempt(b.price, 0.06), 1000.0)\n",
    "check(\"price at 7%\", attempt(b.price, 0.07), 928.938, tol=1e-6)\n",
    "check(\"yield at price 950\", attempt(b.ytm, 950.0), 0.066939, tol=1e-5)\n",
    "check(\"Macaulay duration at 5%\", attempt(b.macaulay_duration, 0.05), 7.7618, tol=1e-4)\n",
    "check(\"zero-coupon duration = term\", attempt(CouponBond(1000, 0.0, 5, freq=1).macaulay_duration, 0.05), 5.0)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-005",
   "metadata": {},
   "source": [
    "**Convexity.** Duration predicts a +1% yield move changes the price by about\n",
    "−modified duration × 1%. Compare that prediction with the real price at 6%."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-006",
   "metadata": {},
   "outputs": [],
   "source": [
    "p5, md = attempt(b.price, 0.05), attempt(b.modified_duration, 0.05)\n",
    "if p5 is not None and md is not None:\n",
    "    print(f\"predicted {p5 * (1 - md * 0.01):,.2f}  vs actual {b.price(0.06):,.2f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-007",
   "metadata": {},
   "source": [
    "## Part 2 — `MortgageLoan`\n",
    "\n",
    "300,000 at 6.5% nominal annual, paid monthly for 30 years. Complete `payment` and\n",
    "`schedule`. Give the final month's principal as the remaining balance so the loan ends\n",
    "at exactly zero."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-008",
   "metadata": {},
   "outputs": [],
   "source": [
    "class MortgageLoan:\n",
    "    \"\"\"A level-payment, monthly amortizing loan.\"\"\"\n",
    "\n",
    "    def __init__(self, principal: float, annual_rate: float, years: int):\n",
    "        self.principal, self.annual_rate, self.years = principal, annual_rate, years\n",
    "\n",
    "    @property\n",
    "    def n_payments(self) -> int:\n",
    "        return self.years * 12\n",
    "\n",
    "    @property\n",
    "    def monthly_rate(self) -> float:\n",
    "        return self.annual_rate / 12\n",
    "\n",
    "    @property\n",
    "    def payment(self) -> float:\n",
    "        ...  # your code here\n",
    "\n",
    "    def __len__(self) -> int:\n",
    "        return self.n_payments\n",
    "\n",
    "    def schedule(self) -> list[dict[str, float]]:\n",
    "        \"\"\"One dict per month: month, payment, interest, principal, balance.\"\"\"\n",
    "        rows = []\n",
    "        ...  # your code here\n",
    "        return rows"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-009",
   "metadata": {},
   "outputs": [],
   "source": [
    "m = MortgageLoan(300_000, 0.065, 30)\n",
    "sched = attempt(m.schedule) or []\n",
    "check(\"number of payments\", len(m), 360)\n",
    "check(\"monthly payment\", round(m.payment, 2) if m.payment is not None else None, 1896.2)\n",
    "check(\"month 1 interest\", sched[0][\"interest\"] if sched else None, 1625.00, tol=1e-9)\n",
    "check(\"balance after month 60\", round(sched[59][\"balance\"], 2) if len(sched) > 59 else None, 280832.93)\n",
    "check(\"final balance\", round(sched[-1][\"balance\"], 2) if sched else None, 0.0)\n",
    "check(\"total interest\", round(sum(r[\"interest\"] for r in sched), 2) if sched else None, 382633.47)\n",
    "check(\"first month principal > interest\",\n",
    "      next((r[\"month\"] for r in sched if r[\"principal\"] > r[\"interest\"]), None), 233)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
