"""Article 6: frozen ETF proxy analysis, no network; matplotlib only for --plots.""" import argparse import csv import datetime as dt import hashlib import io import json import math from pathlib import Path import statistics if __package__: from .analyze_long_term import metrics, table, pct from .simulate_nisa import future_value, yen else: from analyze_long_term import metrics, table, pct from simulate_nisa import future_value, yen ROOT=Path(__file__).resolve().parents[1] DATA=ROOT/'articles/data/sp500-all-country' ARTICLE=ROOT/'articles/sp500-vs-all-country.html' START,END='','' LABEL={'spy':'SPY(S&P500連動ETF)','acwi':'ACWI(全世界株式ETF)'} def load_data(): manifest=json.loads((DATA/'sources.json').read_text()) payload=(DATA/'observations.csv').read_bytes() if hashlib.sha256(payload).hexdigest()!=manifest['input_sha256']: raise ValueError('CSV hash mismatch') rows=list(csv.DictReader(io.StringIO(payload.decode()))) if [r['date'] for r in rows]!=sorted({r['date'] for r in rows}): raise ValueError('Invalid dates') for row in rows: dt.date.fromisoformat(row['date']) for k,v in row.items(): if k!='date' and v and (not math.isfinite(float(v)) or float(v)<=0): raise ValueError('Invalid value') common=[r for r in rows if all(r.values())] if common[0]['date']!='2016-09-30' or common[-1]['date']!='2026-09-30' or len(common)<2400: raise ValueError('Missing window') return common def month_ends(rows): ends={} for row in rows: ends[row['date'][:7]]=row return list(ends.values()) def analyze(): rows=load_data();primary=json.loads((DATA/'primary-facts.json').read_text()) result=dict(start=rows[0]['date'],end=rows[-1]['date'],common_dates=len(rows),windows={},annual=[],historical_monthly={},hypothetical=[]) for years,start in [(5,'2021-09-30'),(10,'2016-09-30')]: subset=[r for r in rows if r['date']>=start] if subset[0]['date']!=start: raise ValueError('Window baseline missing') dates=[r['date'] for r in subset] window=dict(start=start,end=dates[-1],observations=len(subset),assets={}) for key in LABEL: usd=[float(r[key+'_adjusted_usd']) for r in subset] jpy=[float(r[key+'_adjusted_usd'])*float(r['usd_jpy']) for r in subset] month_rows=month_ends(subset) prices=[float(r[key+'_adjusted_usd'])*float(r['usd_jpy']) for r in month_rows] changes=[b/a-1 for a,b in zip(prices,prices[1:])] asset=dict(usd=metrics(dates,usd),jpy=metrics(dates,jpy),final_jpy=1e6*jpy[-1]/jpy[0], monthly_volatility_jpy=statistics.stdev(changes)*math.sqrt(12),monthly_returns_count=len(changes), official_market_cagr_percent=primary[key]['official_market_cagr_percent'][str(years)]) asset['difference_from_official_percentage_points']=asset['usd']['cagr']*100-asset['official_market_cagr_percent'] window['assets'][key]=asset result['windows'][str(years)]=window base=0 for year in range(2016,2027): end=max(i for i,r in enumerate(rows) if r['date'][:4]==str(year)) record=dict(year=year,start=rows[base]['date'],end=rows[end]['date'],partial=year in (2016,2026),returns={}) for key in LABEL: record['returns'][key]={ccy:float(rows[end][key+'_adjusted_usd'])/float(rows[base][key+'_adjusted_usd'])*(float(rows[end]['usd_jpy'])/float(rows[base]['usd_jpy']) if ccy=='jpy' else 1)-1 for ccy in ['usd','jpy']} result['annual'].append(record);base=end ends=month_ends(rows)[1:] if len(ends)!=120: raise ValueError('Expected 120 month-end contributions') for key in LABEL: units=sum(30000/(float(r[key+'_adjusted_usd'])*float(r['usd_jpy'])) for r in ends) final=units*float(rows[-1][key+'_adjusted_usd'])*float(rows[-1]['usd_jpy']) result['historical_monthly'][key]=dict(contributions=120,first_date=ends[0]['date'],last_date=ends[-1]['date'],principal_yen=3600000,final_yen=round(final),gain_yen=round(final)-3600000) for years in [10,20,30]: for rate in [0,3,5,7]: final=yen(future_value(30000,rate,years*12));principal=30000*years*12 result['hypothetical'].append(dict(years=years,annual_percent=rate,principal_yen=principal,final_yen=final,gain_yen=final-principal)) return result,rows,primary def render(result,primary): sections=[] for years,w in result['windows'].items(): sections.append(table(f'過去約{years}年:{w["start"]}〜{w["end"]}、共通{w["observations"]}観測日', ['ETF代理系列','CAGR USD','CAGR JPY','100万円期末 JPY','最大DD JPY','月次変動率の年率換算 JPY'], [(LABEL[k],pct(a['usd']['cagr']),pct(a['jpy']['cagr']),f'{a["final_jpy"]:,.0f}円',pct(a['jpy']['maximum_drawdown']),f'{a["monthly_volatility_jpy"]*100:.2f}%') for k,a in w['assets'].items()])) sections.append('

公式資料との照合

') sections.append(table('公式公表値との照合:年率、米ドル建て、2026年9月30日終点', ['ETF','期間','公式Market Value/Price','独自の調整後終値計算','差(ポイント)'], [(k.upper(),years+'年',f'{a["official_market_cagr_percent"]:.2f}%',f'{a["usd"]["cagr"]*100:.2f}%',f'{a["difference_from_official_percentage_points"]:+.3f}') for years,w in result['windows'].items() for k,a in w['assets'].items()])) sections.append('

年別騰落率と部分期間

') sections.append(table('年別騰落率:配当等調整後、円換算。2016・2026年は部分期間', ['年','開始日','終了日','SPY','ACWI'],[(str(r['year'])+('(部分)' if r['partial'] else ''),r['start'],r['end'],pct(r['returns']['spy']['jpy']),pct(r['returns']['acwi']['jpy'])) for r in result['annual']])) sections.append('

過去系列による月3万円の積立比較

') sections.append(table('実際の過去10年系列で月3万円:2016年10月〜2026年9月の月末120回', ['代理ETF','積立元本','期末評価額','差額'],[(k.upper(),f'{a["principal_yen"]:,}円',f'{a["final_yen"]:,}円',f'{a["gain_yen"]:+,}円') for k,a in result['historical_monthly'].items()])) sections.append('

月3万円を10・20・30年:利回りを仮定した比較

') sections.append(table('仮定計算:月3万円・月末積立。両指数に共通の計算であり予測ではない', ['期間','仮定実効年率','元本','想定評価額','増減額'],[(str(a['years'])+'年',str(a['annual_percent'])+'%',f'{a["principal_yen"]:,}円',f'{a["final_yen"]:,}円',f'{a["gain_yen"]:+,}円') for a in result['hypothetical']])) return '\n'.join(sections) def render_composition(p): top=table('両ETFの上位10証券:2026年9月30日、発行会社資料の丸め値', ['順位','SPY 銘柄','比率','ACWI 銘柄','比率'],[(i+1,p['spy']['top10'][i][0],f'{p["spy"]["top10"][i][1]:.2f}%',p['acwi']['top10'][i][0],f'{p["acwi"]["top10"][i][1]:.2f}%') for i in range(10)]) translations={'Information Technology':'情報技術','Financials':'金融','Communication Services':'コミュニケーション','Health Care':'ヘルスケア','Consumer Discretionary':'一般消費財','Industrials':'資本財・サービス','Consumer Staples':'生活必需品','Energy':'エネルギー','Utilities':'公益','Real Estate':'不動産','Materials':'素材','Other':'その他(内訳未確認)'} sectors=[] for key,label in translations.items(): fmt=lambda a:f'{a:.2f}%' if a is not None else '個別内訳なし' sectors.append((label,fmt(p['spy']['sectors'].get(key)),fmt(p['acwi']['sectors'].get(key)))) return top+'\n'+table('業種構成:2026年9月30日、ETF発行会社資料', ['業種','SPY','ACWI'],sectors) def outputs(): result,rows,primary=analyze() text=ARTICLE.read_text() for start,end,fragment in [(START,END,render(result,primary)),('','',render_composition(primary))]: if text.count(start)!=1 or text.count(end)!=1: raise ValueError('Markers missing/duplicated') text=text[:text.index(start)+len(start)]+'\n'+fragment+'\n'+text[text.index(end):] files={'results.json':json.dumps(result,ensure_ascii=False,indent=2,allow_nan=False)+'\n'} buffer=io.StringIO(newline='');writer=csv.DictWriter(buffer,fieldnames=list(result['hypothetical'][0]),lineterminator='\n');writer.writeheader();writer.writerows(result['hypothetical']);files['hypothetical.csv']=buffer.getvalue() for name in ['analyze_equity_comparison.py','fetch_equity_comparison.py','audit_equity_comparison.py']: files[name+'.txt']=(ROOT/'scripts'/name).read_text() return files,text def plots(): import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import matplotlib.dates as md result,rows,p=analyze() plt.rcParams.update({'svg.fonttype':'none','svg.hashsalt':'article6-v1','font.size':12}) dates=[dt.date.fromisoformat(r['date']) for r in rows] fig,(ax,dd)=plt.subplots(2,1,figsize=(9,7),sharex=True,gridspec_kw={'height_ratios':[2,1]}) for key,color in [('spy','#176b57'),('acwi','#a66a12')]: vals=[float(r[key+'_adjusted_usd'])*float(r['usd_jpy']) for r in rows];peak=vals[0];draw=[] for v in vals:peak=max(peak,v);draw.append((v/peak-1)*100) ax.plot(dates,[v/vals[0]*100 for v in vals],label=key.upper(),color=color,linewidth=1.4) dd.plot(dates,draw,color=color,linewidth=1) ax.set(title='ETF proxies: JPY 1,000,000 lump sum | 2016-09-30 to 2026-09-30',ylabel='Value (JPY 10,000)');ax.legend();dd.set(ylabel='Drawdown (%)',ylim=(-45,3));dd.xaxis.set_major_locator(md.YearLocator(2));dd.xaxis.set_major_formatter(md.DateFormatter('%Y')) for a in [ax,dd]:a.grid(alpha=.2);a.margins(x=0) fig.tight_layout();fig.savefig(DATA/'historical.svg',metadata={'Date':None});fig.savefig(DATA/'historical.png',dpi=160);plt.close(fig) fig,ax=plt.subplots(figsize=(9,5.4)) for rate in [0,3,5,7]:ax.plot([i/12 for i in range(361)],[float(future_value(30000,rate,i))/10000 for i in range(361)],label=f'{rate}% hypothetical',linewidth=2) ax.set(title='JPY 30,000/month | hypothetical constant effective annual rates',xlabel='Years',ylabel='Value (JPY 10,000)',xlim=(0,30),ylim=(0,None));ax.legend();ax.grid(alpha=.2);fig.tight_layout();fig.savefig(DATA/'hypothetical.svg',metadata={'Date':None});fig.savefig(DATA/'hypothetical.png',dpi=160);plt.close(fig) fig,ax=plt.subplots(figsize=(9,5.4)) sectors=['Information Technology','Financials','Industrials','Health Care','Consumer Discretionary'] for offset,key,color in [(-.18,'spy','#176b57'),(.18,'acwi','#a66a12')]:ax.barh([i+offset for i in range(5)],[p[key]['sectors'][s] for s in sectors],height=.34,label=key.upper(),color=color) ax.set_yticks(range(5),sectors);ax.invert_yaxis();ax.set(xlabel='Weight (%)',title='ETF sector weights | 2026-09-30');ax.legend();ax.grid(axis='x',alpha=.2);fig.tight_layout();fig.savefig(DATA/'sectors.svg',metadata={'Date':None});fig.savefig(DATA/'sectors.png',dpi=160);plt.close(fig) for path in DATA.glob('*.svg'): path.write_text('\n'.join(line.rstrip() for line in path.read_text().splitlines())+'\n') def main(): parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--check',action='store_true');parser.add_argument('--plots',action='store_true');a=parser.parse_args() files,text=outputs() if a.check: if ARTICLE.read_text()!=text or any((DATA/name).read_text()!=value for name,value in files.items()):raise SystemExit('Generated equity outputs are stale') print('Equity outputs reproduce exactly') else: ARTICLE.write_text(text) for name,value in files.items():(DATA/name).write_text(value) if a.plots:plots() if __name__=='__main__':main()