| 1 |
|
−"""Journal figure style for WP46 (matplotlib, vector PDF). Categorical colours = the validated reference palette of the |
| 2 |
|
−dataviz method (fixed slot order, never cycled), assigned by model FAMILY (identity), with distinct markers/linestyles so |
| 3 |
|
−that identity never rests on colour alone (print, CVD). Sequential = single hue (blue) light->dark; diverging = blue/red |
| 4 |
|
−with a neutral grey midpoint. Text always in ink colours; thin marks; recessive grid; no truncated axes.""" |
|
1 |
+"""Journal figure style for WP46 (matplotlib, vector PDF, Times/newtx-compatible serif). |
|
2 |
+ |
|
3 |
+Rules (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 midpoint |
|
7 |
+ 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 print |
|
9 |
+ 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 |
+""" |
| 5 |
12 |
from __future__ import annotations |
| 6 |
13 |
|
| 7 |
14 |
import matplotlib as mpl |
| 8 |
15 |
import matplotlib.pyplot as plt |
| 9 |
|
−from matplotlib.colors import LinearSegmentedColormap |
|
16 |
+import numpy as np |
|
17 |
+from matplotlib.colors import LinearSegmentedColormap, to_rgb |
|
18 |
+from matplotlib.lines import Line2D |
|
19 |
+from matplotlib.patches import Patch |
| 10 |
20 |
|
|
21 |
+# --- validated reference palette (scripts/validate_palette.js: adjacent pairs PASS on #ffffff; contrast WARN on slots 3/4/5 |
|
22 |
+# --- is relieved by direct text labels / tables, as every family mark sits beside an ink label) |
| 11 |
23 |
SLOTS = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"] |
|
24 |
+FAMILY_ORDER = ["baseline", "classical", "asymmetric", "power", "fractional", "component", "regime-switching", "score-driven", "realized"] |
| 12 |
25 |
FAMILY_COLOR = {"classical": SLOTS[0], "asymmetric": SLOTS[1], "power": SLOTS[2], "fractional": SLOTS[3], "component": SLOTS[4], |
| 13 |
26 |
"regime-switching": SLOTS[5], "score-driven": SLOTS[6], "realized": SLOTS[7], "baseline": "#7a7975", "multivariate": SLOTS[0]} |
| 14 |
27 |
FAMILY_MARKER = {"classical": "o", "asymmetric": "s", "power": "^", "fractional": "D", "component": "v", "regime-switching": "P", |
| 15 |
|
− "score-driven": "X", "realized": "*", "baseline": "x"} |
| 16 |
|
−FAMILY_LABEL = {"classical": "Classical GARCH", "asymmetric": "Asymmetric", "power": "Power/absolute", "fractional": "Fractional", |
|
28 |
+ "score-driven": "X", "realized": "*", "baseline": "x", "multivariate": "<"} |
|
29 |
+FAMILY_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))} |
|
31 |
+FAMILY_HATCH = {"classical": "", "asymmetric": "////", "power": "....", "fractional": "\\\\\\\\", "component": "xx", |
|
32 |
+ "regime-switching": "++", "score-driven": "oo", "realized": "**", "baseline": "--"} |
|
33 |
+FAMILY_LABEL = {"classical": "Classical GARCH", "asymmetric": "Asymmetric", "power": "Power / absolute", "fractional": "Fractional", |
| 17 |
34 |
"component": "Component", "regime-switching": "Regime switching", "score-driven": "Score-driven", "realized": "Realized-measure", |
| 18 |
35 |
"baseline": "Baselines"} |
| 19 |
|
−INK, INK2, INK3 = "#0b0b0b", "#52514e", "#8a8984" |
| 20 |
|
−SURFACE = "#ffffff" |
| 21 |
|
−SEQ = LinearSegmentedColormap.from_list("wp46_seq", ["#e8f0fb", "#2a78d6", "#0d2f5c"]) |
| 22 |
|
−DIV = LinearSegmentedColormap.from_list("wp46_div", ["#0d3f8f", "#2a78d6", "#f2f2f0", "#e34948", "#7a1716"]) |
| 23 |
|
−W1, W2 = 3.25, 6.5 # column widths in inches |
|
36 |
+FAMILY_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 alone |
|
39 |
+CLASS_COLOR = {"etf": SLOTS[0], "stock": SLOTS[1], "futures": SLOTS[2], "fx": SLOTS[3], "crypto": SLOTS[4]} |
|
40 |
+CLASS_LS = {"etf": "-", "stock": "--", "futures": "-.", "fx": ":", "crypto": (0, (5, 1, 1, 1))} |
|
41 |
+ |
|
42 |
+INK, INK2, INK3 = "#0b0b0b", "#52514e", "#898781" |
|
43 |
+GRID, BAND, SURFACE = "#e1e0d9", "#f0efec", "#ffffff" |
|
44 |
+NEUTRAL = "#e9e8e3" # diverging midpoint / materiality band |
|
45 |
+SEQ = LinearSegmentedColormap.from_list("wp46_seq", ["#e6eef9", "#9ec5f4", "#3987e5", "#1c5cab", "#0d366b"]) |
|
46 |
+W1, W2 = 3.25, 6.5 # column width / text width in inches |
|
47 |
+MIN_FONT = 7.0 |
| 24 |
48 |
|
| 25 |
49 |
|
| 26 |
50 |
def apply() -> None: |
| 27 |
51 |
mpl.rcParams.update({ |
| 28 |
|
− "font.family": "serif", "font.serif": ["STIX Two Text", "Times New Roman", "Times", "DejaVu Serif"], "mathtext.fontset": "stix", |
| 29 |
|
− "font.size": 8.5, "axes.labelsize": 8.5, "axes.titlesize": 9, "legend.fontsize": 7.5, "xtick.labelsize": 7.5, "ytick.labelsize": 7.5, |
|
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, |
| 30 |
54 |
"axes.edgecolor": INK3, "axes.linewidth": 0.6, "axes.spines.top": False, "axes.spines.right": False, |
| 31 |
|
− "axes.grid": True, "grid.color": "#e3e2de", "grid.linewidth": 0.5, "axes.axisbelow": True, |
| 32 |
|
− "xtick.color": INK2, "ytick.color": INK2, "text.color": INK, "axes.labelcolor": INK, |
| 33 |
|
− "lines.linewidth": 1.2, "lines.markersize": 4, "legend.frameon": False, "figure.dpi": 150, "savefig.dpi": 300, |
| 34 |
|
− "pdf.fonttype": 42, "ps.fonttype": 42, "figure.facecolor": SURFACE, "axes.facecolor": SURFACE, |
|
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, |
| 35 |
61 |
}) |
| 36 |
62 |
|
| 37 |
63 |
|
| 38 |
64 |
def save(fig, path, **kw) -> None: |
| 39 |
|
− fig.savefig(path, bbox_inches="tight", metadata={"CreationDate": None, "Creator": "wp46"}, **kw) |
|
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) |
| 40 |
68 |
plt.close(fig) |
|
69 |
+ |
|
70 |
+ |
|
71 |
+# ----------------------------------------------------------------------------- diverging map with a flat materiality band |
|
72 |
+def 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 covering |
|
74 |
+ [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 cm |
|
91 |
+ |
|
92 |
+ |
|
93 |
+DIV = div_cmap() |
|
94 |
+ |
|
95 |
+ |
|
96 |
+def 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=ax, orientation=orientation, **kw) |
|
99 |
+ ticks = [t for t in (0.7, 0.8, 0.9, 1 - band, 1.0, 1 + band, 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 cb |
|
107 |
+ |
|
108 |
+ |
|
109 |
+# ----------------------------------------------------------------------------- shared annotations |
|
110 |
+def tag(ax, text: str = "exploratory", loc: str = "upper right") -> None: |
|
111 |
+ """Grey tag separating post-registration / exploratory exhibits from the confirmatory ones (mandate §5).""" |
|
112 |
+ x, ha = (0.995, "right") if "right" in loc else (0.005, "left") |
|
113 |
+ y, va = (1.0, "bottom") if "upper" in loc else (0.0, "top") |
|
114 |
+ ax.text(x, y + (0.01 if va == "bottom" else -0.01), text, transform=ax.transAxes, ha=ha, va=va, fontsize=MIN_FONT, color=INK2, |
|
115 |
+ bbox=dict(boxstyle="round,pad=0.25", fc=BAND, ec="none")) |
|
116 |
+ |
|
117 |
+ |
|
118 |
+def ref_band(ax, axis: str = "y", centre: float = 1.0, band: float = 0.02, log: bool = False) -> None: |
|
119 |
+ """Reference line at `centre` and the +-band materiality zone (shaded) on the given axis.""" |
|
120 |
+ lo, hi = (centre * (1 - band), centre * (1 + band)) if not log else (np.log(1 - band), np.log(1 + band)) |
|
121 |
+ c = centre if not log else 0.0 |
|
122 |
+ if axis == "y": |
|
123 |
+ ax.axhspan(lo, hi, color=BAND, zorder=0, lw=0); ax.axhline(c, color=INK2, lw=0.6, zorder=1) |
|
124 |
+ else: |
|
125 |
+ ax.axvspan(lo, hi, color=BAND, zorder=0, lw=0); ax.axvline(c, color=INK2, lw=0.6, zorder=1) |
|
126 |
+ |
|
127 |
+ |
|
128 |
+def family_handles(families, with_hatch: bool = False, kind: str = "marker"): |
|
129 |
+ """Legend handles with the family's colour + marker (or hatch) so that the legend survives grayscale.""" |
|
130 |
+ hs = [] |
|
131 |
+ for f in families: |
|
132 |
+ if kind == "patch": |
|
133 |
+ 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))) |
|
134 |
+ else: |
|
135 |
+ 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", |
|
136 |
+ ms=4.2, mew=0.6, mec=FAMILY_COLOR.get(f, INK3), label=FAMILY_LABEL.get(f, f))) |
|
137 |
+ return hs |
|
138 |
+ |
|
139 |
+ |
|
140 |
+def luminance(rgba) -> float: |
|
141 |
+ r, g, b = to_rgb(rgba) |
|
142 |
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b |
|
143 |
+ |
|
144 |
+ |
|
145 |
+def spread_labels(ys: list[float], min_gap: float, lo: float | None = None, hi: float | None = None) -> list[float]: |
|
146 |
+ """Push label positions apart (1-D) so that consecutive labels are at least `min_gap` apart; order preserved.""" |
|
147 |
+ order = np.argsort(ys); out = np.array(ys, dtype=float) |
|
148 |
+ for k in range(1, len(order)): |
|
149 |
+ i, j = order[k - 1], order[k] |
|
150 |
+ if out[j] - out[i] < min_gap: |
|
151 |
+ out[j] = out[i] + min_gap |
|
152 |
+ if hi is not None and len(out) and out[order[-1]] > hi: |
|
153 |
+ shift = out[order[-1]] - hi |
|
154 |
+ for k in range(len(order) - 1, -1, -1): |
|
155 |
+ i = order[k] |
|
156 |
+ out[i] -= shift |
|
157 |
+ if k and out[i] - out[order[k - 1]] >= min_gap: |
|
158 |
+ break |
|
159 |
+ if lo is not None: |
|
160 |
+ for k in range(len(order)): |
|
161 |
+ i = order[k] |
|
162 |
+ if out[i] < lo: |
|
163 |
+ out[i] = lo |
|
164 |
+ if k and out[i] - out[order[k - 1]] < min_gap: |
|
165 |
+ out[i] = out[order[k - 1]] + min_gap |
|
166 |
+ return out.tolist() |
| 41 |
167 |
|