"""ECB EUR/USD reference-rate period sensitivity; Python standard library only. Run: python analyze.py [--fetch]. Default: verify and analyse the frozen snapshot. ECB source values are USD per EUR. Derived changes are not trading returns. """ from pathlib import Path from datetime import date, datetime, timezone import argparse, csv, io, json, hashlib, math, statistics, urllib.request from collections import Counter B=Path(__file__).resolve().parent URL="https://data-api.ecb.europa.eu/service/data/EXR/D.USD.EUR.SP00.A?startPeriod=2016-01-01&endPeriod=2025-12-31&format=csvdata" RAW=B/"ecb-eurusd-2016-2025-raw.csv" def sha(p): return hashlib.sha256(p.read_bytes()).hexdigest() def dump(name,obj): (B/name).write_text(json.dumps(obj,ensure_ascii=False,indent=2)+"\n",encoding="utf-8") def fetch(): req=urllib.request.Request(URL,headers={"Accept":"text/csv","User-Agent":"Forex-podstawy-reproducible-research/1.0"}) with urllib.request.urlopen(req,timeout=30) as r: raw=r.read(); status=r.status; headers=dict(r.headers.items()); response_url=r.url assert status==200 and b"TIME_PERIOD" in raw and b"OBS_VALUE" in raw RAW.write_bytes(raw) dump("snapshot.json",{"source":"European Central Bank","series":"EXR.D.USD.EUR.SP00.A","unit":"USD per EUR","request_url":URL,"response_url":response_url,"retrieved_at_utc":datetime.now(timezone.utc).isoformat(),"http_status":status,"response_headers":headers,"bytes":len(raw),"sha256":sha(RAW),"data_period_requested":["2016-01-01","2025-12-31"]}) def quantile(xs,p): s=sorted(xs); pos=(len(s)-1)*p; lo=math.floor(pos); hi=math.ceil(pos) return s[lo]+(s[hi]-s[lo])*(pos-lo) def changes(obs): return [{"date":d.isoformat(),"previous_date":pd.isoformat(),"calendar_days":(d-pd).days,"rate_usd_per_eur":v,"previous_rate_usd_per_eur":pv,"simple_change_pct":100*(v/pv-1),"log_change_pct":100*math.log(v/pv)} for (pd,pv),(d,v) in zip(obs,obs[1:])] def distribution(xs): return {"count":len(xs),"mean_pct":statistics.fmean(xs),"population_stdev_pct":statistics.pstdev(xs),"sample_stdev_pct":statistics.stdev(xs),"mean_absolute_pct":statistics.fmean(abs(x) for x in xs),"min_pct":min(xs),"max_pct":max(xs),"quantiles_pct":{str(p):quantile(xs,p) for p in [.01,.05,.25,.5,.75,.95,.99]}} def drawdown(obs): peak=obs[0][1];peak_index=0;best=0.0;peak_for_best=0;trough_index=0;path=[] for i,(d,v) in enumerate(obs): if v>peak:peak=v;peak_index=i dd=100*(v/peak-1);path.append(dd) if -dd>best:best=-dd;peak_for_best=peak_index;trough_index=i pd,pv=obs[peak_for_best];td,tv=obs[trough_index] recovery=next((d.isoformat() for d,v in obs[trough_index+1:] if v>=pv),None) return {"maximum_decline_pct":best,"peak_date":pd.isoformat(),"peak_rate":pv,"trough_date":td.isoformat(),"trough_rate":tv,"calendar_days_peak_to_trough":(td-pd).days,"first_recovery_date_within_selected_period":recovery,"end_drawdown_pct":path[-1]},path BIN_EDGES=[-math.inf,-1,-.5,-.25,0,.25,.5,1,math.inf] def histogram(xs): labels=["less than -1%","[-1%, -0.5%)","[-0.5%, -0.25%)","[-0.25%, 0%)","[0%, 0.25%)","[0.25%, 0.5%)","[0.5%, 1%)","at least 1%"] return [{"label":label,"lower":None if not math.isfinite(a) else a,"upper":None if not math.isfinite(b) else b,"count":sum(a<=x1 for r in simple),"simple_absolute_gt_1pct_share":sum(abs(r)>1 for r in simple)/len(simple),"unchanged_rate_count":sum(r==0 for r in simple),"calendar_day_gaps":dict(sorted(Counter(r["calendar_days"] for r in cc).items())),"max_drawdown_reference_rate":dd,"minimum_change_observation":min(cc,key=lambda r:r["simple_change_pct"]),"maximum_change_observation":max(cc,key=lambda r:r["simple_change_pct"]),"log_histogram":histogram(logs)},cc,path def csvwrite(name,rows): with (B/name).open("w",encoding="utf-8",newline="") as f: w=csv.DictWriter(f,fieldnames=list(rows[0]));w.writeheader();w.writerows(rows) def svg(results,obs): # Data charts only; no imported graphics, logos or images. English text >=24px. parts=['','ECB EUR/USD reference-rate risk: period comparison','Historical reference-rate index and log-change distribution. Changes compare consecutive published observations, not necessarily 24 hours. Drawdown measures the reference rate, not trader equity.','','','ECB EUR/USD: the selected period changes the risk picture','USD per EUR reference rates; first observation = 100'] left,right,top,bottom=100,1140,130,440 indexed=[100*v/obs[0][1] for d,v in obs];ymin,ymax=85,120 x=lambda i:left+(right-left)*(obs[i][0]-obs[0][0]).days/(obs[-1][0]-obs[0][0]).days;y=lambda v:bottom-(v-ymin)/(ymax-ymin)*(bottom-top) for value in range(85,121,5):parts.extend([f'',f'{value}']) parts.append('') for year in [2016,2018,2020,2022,2024]: i=next(i for i,(d,v) in enumerate(obs) if d.year==year);parts.append(f'{year}') parts.append('Log changes (%), between observations: counts') a,b=results[0],results[1];maxcount=max(r['count'] for q in [a,b] for r in q['log_histogram']);histtop,histbottom=590,805 for value in [0,100,200,300]: yy=histbottom-(histbottom-histtop)*value/maxcount parts.extend([f'',f'{value}']) for i,(ra,rb) in enumerate(zip(a['log_histogram'],b['log_histogram'])): xx=110+i*127 for j,r in enumerate([ra,rb]): pct=100*r['count']/[a,b][j]['changes'];h=(histbottom-histtop)*r['count']/maxcount;fill=['#5edcca','#ffb56b'][j] parts.append(f'') labels=['<-1','-1:-.5','-.5:-.25','-.25:0','0:.25','.25:.5','.5:1','>=1'];parts.append(f'{labels[i]}') parts.extend(['2016–2020','2021–2025',f'Rate drawdown: {a["max_drawdown_reference_rate"]["maximum_decline_pct"]:.2f}% / {b["max_drawdown_reference_rate"]["maximum_decline_pct"]:.2f}%','Source: ECB. Calculations: Forex-podstawy.pl, 7 October 2026.','No leverage, trading costs or execution simulation; not strategy returns.','']) (B/'period-risk.svg').write_text('\n'.join(parts),encoding='utf-8') # Explicit bin denominators alongside count-scaled chart to prevent count/proportion ambiguity. # The two sample sizes differ only slightly but the SVG axis must say count if count height is used. p=B/'period-risk.svg';t=p.read_text(encoding='utf8').replace('distribution (%)','distribution (counts)');p.write_text(t,encoding='utf8') translations={ 'ECB EUR/USD reference-rate risk: period comparison':'ECB EUR/USD: porównanie ryzyka w dwóch okresach', 'Historical reference-rate index and log-change distribution. Changes compare consecutive published observations, not necessarily 24 hours. Drawdown measures the reference rate, not trader equity.':'Indeks kursu referencyjnego i rozkład zmian logarytmicznych. Zmiany pomiędzy publikacjami nie zawsze obejmują 24 godziny. Obsunięcie dotyczy kursu, nie kapitału tradera.', 'ECB EUR/USD: the selected period changes the risk picture':'ECB EUR/USD: wybór okresu zmienia obraz ryzyka', 'USD per EUR reference rates; first observation = 100':'Kurs referencyjny USD za 1 EUR; pierwsza obserwacja = 100', 'Log changes (%), between observations: counts':'Zmiany logarytmiczne (%): liczba obserwowanych zmian', 'Rate drawdown:':'Obsunięcie:', 'Source: ECB. Calculations: Forex-podstawy.pl, 7 October 2026.':'Źródło: ECB. Obliczenia: Forex-podstawy.pl, 7 października 2026.', 'No leverage, trading costs or execution simulation; not strategy returns.':'Bez dźwigni, kosztów i symulacji wykonania; to nie wynik strategii.' } for old,new in translations.items():t=t.replace(old,new) import re t=re.sub(r'(]*>)([^<]*)()',lambda m:m[1]+m[2].replace('.25',',25').replace('.5',',5').replace('.30%',',30%').replace('.48%',',48%')+m[3],t) (B/'period-risk-pl.svg').write_text(t,encoding='utf8') def main(): ap=argparse.ArgumentParser();ap.add_argument('--fetch',action='store_true');args=ap.parse_args() if args.fetch:fetch() manifest=json.loads((B/'snapshot.json').read_text(encoding='utf8'));assert sha(RAW)==manifest['sha256'],'snapshot SHA mismatch' rawrows=list(csv.DictReader(io.StringIO(RAW.read_text(encoding='utf-8-sig'))));obs=[] for r in rawrows: assert r['KEY']=='EXR.D.USD.EUR.SP00.A' and r['CURRENCY']=='USD' and r['CURRENCY_DENOM']=='EUR' and r['UNIT']=='USD' and r['UNIT_MULT']=='0' d=date.fromisoformat(r['TIME_PERIOD']);v=float(r['OBS_VALUE']);assert date(2016,1,1)<=d<=date(2025,12,31) and math.isfinite(v) and v>0 obs.append((d,v)) assert len({d for d,v in obs})==len(obs) and all(a[0]=2021]),('2016-2025',obs)] results=[];derived=[] for label,subset in periods: result,cc,dd=summarize(label,subset);results.append(result) csvwrite('changes-'+label+'.csv',cc) if label=='2016-2025': peak=0 for (d,v),dv in zip(subset,dd): peak=max(peak,v);derived.append({'date':d.isoformat(),'rate_usd_per_eur':format(v,'.4f'),'index_first_observation_100':format(100*v/subset[0][1],'.12f'),'running_peak_usd_per_eur':format(peak,'.4f'),'reference_rate_drawdown_pct':format(dv,'.12f')}) metrics={'series':'EXR.D.USD.EUR.SP00.A','raw_sha256':sha(RAW),'unit':'USD per EUR','split_policy':'Each five-year subperiod resets its predecessor at its own first observation: no 2020-2021 bridging change in 2021-2025. Full 2016-2025 period includes that one bridge. No pre-2016 rate added. Drawdown peak resets at each subperiod start.','stdev_policy':'Headline population standard deviation (denominator n) describes these observed changes. Sample standard deviation (n-1) also supplied. Neither is annualised or an estimate of future trading risk.','quantile_policy':'Linear interpolation at (n-1)*p (type 7); no resampling or normal distribution fit.','change_policy':'100*(P_t/P_previous-1) and 100*ln(P_t/P_previous), between consecutive published observations, not necessarily 24 hours.','drawdown_policy':'Largest percentage decline of USD-per-EUR reference rate from an earlier observed running peak within the selected period; no equity, leverage, spread, financing or intraday path.','quality':{'rows':len(obs),'duplicates':0,'nonpositive_values':0,'nonfinite_values':0,'observation_status_counts':dict(Counter(r['OBS_STATUS'] for r in rawrows)),'filled_missing_dates':0,'note':'No calendar completion or missing-weekend inference; changes use available consecutive observations.'},'periods':results} dump('metrics.json',metrics);csvwrite('reference-rate-path.csv',derived);svg(results,obs) print(json.dumps({'raw_sha256':sha(RAW),'rows':len(obs),'periods':[{'period':r['period'],'n_changes':r['changes'],'simple_pop_sd_pct':r['simple']['population_stdev_pct'],'log_pop_sd_pct':r['log']['population_stdev_pct'],'gt1pct':r['simple_absolute_gt_1pct_count'],'mdd':r['max_drawdown_reference_rate']} for r in results]},indent=2)) if __name__=='__main__':main()