{"spec_id":"heatmap-loss-triangle","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nheatmap-loss-triangle: Actuarial Loss Development Triangle\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 93/100 | Updated: 2026-06-03\n\"\"\"\n\n# Remove this script's directory from sys.path so 'import bokeh' finds the installed package,\n# not this file (bokeh.py shadows the bokeh package when run from its own directory).\nimport os as _os\nimport sys as _sys\n\n\n_here = _os.path.dirname(_os.path.abspath(__file__))\n_sys.path = [p for p in _sys.path if _os.path.abspath(p) != _here]\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import (\n    BasicTicker,\n    ColorBar,\n    ColumnDataSource,\n    HoverTool,\n    Legend,\n    LegendItem,\n    LinearColorMapper,\n    Title,\n)\nfrom bokeh.plotting import figure\nfrom bokeh.transform import transform\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint sequential colormap: brand green (#009E73) → blue (#4467A3)\n_seq_r = np.linspace(0, 68, 256).astype(int)\n_seq_g = np.linspace(158, 103, 256).astype(int)\n_seq_b = np.linspace(115, 163, 256).astype(int)\nIMPRINT_SEQ = [f\"#{r:02X}{g:02X}{b:02X}\" for r, g, b in zip(_seq_r, _seq_g, _seq_b, strict=True)]\n\n# Data: cumulative paid claims triangle (10 accident years × 10 development periods)\nnp.random.seed(42)\n\naccident_years = list(range(2015, 2025))\ndev_periods = list(range(1, 11))\n\ninitial_claims = np.array([4200, 4500, 4800, 5100, 5400, 5700, 6000, 6300, 6600, 7000], dtype=float)\ninitial_claims += np.random.normal(0, 200, 10)\ndev_factors = np.array([2.50, 1.45, 1.22, 1.12, 1.07, 1.04, 1.025, 1.015, 1.008])\n\nfull_triangle = np.zeros((10, 10))\nfor i in range(10):\n    full_triangle[i, 0] = initial_claims[i]\n    for j in range(1, 10):\n        full_triangle[i, j] = full_triangle[i, j - 1] * dev_factors[j - 1] * np.random.normal(1.0, 0.02)\n\n# Actual: upper-left triangle where row + col < 10; remainder is projected (IBNR)\nis_actual = np.array([[i + j < 10 for j in range(10)] for i in range(10)])\n\nmin_val = full_triangle.min()\nmax_val = full_triangle.max()\n\nactual_x, actual_y, actual_val = [], [], []\nproj_x, proj_y, proj_val = [], [], []\nall_x, all_y, all_text, all_tc, all_status, all_val_list = [], [], [], [], [], []\n\nfor i in range(10):\n    for j in range(10):\n        x = str(dev_periods[j])\n        y = str(accident_years[i])\n        val = full_triangle[i, j]\n        norm = (val - min_val) / (max_val - min_val)\n        # Text must contrast against cell fill (Imprint seq: green→blue), not the page bg\n        tc = \"#F0EFE8\" if norm > 0.45 else \"#1A1A17\"\n        all_x.append(x)\n        all_y.append(y)\n        all_text.append(f\"{val:,.0f}\")\n        all_tc.append(tc)\n        all_status.append(\"Actual\" if is_actual[i, j] else \"Projected (IBNR)\")\n        all_val_list.append(val)\n        if is_actual[i, j]:\n            actual_x.append(x)\n            actual_y.append(y)\n            actual_val.append(val)\n        else:\n            proj_x.append(x)\n            proj_y.append(y)\n            proj_val.append(val)\n\nsource_actual = ColumnDataSource(data={\"x\": actual_x, \"y\": actual_y, \"value\": actual_val})\nsource_proj = ColumnDataSource(data={\"x\": proj_x, \"y\": proj_y, \"value\": proj_val})\nsource_text = ColumnDataSource(\n    data={\"x\": all_x, \"y\": all_y, \"text\": all_text, \"text_color\": all_tc, \"status\": all_status, \"value\": all_val_list}\n)\n\nmapper = LinearColorMapper(palette=IMPRINT_SEQ, low=min_val, high=max_val)\n\n# Title length-adjusted font size (floor 34pt per bokeh library rules)\ntitle_str = \"Actuarial Loss Development Triangle · heatmap-loss-triangle · python · bokeh · anyplot.ai\"\n_n = len(title_str)\ntitle_pt = max(34, round(50 * 67 / _n)) if _n > 67 else 50\n\ndev_labels = [str(d) for d in dev_periods]\nyear_labels = [str(y) for y in accident_years]\n\n# Plot — square canvas (2400×2400) for symmetric grid; x-axis at top per actuarial convention\np = figure(\n    width=2400,\n    height=2400,\n    x_range=dev_labels,\n    y_range=list(reversed(year_labels)),\n    title=title_str,\n    x_axis_location=\"above\",\n    toolbar_location=None,\n    min_border_bottom=160,\n    min_border_left=200,\n    min_border_top=260,\n    min_border_right=130,\n)\n\n# Actual cells: solid fill, page-bg border for clean separation\nr_actual = p.rect(\n    x=\"x\",\n    y=\"y\",\n    width=1,\n    height=1,\n    source=source_actual,\n    fill_color=transform(\"value\", mapper),\n    fill_alpha=1.0,\n    line_color=PAGE_BG,\n    line_width=3,\n)\n\n# Projected cells: reduced opacity + dashed border to flag IBNR estimates\nr_proj = p.rect(\n    x=\"x\",\n    y=\"y\",\n    width=1,\n    height=1,\n    source=source_proj,\n    fill_color=transform(\"value\", mapper),\n    fill_alpha=0.55,\n    line_color=INK_SOFT,\n    line_width=2,\n    line_dash=\"dashed\",\n)\n\n# Cell annotations — 22pt for readability across 100 cells at full resolution\np.text(\n    x=\"x\",\n    y=\"y\",\n    text=\"text\",\n    source=source_text,\n    text_align=\"center\",\n    text_baseline=\"middle\",\n    text_font_size=\"22pt\",\n    text_color=\"text_color\",\n)\n\nhover = HoverTool(\n    renderers=[r_actual, r_proj],\n    tooltips=[(\"Accident Year\", \"@y\"), (\"Dev Period\", \"@x\"), (\"Status\", \"@status\"), (\"Cumulative\", \"$@value{0,0}\")],\n)\np.add_tools(hover)\n\n# Theme-adaptive chrome\np.title.text_font_size = f\"{title_pt}pt\"\np.title.text_color = INK\np.title.align = \"center\"\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = INK_SOFT\n\np.xaxis.axis_label = \"Development Period (Years)\"\np.yaxis.axis_label = \"Accident Year\"\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\np.axis.axis_line_color = None\np.axis.major_tick_line_color = None\np.grid.grid_line_color = None\n\n# Legend (below figure, horizontal)\nlegend = Legend(\n    items=[\n        LegendItem(label=\"Actual (Observed)\", renderers=[r_actual]),\n        LegendItem(label=\"Projected (IBNR Estimate)\", renderers=[r_proj]),\n    ],\n    orientation=\"horizontal\",\n    label_text_font_size=\"34pt\",\n    label_text_color=INK_SOFT,\n    glyph_width=50,\n    glyph_height=40,\n    border_line_color=None,\n    background_fill_color=ELEVATED_BG,\n    background_fill_alpha=0.0,\n    spacing=40,\n)\np.add_layout(legend, \"below\")\n\n# Development factors subtitle\n_factor_str = \"  \".join([f\"F{j + 1}→{j + 2}: {dev_factors[j]:.3f}\" for j in range(9)])\np.add_layout(\n    Title(\n        text=f\"Age-to-Age Development Factors:  {_factor_str}\",\n        text_font_size=\"24pt\",\n        text_color=INK_MUTED,\n        align=\"center\",\n    ),\n    \"below\",\n)\n\n# Color bar (Imprint seq scale)\ncolor_bar = ColorBar(\n    color_mapper=mapper,\n    ticker=BasicTicker(desired_num_ticks=8),\n    label_standoff=14,\n    major_label_text_font_size=\"28pt\",\n    major_label_text_color=INK_SOFT,\n    title=\"Cumulative Claims ($K)\",\n    title_text_font_size=\"30pt\",\n    title_text_color=INK,\n    width=50,\n    location=(0, 0),\n)\np.add_layout(color_bar, \"right\")\n\n# Save interactive HTML — use absolute path so the file lands in the script's directory\n_impl_dir = Path(_os.path.dirname(_os.path.abspath(__file__)))\n_html_path = _impl_dir / f\"plot-{THEME}.html\"\n_png_path = _impl_dir / f\"plot-{THEME}.png\"\n\noutput_file(str(_html_path))\nsave(p)\n\n# Screenshot with headless Chrome — force exact viewport via CDP to avoid\n# set_window_size() overhead (outer vs inner window discrepancy in headless)\nW, H = 2400, 2400\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{_html_path.resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(str(_png_path))\ndriver.quit()\n"}