Repository Public Research preview
1"""Journal figure style for WP46 (matplotlib, vector PDF, Times/newtx-compatible serif).23Rules (docs/EXHIBITS_MANDATE.md, dataviz skill):4* categorical colours = the validated reference palette (fixed slot order, never cycled), assigned by model FAMILY;5 identity never rests on colour alone: every family also has a marker, a line style and a hatch;6* sequential = one hue (blue, light -> dark); diverging = blue / neutral grey / red with the ratio 1.0 at the midpoint7 and a flat grey band for the +-2 % materiality zone so that the band is visible in the colour bar itself;8* all text in ink colours (never the series colour); thin marks; recessive solid hairline grid; fonts >= 7 pt at print9 size (figures are built at their final width: W1 = 3.25 in column, W2 = 6.5 in text width);10* deterministic PDF metadata (no timestamps).11"""12from __future__ import annotations1314import matplotlib as mpl15import matplotlib.pyplot as plt16import numpy as np17from matplotlib.colors import LinearSegmentedColormap, to_rgb18from matplotlib.lines import Line2D19from matplotlib.patches import Patch2021# --- validated reference palette (scripts/validate_palette.js: adjacent pairs PASS on #ffffff; contrast WARN on slots 3/4/522# --- is relieved by direct text labels / tables, as every family mark sits beside an ink label)23SLOTS = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"]24FAMILY_ORDER = ["baseline", "classical", "asymmetric", "power", "fractional", "component", "regime-switching", "score-driven", "realized"]25FAMILY_COLOR = {"classical": SLOTS[0], "asymmetric": SLOTS[1], "power": SLOTS[2], "fractional": SLOTS[3], "component": SLOTS[4],26 "regime-switching": SLOTS[5], "score-driven": SLOTS[6], "realized": SLOTS[7], "baseline": "#7a7975", "multivariate": SLOTS[0]}27FAMILY_MARKER = {"classical": "o", "asymmetric": "s", "power": "^", "fractional": "D", "component": "v", "regime-switching": "P",28 "score-driven": "X", "realized": "*", "baseline": "p", "multivariate": "<"}29FAMILY_LS = {"classical": "-", "asymmetric": "--", "power": "-.", "fractional": ":", "component": (0, (5, 1, 1, 1)),30 "regime-switching": (0, (3, 1)), "score-driven": (0, (1, 1)), "realized": "-", "baseline": (0, (6, 2))}31FAMILY_HATCH = {"classical": "", "asymmetric": "////", "power": "....", "fractional": "\\\\\\\\", "component": "xx",32 "regime-switching": "++", "score-driven": "oo", "realized": "**", "baseline": "--"}33FAMILY_LABEL = {"classical": "Classical GARCH", "asymmetric": "Asymmetric", "power": "Power / absolute", "fractional": "Fractional",34 "component": "Component", "regime-switching": "Regime switching", "score-driven": "Score-driven", "realized": "Realized-measure",35 "baseline": "Baselines"}36FAMILY_SHORT = {"classical": "Classical", "asymmetric": "Asymmetric", "power": "Power", "fractional": "Fractional", "component": "Component",37 "regime-switching": "Regime", "score-driven": "Score", "realized": "Realized", "baseline": "Baselines"}38# asset classes: five adjacent blocks (adjacent-pair validation); each block also carries a text label, never colour alone39CLASS_COLOR = {"etf": SLOTS[0], "stock": SLOTS[1], "futures": SLOTS[2], "fx": SLOTS[3], "crypto": SLOTS[4]}40CLASS_LS = {"etf": "-", "stock": "--", "futures": "-.", "fx": ":", "crypto": (0, (5, 1, 1, 1))}4142INK, INK2, INK3 = "#0b0b0b", "#52514e", "#898781"43GRID, BAND, SURFACE = "#e1e0d9", "#f0efec", "#ffffff"44NEUTRAL = "#e9e8e3" # diverging midpoint / materiality band45SEQ = LinearSegmentedColormap.from_list("wp46_seq", ["#e6eef9", "#9ec5f4", "#3987e5", "#1c5cab", "#0d366b"])46W1, W2 = 3.25, 6.5 # column width / text width in inches47MIN_FONT = 7.0484950def apply() -> None:51 mpl.rcParams.update({52 "font.family": "serif", "font.serif": ["Times New Roman", "Times", "STIX Two Text", "DejaVu Serif"], "mathtext.fontset": "stix",53 "font.size": 8, "axes.labelsize": 8, "axes.titlesize": 8.5, "legend.fontsize": 7, "xtick.labelsize": 7, "ytick.labelsize": 7,54 "axes.edgecolor": INK3, "axes.linewidth": 0.6, "axes.spines.top": False, "axes.spines.right": False,55 "axes.grid": True, "grid.color": GRID, "grid.linewidth": 0.5, "grid.linestyle": "-", "axes.axisbelow": True,56 "xtick.color": INK2, "ytick.color": INK2, "xtick.labelcolor": INK, "ytick.labelcolor": INK, "text.color": INK, "axes.labelcolor": INK,57 "xtick.major.size": 2.5, "ytick.major.size": 2.5, "xtick.major.width": 0.5, "ytick.major.width": 0.5,58 "lines.linewidth": 1.1, "lines.markersize": 3.8, "lines.markeredgewidth": 0.6, "legend.frameon": False, "legend.handlelength": 1.8,59 "figure.dpi": 150, "savefig.dpi": 300, "pdf.fonttype": 42, "ps.fonttype": 42, "figure.facecolor": SURFACE, "axes.facecolor": SURFACE,60 "hatch.linewidth": 0.4, "hatch.color": SURFACE, "axes.titlelocation": "left", "axes.titleweight": "bold", "axes.titlepad": 4,61 })626364def save(fig, path, **kw) -> None:65 """Vector PDF with deterministic metadata (no timestamps); closes the figure."""66 fig.savefig(path, bbox_inches="tight", pad_inches=0.02,67 metadata={"CreationDate": None, "ModDate": None, "Creator": "wp46 build_paper.py", "Producer": "wp46 (matplotlib)"}, **kw)68 plt.close(fig)697071# ----------------------------------------------------------------------------- diverging map with a flat materiality band72def div_cmap(vmin: float = 0.8, vmax: float = 1.25, band: float = 0.02, n: int = 512) -> LinearSegmentedColormap:73 """Blue (ratio < 1, better than GARCH) / neutral / red (ratio > 1) on the log scale, with a FLAT neutral segment covering74 [1-band, 1+band] so that the pre-registered +-2 % materiality zone reads as 'no difference' both in the cells and in the bar."""75 lo, hi = np.log(vmin), np.log(vmax)76 blo, bhi = np.log(1 - band), np.log(1 + band)77 blue = LinearSegmentedColormap.from_list("b", ["#0d366b", "#1c5cab", "#3987e5", "#9ec5f4", NEUTRAL])78 red = LinearSegmentedColormap.from_list("r", [NEUTRAL, "#f2a9a8", "#e34948", "#b02a2a", "#6e1413"])79 xs = np.linspace(lo, hi, n)80 cols = []81 for x in xs:82 if x <= blo:83 cols.append(blue((x - lo) / (blo - lo)))84 elif x >= bhi:85 cols.append(red((x - bhi) / (hi - bhi)))86 else:87 cols.append(to_rgb(NEUTRAL) + (1.0,))88 cm = LinearSegmentedColormap.from_list("wp46_div", cols, N=n)89 cm.set_bad("#ffffff")90 return cm919293DIV = div_cmap()949596def ratio_colorbar(fig, im, ax, vmin=0.8, vmax=1.25, band=0.02, label="QLIKE ratio to GARCH(1,1)-N (log scale)", orientation="horizontal", **kw):97 """Colour bar for a log-ratio image: ticks at round ratios, the +-band zone outlined, text in ink."""98 cb = fig.colorbar(im, ax=None if "cax" in kw else ax, orientation=orientation, **kw)99 ticks = [t for t in (0.7, 0.8, 0.9, 1.0, 1.1, 1.25, 1.4) if vmin <= t <= vmax]100 cb.set_ticks(np.log(ticks)); cb.set_ticklabels([f"{t:.2f}" for t in ticks])101 cb.ax.tick_params(labelsize=MIN_FONT, length=2, width=0.5, color=INK2)102 for t in (np.log(1 - band), np.log(1 + band)):103 (cb.ax.axvline if orientation == "horizontal" else cb.ax.axhline)(t, color=INK, lw=0.6)104 cb.set_label(label + f"; grey band = ±{100 * band:.0f} % materiality zone", fontsize=MIN_FONT, color=INK)105 cb.outline.set_edgecolor(INK3); cb.outline.set_linewidth(0.5)106 return cb107108109# ----------------------------------------------------------------------------- shared annotations110def ref_band(ax, axis: str = "y", centre: float = 1.0, band: float = 0.02, log: bool = False) -> None:111 """Reference line at `centre` and the +-band materiality zone (shaded) on the given axis."""112 lo, hi = (centre * (1 - band), centre * (1 + band)) if not log else (np.log(1 - band), np.log(1 + band))113 c = centre if not log else 0.0114 if axis == "y":115 ax.axhspan(lo, hi, color=BAND, zorder=0, lw=0); ax.axhline(c, color=INK2, lw=0.6, zorder=1)116 else:117 ax.axvspan(lo, hi, color=BAND, zorder=0, lw=0); ax.axvline(c, color=INK2, lw=0.6, zorder=1)118119120def family_handles(families, with_hatch: bool = False, kind: str = "marker"):121 """Legend handles with the family's colour + marker (or hatch) so that the legend survives grayscale."""122 hs = []123 for f in families:124 if kind == "patch":125 hs.append(Patch(fc=FAMILY_COLOR.get(f, INK3), ec=SURFACE, hatch=FAMILY_HATCH.get(f, "") if with_hatch else "", label=FAMILY_LABEL.get(f, f)))126 else:127 hs.append(Line2D([], [], marker=FAMILY_MARKER.get(f, "o"), color=FAMILY_COLOR.get(f, INK3), ls=FAMILY_LS.get(f, "-") if kind == "line" else "none",128 ms=4.2, mew=0.6, mec=FAMILY_COLOR.get(f, INK3), label=FAMILY_LABEL.get(f, f)))129 return hs130131132def luminance(rgba) -> float:133 r, g, b = to_rgb(rgba)134 return 0.2126 * r + 0.7152 * g + 0.0722 * b135136137def spread_labels(ys: list[float], min_gap: float, lo: float | None = None, hi: float | None = None) -> list[float]:138 """Push label positions apart (1-D) so that consecutive labels are at least `min_gap` apart; order preserved."""139 order = np.argsort(ys); out = np.array(ys, dtype=float)140 for k in range(1, len(order)):141 i, j = order[k - 1], order[k]142 if out[j] - out[i] < min_gap:143 out[j] = out[i] + min_gap144 if hi is not None and len(out) and out[order[-1]] > hi:145 shift = out[order[-1]] - hi146 for k in range(len(order) - 1, -1, -1):147 i = order[k]148 out[i] -= shift149 if k and out[i] - out[order[k - 1]] >= min_gap:150 break151 if lo is not None:152 for k in range(len(order)):153 i = order[k]154 if out[i] < lo:155 out[i] = lo156 if k and out[i] - out[order[k - 1]] < min_gap:157 out[i] = out[order[k - 1]] + min_gap158 return out.tolist()159