"""Reproduce PSIM methods figures from public formulas and invented inputs. Run: python figures_v1.py --output-dir figures Requires Python 3.10+, NumPy, SciPy and Matplotlib. All model inputs are declared below; no market data, private settings or network resources are used. Writes desktop/mobile SVG and PNG figures plus data and check JSON files into the requested output directory. The SVG figures contain paths and presentation attributes, with no active code. """ import argparse import json import math from pathlib import Path import platform import textwrap import xml.etree.ElementTree as ET import numpy as np import scipy from scipy.stats import norm, t import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt from matplotlib.patches import Rectangle, FancyArrowPatch from matplotlib.ticker import PercentFormatter, FuncFormatter PALETTE = {'ink':'#172b3a','muted':'#526570','grid':'#dde4e9','white':'#ffffff', 'blue':'#2f6f98','blue_dark':'#193a55','blue_light':'#d9e8f2', 'gold':'#a87420','gold_light':'#f1e4c5','grey':'#8193a0'} plt.rcParams.update({'font.family':'DejaVu Sans','font.size':11,'axes.labelcolor':PALETTE['ink'], 'text.color':PALETTE['ink'],'xtick.color':PALETTE['muted'],'ytick.color':PALETTE['muted'], 'axes.edgecolor':PALETTE['grid'],'axes.spines.top':False,'axes.spines.right':False, 'axes.linewidth':.8,'lines.linewidth':1.9,'svg.fonttype':'path','svg.hashsalt':'psim-methods-figures-v1', 'savefig.facecolor':'white','figure.facecolor':'white','axes.facecolor':'white', 'axes.unicode_minus':True,'path.simplify':False}) CONTRACTS = [ {'id':'holm','paper':'RN-01','question':'How does the Holm adjustment change four supplied p-values?', 'takeaway':'A and B have an adjusted p-value at or below 0.05; this is arithmetic on assumed inputs.', 'family':'comparison','variant':'paired horizontal bars','rows':4,'grain':'invented test', 'palette':'single blue root; open raw bars and filled adjusted bars; neutral threshold', 'data':'raw p-values, adjusted p-values, family size and threshold'}, {'id':'costs','paper':'RN-02','question':'Does the reduction in order count equal the reduction in notional and direct cost?', 'takeaway':'B has 20% of the orders but 60% of the notional and proportional charge of A.', 'family':'comparison','variant':'indexed horizontal bars with baseline','rows':3, 'grain':'metric for two invented lists','palette':'single blue root; labelled neutral 100% reference', 'data':'orders, capital, per-order notional, total traded notional, charge and ratios', 'sufficiency':'The three defined accounting quantities exhaust this example; adding categories or market data would change the question.'}, {'id':'drawdown','paper':'RN-03','question':'What happens to an unrecovered peak at a fold boundary?', 'takeaway':'The linked path retains the earlier peak while local drawdown resets.', 'family':'ordered path','variant':'two aligned line panels with observed marks','rows':6, 'grain':'ordered invented account mark, not an interpolated market path', 'palette':'two roots; markers and distinct line styles separate series', 'data':'local wealth, linked wealth, local peak, linked peak and both drawdown measures', 'sufficiency':'All six marks in the published counterexample are retained; extra marks would invent an unobserved path.'}, {'id':'gbm','paper':'RN-04','question':'How do GBM paths relate to the mean, median and marginal quantiles?', 'takeaway':'Positive paths vary around pointwise distributions whose mean and median differ.', 'family':'uncertainty','variant':'quantile band with fixed seeded paths','rows':253, 'grain':'equally spaced model time; four paths','palette':'blue marginal band and median; gold dashed mean; grey paths', 'data':'time, lower/upper quantiles, median, mean, path values and seed', 'limitation':'The 90% band is pointwise, not a simultaneous path-coverage statement.'}, {'id':'factor','paper':'RN-04','question':'How does a factor representation avoid storing the dense covariance?', 'takeaway':'A plus the residual diagonal needs 4.08 MB at N=10,000 and K=50; workspaces are additional.', 'family':'matrix','variant':'dimension-labelled schematic','grain':'matrix representation', 'palette':'blue shared-factor matrices; gold residual diagonal; direct shape labels', 'data':'N, K, dimensions, array-entry and float64 byte counts', 'limitation':'Schematic blocks are not to scale and do not show fitted correlations.'}, {'id':'tails','paper':'RN-05','question':'Can equal variance conceal differences in the loss tail?', 'takeaway':'The variance-one t5 law has heavier upper tails than the Gaussian law.', 'family':'distribution','variant':'density and log-survival small multiples','rows':801, 'grain':'loss coordinate and model family','palette':'blue solid Gaussian and gold dashed t5', 'data':'loss coordinate, both densities and exceedance probabilities, VaR and ES', 'limitation':'Known synthetic laws in arbitrary loss units; not fitted equity-return distributions.'}, ] def build_data(): checks = {} raw = np.array([.012,.014,.080,.400]) holm = np.minimum(1,np.maximum.accumulate(np.arange(4,0,-1)*raw)) checks['holm_adjustments'] = bool(np.allclose(holm,[.048,.048,.16,.4])) checks['holm_two_rejections'] = int(np.count_nonzero(holm <= .05)) == 2 costs = {'capital':100000,'basis_points_per_side':5, 'A':{'orders':100,'notional_per_order':1000},'B':{'orders':20,'notional_per_order':3000}} for key in ('A','B'): row = costs[key] row['gross_traded_notional'] = row['orders']*row['notional_per_order'] row['direct_cost'] = row['gross_traded_notional']*costs['basis_points_per_side']/10000 ratios = [costs['B'][key]/costs['A'][key] for key in ('orders','gross_traded_notional','direct_cost')] checks['cost_ratios'] = bool(np.allclose(ratios,[.2,.6,.6])) checks['direct_charges'] = costs['A']['direct_cost'] == 50 and costs['B']['direct_cost'] == 30 folds = np.array([[1,1.10,1.04],[1,.98,.95]]) local = folds.reshape(-1) linked = np.r_[folds[0],folds[0,-1]*folds[1]] local_peak = np.maximum.accumulate(folds,axis=1).reshape(-1) linked_peak = np.maximum.accumulate(linked) local_dd,linked_dd = 1-local/local_peak,1-linked/linked_peak checks['drawdown_worst_local'] = math.isclose(float(local_dd.max()),3/55,abs_tol=1e-12) checks['drawdown_linked'] = math.isclose(float(linked_dd.max()),28/275,abs_tol=1e-12) checks['drawdown_boundary_peak_retained'] = linked_peak[3] == linked_peak[2] and local_dd[3] == 0 s0,mu,sigma,horizon,steps,seed = 100.,.06,.20,1.,252,20260909 times = np.linspace(0,horizon,steps+1) median = s0*np.exp((mu-.5*sigma*sigma)*times) mean = s0*np.exp(mu*times) low = median*np.exp(norm.ppf(.05)*sigma*np.sqrt(times)) high = median*np.exp(norm.ppf(.95)*sigma*np.sqrt(times)) rng = np.random.Generator(np.random.PCG64(seed)) increments = (mu-.5*sigma*sigma)*horizon/steps + sigma*math.sqrt(horizon/steps)*rng.standard_normal((steps,4)) paths = s0*np.exp(np.vstack([np.zeros(4),np.cumsum(increments,axis=0)])) checks['gbm_paths_positive'] = bool(np.isfinite(paths).all() and (paths > 0).all()) checks['gbm_initial_values'] = bool(np.all(paths[0] == s0)) checks['gbm_terminal_quantiles'] = bool(np.allclose([low[-1],high[-1]],[74.9034,144.6245],atol=.00005,rtol=0)) checks['gbm_band_mass'] = math.isclose(float(norm.cdf(norm.ppf(.95))-norm.cdf(norm.ppf(.05))),.90,abs_tol=1e-12) checks['gbm_mean_and_median'] = bool(np.allclose([mean[-1],median[-1]],[106.1837,104.0811],atol=.00005,rtol=0)) n,k = 10000,50 factor = {'assets':n,'factors':k,'dense_entries':n*n,'symmetric_entries':n*(n+1)//2, 'dense_float64_bytes':8*n*n,'factor_and_diagonal_bytes':8*(n*k+n)} checks['factor_storage'] = factor['dense_float64_bytes'] == 800000000 and factor['factor_and_diagonal_bytes'] == 4080000 alpha,nu = .99,5 scale = math.sqrt((nu-2)/nu) loss = np.linspace(-4.5,4.5,801) nvar = float(norm.ppf(alpha)); tq = float(t.ppf(alpha,nu)); tvar = scale*tq nes = float(norm.pdf(nvar)/(1-alpha)) tes = float(scale*(nu+tq*tq)/(nu-1)*t.pdf(tq,nu)/(1-alpha)) checks['tail_variance_matching'] = math.isclose(float(t.var(nu))*scale*scale,1,abs_tol=1e-12) checks['tail_var_probabilities'] = abs(float(norm.sf(nvar))-.01) < 1e-12 and abs(float(t.sf(tvar/scale,nu))-.01) < 1e-10 checks['tail_es_values'] = bool(np.allclose([nes,tes],[2.665214,3.448837],atol=.000001,rtol=0)) checks['tail_curves_monotone'] = bool((np.diff(norm.sf(loss)) < 0).all() and (np.diff(t.sf(loss/scale,nu)) < 0).all()) # An additional public Euler-allocation example: equal weights, diagonal covariance. w = np.ones(3)/3 covariance = np.diag(np.array([.1,.2,.3])**2) volatility = float(np.sqrt(w@covariance@w)) contributions = w*(covariance@w)/volatility shares = contributions/volatility h = 1e-6 differences = [] for i in range(3): bump = np.eye(3)[i]*h differences.append((np.sqrt((w+bump)@covariance@(w+bump))-np.sqrt((w-bump)@covariance@(w-bump)))/(2*h)) checks['risk_contributions_sum_to_volatility'] = math.isclose(float(contributions.sum()),volatility,abs_tol=1e-12) checks['risk_contributions_match_derivatives'] = bool(np.allclose(contributions,w*np.array(differences),atol=1e-10,rtol=1e-9)) checks['risk_shares_equal_1_4_9'] = bool(np.allclose(shares,np.array([1,4,9])/14)) checks['risk_shares_sum_to_one'] = math.isclose(float(shares.sum()),1,abs_tol=1e-12) if not all(checks.values()): raise ArithmeticError('Figure checks failed') return {'classification':'constructed_public_mathematics','runtime':{'python':platform.python_version(), 'numpy':np.__version__,'scipy':scipy.__version__,'matplotlib':matplotlib.__version__}, 'scope':'Invented constants and seeded draws. No market observations, private strategy inputs or investment-performance evidence.', 'holm':{'labels':list('ABCD'),'raw':raw.tolist(),'adjusted':holm.tolist(),'family_size':4,'threshold':.05}, 'costs':{**costs,'B_relative_to_A':ratios,'turnover':'sum of absolute buy and sell notionals; no halving'}, 'drawdown':{'labels':['A start','A observation 1','A observation 2','B start','B observation 1','B observation 2'],'local_wealth':local.tolist(), 'linked_wealth':linked.tolist(),'local_peak':local_peak.tolist(),'linked_peak':linked_peak.tolist(), 'local_drawdown':local_dd.tolist(),'linked_drawdown':linked_dd.tolist()}, 'gbm':{'initial_price':s0,'physical_drift':mu,'volatility':sigma,'horizon_years':horizon,'steps':steps, 'seed':seed,'path_count':4,'time_years':times.tolist(),'mean':mean.tolist(),'median':median.tolist(), 'fifth_percentile':low.tolist(),'ninety_fifth_percentile':high.tolist(),'paths':paths.tolist()}, 'factor':factor,'tails':{'alpha':alpha,'degrees_of_freedom':nu,'loss':loss.tolist(), 'normal_density':norm.pdf(loss).tolist(),'student_density':(t.pdf(loss/scale,nu)/scale).tolist(), 'normal_survival':norm.sf(loss).tolist(),'student_survival':t.sf(loss/scale,nu).tolist(), 'normal_var':nvar,'student_var':tvar,'normal_es':nes,'student_es':tes}, 'risk_contributions':{'weights':w.tolist(),'covariance':covariance.tolist(),'portfolio_volatility':volatility, 'component_volatilities':contributions.tolist(),'shares':shares.tolist(), 'convention':'Annual model volatility; partial derivatives hold other weights fixed, not a funded rebalance.'}, 'checks':{key:bool(value) for key,value in checks.items()}} def figure(paper,title,subtitle,mobile,height): fig = plt.figure(figsize=(4.4 if mobile else 8.8,height),layout=None) fig.text(.94,.975,'PSIM / '+paper,ha='right',va='top',fontsize=8.5,color=PALETTE['muted']) fig.text(.075,.91 if mobile else .93,textwrap.fill(title,29 if mobile else 70),va='top', fontsize=16 if mobile else 19,fontweight='semibold',linespacing=1.15) fig.text(.075,.795 if mobile else .845,textwrap.fill(subtitle,46 if mobile else 105),va='top', fontsize=10 if mobile else 10.5,color=PALETTE['muted'],linespacing=1.5) return fig def tidy(ax,axis='x'): ax.grid(axis=axis,color=PALETTE['grid'],linewidth=.65,zorder=0) ax.set_axisbelow(True) ax.tick_params(length=0,pad=7) def holm_plot(data,mobile): fig = figure('RN-01','P-values before and after Holm','Four invented tests; adjusted p-value threshold = 0.05.',mobile,5.6 if mobile else 4.7) ax = fig.add_axes([.16,.15,.76,.49 if mobile else .56]) y = np.arange(4) for values,shift,label,color in ((data['holm']['raw'],-.17,'Raw',PALETTE['white']), (data['holm']['adjusted'],.17,'Holm-adjusted',PALETTE['blue'])): ax.barh(y+shift,values,height=.26,label=label,color=color,edgecolor=PALETTE['blue'],linewidth=1.1,zorder=3) for yy,value in zip(y+shift,values): ax.text(value+.008,yy,f'{value:.3f}',va='center',fontsize=10, bbox={'facecolor':'white','edgecolor':'none','pad':.2},zorder=5) ax.axvline(.05,color=PALETTE['ink'],ls=(0,(3,3)),lw=1) ax.set_yticks(y,labels=list('ABCD'));ax.invert_yaxis();ax.set_xlim(0,.47) ax.set_xlabel('p-value');ax.set_ylabel('Invented test',labelpad=12) ax.set_xticks([0,.1,.2,.3,.4]);tidy(ax) ax.legend(loc='upper left',bbox_to_anchor=(0,1.14 if mobile else 1.16),ncol=2,frameon=False,fontsize=9.5) return fig def costs_plot(data,mobile): fig = figure('RN-02','Order counts and execution charges','List B relative to list A; a fixed charge of 5 bp per side.',mobile,5.6 if mobile else 4.8) ax = fig.add_axes([.37 if mobile else .31,.16,.56 if mobile else .62,.50 if mobile else .54]) labels = ['Orders\n100 → 20','Traded notional\n100,000 → 60,000','Direct cost\n50 → 30'] values = data['costs']['B_relative_to_A'] ax.barh(np.arange(3),values,height=.42,color=PALETTE['blue'],edgecolor=PALETTE['blue_dark'],lw=.8,zorder=3) for y,value in enumerate(values): ax.text(value+.025,y,f'{value:.0%}',va='center',fontsize=12,fontweight='semibold') ax.axvline(1,color=PALETTE['ink'],ls=(0,(3,3)),lw=1) ax.text(1,.99,'A = 100%',ha='right',va='bottom',transform=ax.get_xaxis_transform(),fontsize=9) ax.set_yticks(range(3),labels=labels);ax.invert_yaxis();ax.set_xlim(0,1.13) ax.set_xticks([0,.5,1] if mobile else [0,.25,.5,.75,1]);ax.xaxis.set_major_formatter(PercentFormatter(1)) ax.set_xlabel('Share of list A');tidy(ax) fig.text(.075,.035,'Notional and cost are in invented currency units.',fontsize=9,color=PALETTE['muted']) return fig def drawdown_plot(data,mobile): fig = figure('RN-03','Drawdown across two folds','Six invented account marks; linked and reset reference peaks.',mobile,7.4 if mobile else 6.2) left,width = (.17,.75) if mobile else (.13,.8) top = fig.add_axes([left,.44,width,.25 if mobile else .30]) bottom = fig.add_axes([left,.12,width,.22 if mobile else .23]) x = np.arange(6);d = data['drawdown'] top.plot(x,d['linked_wealth'],'o-',color=PALETTE['blue'],ms=5,label='Linked wealth',zorder=3) top.plot(x,d['linked_peak'],color=PALETTE['ink'],ls=(0,(3,3)),lw=1.4,label='Running peak') top.set_ylim(.97,1.135);top.set_ylabel('Wealth');top.set_xticks([]);top.set_yticks([1,1.05,1.10]);tidy(top,'y') top.text(1,1.123,'Fold A',ha='center',fontsize=10);top.text(4,1.123,'Fold B',ha='center',fontsize=10) top.legend(loc='upper left',bbox_to_anchor=(-.04,1.25 if mobile else 1.19),ncol=2,frameon=False,fontsize=9) bottom.plot(x,-100*np.array(d['linked_drawdown']),'-o',color=PALETTE['gold'],ms=5,label='Linked') bottom.plot(x,-100*np.array(d['local_drawdown']),'--o',color=PALETTE['blue'],mfc='white',ms=5,label='Fold-local') bottom.set_ylim(-12.8,1);bottom.set_ylabel('Below peak (%)');bottom.set_yticks([0,-5,-10]);tidy(bottom,'y') bottom.set_xticks(x,labels=['Start','1h','2h','Start','1h','2h'],fontsize=9) bottom.set_xlabel('Ordered account marks') bottom.legend(loc='upper left',bbox_to_anchor=(-.04,1.25 if mobile else 1.24),ncol=2,frameon=False,fontsize=9) for ax in (top,bottom): ax.set_xlim(-.25,5.25);ax.axvline(2.5,color=PALETTE['grid'],ls='--',lw=1) fig.text(.075,.032,'Maximum: 5.45% within folds; 10.18% on the linked path.',fontsize=9,color=PALETTE['muted']) return fig def gbm_plot(data,mobile): fig = figure('RN-04','GBM paths and marginal quantiles','S₀ = 100; μ = 6%; σ = 20%; one model year.',mobile,6.0 if mobile else 5.0) ax = fig.add_axes([.17 if mobile else .12,.16,.75 if mobile else .81,.48 if mobile else .55]) d = data['gbm'];x = d['time_years'] ax.fill_between(x,d['fifth_percentile'],d['ninety_fifth_percentile'],color=PALETTE['blue_light'],label='5th–95th percentiles') for i in range(4): ax.plot(x,np.array(d['paths'])[:,i],color=PALETTE['grey'],lw=.75,alpha=.70,label='Four sampled paths' if i == 0 else None) ax.plot(x,d['median'],color=PALETTE['blue'],lw=2.1,label='Median') ax.plot(x,d['mean'],color=PALETTE['gold'],lw=1.9,ls='--',label='Mean') ax.set_xlabel('Model time (years)');ax.set_ylabel('Price units');ax.set_xlim(0,1) ax.set_xticks([0,.25,.5,.75,1]);tidy(ax,'y') handles,labels = ax.get_legend_handles_labels() ax.legend(handles,labels,loc='upper left',bbox_to_anchor=(-.04,1.25 if mobile else 1.21),ncol=2,frameon=False,fontsize=8.6 if mobile else 9.5) fig.text(.075,.035,'The shaded band is pointwise, not whole-path coverage.',fontsize=9,color=PALETTE['muted']) return fig def matrix(ax,x,y,w,h,rows,cols,color,diagonal=False): ax.add_patch(Rectangle((x,y),w,h,facecolor='white',edgecolor=color,lw=1.3)) if diagonal: count = min(rows,cols) for i in range(count): ax.add_patch(Rectangle((x+i*w/count,y+h-(i+1)*h/count),w/count,h/count,facecolor=color,edgecolor='white',lw=.5)) else: ax.add_patch(Rectangle((x,y),w,h,facecolor=color,alpha=.12,lw=0)) for i in range(1,cols): ax.plot([x+i*w/cols]*2,[y,y+h],color=color,lw=.45,alpha=.5) for i in range(1,rows): ax.plot([x,x+w],[y+i*h/rows]*2,color=color,lw=.45,alpha=.5) def factor_plot(data,mobile): fig = figure('RN-04','Factor covariance at 10,000 assets','Σ = AAᵀ + D; N = 10,000, K = 50. Blocks are schematic.',mobile,7.0 if mobile else 5.4) ax = fig.add_axes([.045,.09,.91,.64 if mobile else .68]);ax.set_xlim(0,10);ax.set_ylim(0,7);ax.axis('off') if mobile: matrix(ax,.7,3.9,3.6,2.3,10,10,PALETTE['blue']) ax.text(5.0,5.8,'Dense Σ',fontsize=13,fontweight='semibold') ax.text(5.0,5.5,'10,000 × 10,000\n100 million entries\n800 MB',va='top',fontsize=10.5,linespacing=1.7) ax.add_patch(FancyArrowPatch((5,3.55),(5,3.0),arrowstyle='-|>',mutation_scale=14,color=PALETTE['muted'])) matrix(ax,.8,1.0,1.1,1.65,10,3,PALETTE['blue']) matrix(ax,2.35,1.82,2.3,.83,3,10,PALETTE['blue']) ax.text(2.0,2.12,'×',ha='center',va='center',fontsize=16) ax.text(5.3,1.88,'+',ha='center',va='center',fontsize=18) matrix(ax,6.1,1.0,2.6,1.65,10,10,PALETTE['gold'],diagonal=True) ax.text(1.35,.72,'A',ha='center',fontsize=12);ax.text(3.5,1.45,'Aᵀ',ha='center',fontsize=12);ax.text(7.4,.72,'D',ha='center',fontsize=12) ax.text(5,-.02,'Store A and diag(D): 4.08 MB',ha='center',fontsize=12,fontweight='semibold') else: matrix(ax,.1,2.1,2.9,3.2,10,10,PALETTE['blue']) ax.text(1.55,5.8,'Dense Σ',ha='center',fontsize=13,fontweight='semibold') ax.text(1.55,1.65,'10,000 × 10,000',ha='center',fontsize=11) ax.text(1.55,.95,'800 MB',ha='center',fontsize=15,fontweight='semibold') ax.text(3.4,3.7,'=',ha='center',va='center',fontsize=22) matrix(ax,3.9,2.1,.75,3.2,10,3,PALETTE['blue']) matrix(ax,5.08,4.5,2.35,.8,3,10,PALETTE['blue']) ax.text(4.84,4.9,'×',ha='center',va='center',fontsize=17) ax.text(7.73,3.7,'+',ha='center',va='center',fontsize=22) matrix(ax,8.1,2.1,1.7,3.2,10,10,PALETTE['gold'],diagonal=True) ax.text(4.28,1.65,'A\n10,000 × 50',ha='center',va='top',fontsize=10.5,linespacing=1.5) ax.text(6.25,4.0,'Aᵀ\n50 × 10,000',ha='center',va='top',fontsize=10.5,linespacing=1.5) ax.text(8.95,1.65,'D\ndiagonal',ha='center',va='top',fontsize=10.5,linespacing=1.5) ax.text(6.83,.05,'A and diag(D): 4.08 MB',ha='center',fontsize=14,fontweight='semibold') fig.text(.075,.033,'Float64 payload only; solver and temporary storage are additional.',fontsize=8.6 if mobile else 9,color=PALETTE['muted']) return fig def tails_plot(data,mobile): fig = figure('RN-05','Two loss distributions with equal variance','Gaussian and variance-one Student-t₅; invented loss units.',mobile,8.0 if mobile else 5.3) if mobile: axes = [fig.add_axes([.18,.43,.75,.26]),fig.add_axes([.18,.10,.75,.22])] else: axes = [fig.add_axes([.10,.18,.38,.51]),fig.add_axes([.60,.18,.35,.51])] d = data['tails'];x = np.array(d['loss']) for prefix,label,color,style in (('normal','Gaussian',PALETTE['blue'],'-'),('student','Student-t₅',PALETTE['gold'],'--')): axes[0].plot(x,d[prefix+'_density'],color=color,ls=style,label=label) positive = x >= 0 axes[1].plot(x[positive],np.array(d[prefix+'_survival'])[positive],color=color,ls=style,label=label) axes[0].set_xlim(-4.5,4.5);axes[0].set_ylim(0,.52);axes[0].set_ylabel('Probability density') axes[0].set_xticks([-4,-2,0,2,4]);axes[0].set_xlabel('Loss (arbitrary units)');tidy(axes[0],'y') axes[1].set_xlim(0,4.5);axes[1].set_yscale('log');axes[1].set_ylim(1e-6,1) axes[1].set_yticks([1,.01,.0001,.000001],labels=['100%','1%','0.01%','0.0001%']) axes[1].set_title('Exceedance probability (log scale)',loc='left',fontsize=10,pad=10) axes[1].set_xlabel('Loss threshold x');tidy(axes[1],'y') axes[1].axhline(.01,color=PALETTE['ink'],ls=(0,(2,3)),lw=1) axes[1].plot([d['normal_var']],[.01],'o',mfc='white',mec=PALETTE['blue'],ms=6) axes[1].plot([d['student_var']],[.01],'s',mfc='white',mec=PALETTE['gold'],ms=5.5) axes[1].text(4.35,.013,'1% exceedance',ha='right',va='bottom',fontsize=8.7) axes[0].legend(loc='upper left',bbox_to_anchor=(-.02,1.20 if mobile else 1.22),ncol=2,frameon=False,fontsize=9) fig.text(.075,.025,'99% ES: Gaussian 2.6652; Student-t₅ 3.4488.',fontsize=9,color=PALETTE['muted']) return fig PLOTS = {'holm':holm_plot,'costs':costs_plot,'drawdown':drawdown_plot,'gbm':gbm_plot,'factor':factor_plot,'tails':tails_plot} def safe_svg(path,title,description): """Keep generated vector geometry; replace inline style with SVG attributes.""" ns = 'http://www.w3.org/2000/svg' ET.register_namespace('',ns);ET.register_namespace('xlink','http://www.w3.org/1999/xlink') root = ET.fromstring(path.read_bytes()) for parent in list(root.iter()): for child in list(parent): if child.tag.split('}')[-1] in ('style','metadata'): parent.remove(child) inline = parent.attrib.pop('style','') for decl in inline.split(';'): if ':' in decl: key,value = decl.split(':',1) parent.set(key.strip(),value.strip()) root.set('stroke-linejoin','round');root.set('stroke-linecap','butt') root.set('role','img');root.set('aria-labelledby','figure-title figure-description') tnode = ET.Element('{'+ns+'}title',{'id':'figure-title'});tnode.text = title dnode = ET.Element('{'+ns+'}desc',{'id':'figure-description'});dnode.text = description root.insert(0,dnode);root.insert(0,tnode) path.write_bytes(ET.tostring(root,encoding='utf-8',xml_declaration=True)) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output-dir',type=Path,default=Path('figures')) args = parser.parse_args();args.output_dir.mkdir(parents=True,exist_ok=True) data = build_data() for contract in CONTRACTS: for mobile in (False,True): name = contract['id']+('_mobile' if mobile else '')+'_v1' fig = PLOTS[contract['id']](data,mobile) fig.savefig(args.output_dir/(name+'.png'),dpi=160) svg = args.output_dir/(name+'.svg') fig.savefig(svg,metadata={'Date':None,'Creator':'PSIM','Title':contract['question']}) safe_svg(svg,contract['question'],contract['takeaway']+' '+contract.get('limitation','Synthetic example.')) plt.close(fig) (args.output_dir/'figures_data_v1.json').write_text(json.dumps(data,indent=2,allow_nan=False)+'\n',encoding='utf-8') (args.output_dir/'figure_contracts_v1.json').write_text(json.dumps(CONTRACTS,indent=2)+'\n',encoding='utf-8') print(json.dumps({'status':'passed','figures':len(CONTRACTS),'layouts_per_figure':2,'checks':data['checks']},indent=2)) if __name__ == '__main__': main()